Skip to content

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:

apps/RainWarning.php
<?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;
}
}
$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.

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.

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]);
}

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.

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:

Terminal window
make run ONLY=rain-warning DRY_RUN=1

The 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.

The test harness records every call. stubServiceAnswer() sets what a service answers, and stubServiceFailure() makes it fail:

tests/RainWarningTest.php
<?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().

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.