Skip to content

State history

getState() tells you what an entity is doing now. getHistory() asks Home Assistant’s recorder what it did before. This app turns off the hall light when nobody has moved there for half an hour:

apps/ForgottenLight.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\History\HistoryQuery;
use Stewart\Contracts\Schedule\Scheduler;
use Stewart\Contracts\Service\ServiceTarget;
use Stewart\Contracts\Time\Duration;
#[Automation(id: 'forgotten-light')]
final readonly class ForgottenLight implements App
{
public function __construct(
private HaContext $ha,
private Scheduler $scheduler,
private string $motionSensor = 'binary_sensor.hall_motion',
private string $light = 'light.hall',
) {}
public function initialize(): void
{
$this->scheduler->runEvery(Duration::minutes(15), function (): void {
if ($this->ha->getState($this->light)?->state !== 'on') {
return;
}
$motion = $this->ha->getHistory($this->motionSensor, HistoryQuery::lastFor(Duration::minutes(30)));
if (!$motion->hasBeenIn('on')) {
$this->ha->callService('light', 'turn_off', target: ServiceTarget::forEntities($this->light));
}
});
}
public function dispose(): void {}
}

getHistory() exists on HaContext, as in the example, and on every typed entity.

Query Covers
HistoryQuery::lastFor(Duration::hours(1)) The last hour, up to now
HistoryQuery::between($from, $until) A fixed window, between two Instants

The answer holds one entry per state change, without attributes. Two methods add more:

Method Adds
->withAttributes() The attributes at each state change
->withAttributeChanges() Also the moments when only attributes changed, such as a light’s brightness

A long window with attributes can be larger than home_assistant.message_size_limit, which drops the connection. Keep such windows short.

Method Answers
hasBeenIn('on') Was it in this state (or one of several) at any moment in the window?
getDurationIn('on') For how long, as a Duration
countChanges() How many times the state changed
getLastChangeTo('on') When it last changed to this state, or null
getStateAtStart(), getLastState() The first and the last entry

The first entry is the state the entity was in when the window started. A light that stayed on the whole time still answers hasBeenIn('on'). That first entry does not count as a change.

$history->states holds every entry, oldest first. An entity that the recorder does not keep, or that did not exist yet, has an empty history.

getHistory() asks Home Assistant, so it waits for the answer like callService() and shares its timeout and limits. Do not call it on every change of a busy sensor; a schedule, as in the example, is a better fit.

It throws HistoryException. The reason is RecorderUnavailable when Home Assistant has no recorder, or Rejected, Unreachable, TimedOut or Overloaded, as for service calls.

The test harness keeps a history of every state it sees, at the virtual clock’s time. seedHistoricalState() adds one in the past:

tests/ForgottenLightTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\ForgottenLight;
use PHPUnit\Framework\TestCase;
use Stewart\Contracts\Time\Duration;
use Stewart\Testing\App\AppHarness;
final class ForgottenLightTest extends TestCase
{
private AppHarness $harness;
protected function setUp(): void
{
$this->harness = new AppHarness();
$this->harness->ha->seedState('light.hall', 'on');
$this->harness->ha->seedHistoricalState('binary_sensor.hall_motion', 'off', $this->harness->clock->getNow()->minus(Duration::hours(1)));
$this->harness->startApp(ForgottenLight::class);
}
public function testALightWithoutMotionForHalfAnHourTurnsOff(): void
{
$this->harness->advanceTime(Duration::minutes(15));
self::assertSame('light.turn_off', $this->harness->ha->getLastCall()->getServiceName());
}
public function testRecentMotionKeepsTheLightOn(): void
{
$this->harness->advanceTime(Duration::minutes(10));
$this->harness->ha->pushState('binary_sensor.hall_motion', 'on');
$this->harness->ha->pushState('binary_sensor.hall_motion', 'off');
$this->harness->advanceTime(Duration::minutes(5));
self::assertTrue($this->harness->ha->calls->isEmpty());
}
}

$harness->ha->historyQueries lists the queries the app made, and stubHistoryFailure() makes one entity’s history fail.

The history is empty. The recorder may exclude the entity. Check recorder: in Home Assistant’s configuration.yaml.

RecorderUnavailable. Home Assistant runs without the recorder or history integration.