Calling services
Apps change things in Home Assistant by calling its services, also called actions: light.turn_on,
notify.notify, cover.close_cover. This app reads the weather forecast every morning and warns you when it will rain:
<?php
declare(strict_types=1);
namespace App;
use Psr\Log\LoggerInterface;use Stewart\Contracts\App;use Stewart\Contracts\Automation;use Stewart\Contracts\Exception\ServiceCallException;use Stewart\Contracts\HaContext;use Stewart\Contracts\Schedule\Scheduler;use Stewart\Contracts\Service\ServiceResponse;use Stewart\Contracts\Service\ServiceTarget;
#[Automation(id: 'rain-warning')]final readonly class RainWarning implements App{ public function __construct( private HaContext $ha, private Scheduler $scheduler, private LoggerInterface $logger, private string $weather = 'weather.home', private int $minimumChance = 50, ) {}
public function initialize(): void { $this->scheduler->runDailyAt('07:00', $this->checkTheForecast(...)); }
public function dispose(): void {}
private function checkTheForecast(): void { try { $answer = $this->ha->callServiceForResponse('weather', 'get_forecasts', ['type' => 'daily'], ServiceTarget::forEntities($this->weather)); } catch (ServiceCallException $e) { $this->logger->warning('No forecast today', ['reason' => $e->getMessage()]);
return; }
$chance = $this->readChanceOfRain($answer);
if ($chance >= $this->minimumChance) { $this->ha->callService('notify', 'notify', ['message' => "Take an umbrella: {$chance}% chance of rain."]); } }
// The response is Home Assistant's JSON as arrays, so each level is checked before it is read. private function readChanceOfRain(ServiceResponse $answer): int { $entity = $answer->response[$this->weather] ?? null; $forecast = \is_array($entity) ? ($entity['forecast'] ?? null) : null; $today = \is_array($forecast) ? ($forecast[0] ?? null) : null; $chance = \is_array($today) ? ($today['precipitation_probability'] ?? null) : null;
return is_numeric($chance) ? (int) $chance : 0; }}Calling a service
Section titled “Calling a service”$this->ha->callService('light', 'turn_on', ['brightness_pct' => 60], ServiceTarget::forEntities('light.hall'));The arguments are the domain, the service, the service data, and the target. Data and target are optional. The service names and their data are listed in Home Assistant under Developer tools → Actions.
The target picks what the service acts on:
| Target | Acts on |
|---|---|
ServiceTarget::forEntities('light.hall', 'light.porch') |
Those entities |
ServiceTarget::forAreas('kitchen') |
Everything in the area |
ServiceTarget::forFloors('upstairs') |
Everything on the floor |
ServiceTarget::forLabels('night') |
Everything with the label |
ServiceTarget::forDevices('a1b2c3…') |
Everything on the device |
callService() returns when Home Assistant has answered. Other apps keep running meanwhile. It returns an
EventContext that identifies the call, which Events explains.
Typed entities offer the same calls as methods, such as
$this->entities->light->getEntity('light.hall')->turnOn(), with your editor completing the names.
Reading an answer
Section titled “Reading an answer”Some services answer with data, such as weather.get_forecasts or calendar.get_events. Call those with
callServiceForResponse(). Its response property holds the answer as Home Assistant sends it: an array keyed by
entity ID. The example checks each level of that array before it reads it, because nothing guarantees its shape.
When a call fails
Section titled “When a call fails”A failed call throws ServiceCallException. Its reason says why:
| Reason | Means |
|---|---|
ServiceCallError::Rejected |
Home Assistant refused the call, for example an unknown service or wrong data |
ServiceCallError::Unreachable |
Stewart is not connected to Home Assistant right now. The call is not queued |
ServiceCallError::TimedOut |
Home Assistant did not answer within home_assistant.command_timeout (30 seconds) |
ServiceCallError::Overloaded |
Too many calls were waiting for an answer at once; see below |
If your handler does not catch the exception, Stewart logs it as a failure of the app, and the app keeps running. Catch it where the app can do something useful, as the example does:
try { $this->ha->callService('lock', 'lock', target: ServiceTarget::forEntities('lock.front_door'));} catch (ServiceCallException $e) { $this->logger->warning('Could not lock the door', ['reason' => $e->reason->value]);}Limits
Section titled “Limits”A worker may have 64 calls waiting for an answer at once, and all workers together 256. Further calls fail with
Overloaded until answers come back. A loop that calls a service for hundreds of entities can reach this; use one
call with a target that names all of them instead. The limits are service_calls.max_in_flight_per_worker and
service_calls.max_in_flight.
Dry run
Section titled “Dry run”With dry run on, Stewart logs each call instead of sending it, and answers it as if it succeeded. Use it to try an app against your real Home Assistant without changing anything:
make run ONLY=rain-warning DRY_RUN=1The log shows Service call not sent: service_calls.dry_run is on, with the service, the data and the target. Dry
run is the service_calls.dry_run setting.
Testing
Section titled “Testing”The test harness records every call. stubServiceAnswer() sets what a service answers, and
stubServiceFailure() makes it fail:
<?php
declare(strict_types=1);
namespace App\Tests;
use App\RainWarning;use PHPUnit\Framework\TestCase;use Stewart\Contracts\Exception\ServiceCallException;use Stewart\Contracts\Time\Duration;use Stewart\Testing\App\AppHarness;
final class RainWarningTest extends TestCase{ public function testRainyDaysSendAWarning(): void { $harness = new AppHarness(); $harness->ha->stubServiceAnswer('weather', 'get_forecasts', [ 'weather.home' => ['forecast' => [['condition' => 'rainy', 'precipitation_probability' => 80]]], ]); $harness->startApp(RainWarning::class);
$harness->advanceTime(Duration::hours(7));
self::assertSame('Take an umbrella: 80% chance of rain.', $harness->ha->getLastCall()->data['message'] ?? null); }
public function testAMissingForecastIsLogged(): void { $harness = new AppHarness(); $harness->ha->stubServiceFailure('weather', 'get_forecasts', ServiceCallException::unreachable('weather', 'get_forecasts', 'disconnected')); $harness->startApp(RainWarning::class);
$harness->advanceTime(Duration::hours(7));
self::assertCount(1, $harness->logger->listMessagesAt('warning')); }}$harness->ha->getLastCall() returns the last call, and $harness->ha->calls all of them. Each has
getServiceName() ('notify.notify'), data, and listTargetedEntityIds().
Common problems
Section titled “Common problems”was rejected by Home Assistant. The message carries Home Assistant’s reason. Try the same call under
Developer tools → Actions.
Calls fail with could not reach Home Assistant. Stewart was reconnecting. Check its log for connection
messages, and make status.