Skip to content
These pages describe Stewart 1.x. Read the docs for the latest release, and how to upgrade.

Call actions

Apps change things in Home Assistant by calling its actions, which older Home Assistant releases called services: 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(...));
}
private function checkTheForecast(): void
{
try {
$answer = $this->ha->callActionForResponse('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->callAction('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->callAction('light.turn_on', ['brightness_pct' => 60], ServiceTarget::forEntities('light.hall'));

The arguments are the action, its data, and the target. Data and target are optional. The action is written as Home Assistant writes it, domain.action; Home Assistant lists every action and its data under Developer tools → Actions. A name without exactly one dot throws ServiceCallError::ActionNameInvalid before anything is sent.

callService('light', 'turn_on', …) from Stewart 1.7 and earlier still works, but is deprecated: each call prints a PHP deprecation line, and Stewart 2.0 removes it.

The target picks what the action 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

callAction() 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. An untyped Entity calls its own domain’s actions: $this->ha->getEntity('light.hall')->callAction('turn_on').

Some actions answer with data, such as weather.get_forecasts or calendar.get_events. Call those with callActionForResponse(). 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 action 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->callAction('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 an action 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 action, the data and the target. Dry run is the service_calls.dry_run setting.

The test harness records every call. stubActionAnswer() sets what an action answers, and stubActionFailure() 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->stubActionAnswer('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->stubActionFailure('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 getActionName() ('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.