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:
<?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.
Choosing the time window
Section titled “Choosing the time window”| 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.
Reading the answer
Section titled “Reading the answer”| 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.
Cost and failures
Section titled “Cost and failures”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.
Testing
Section titled “Testing”The test harness keeps a history of every state it sees, at the virtual clock’s time. seedHistoricalState() adds
one in the past:
<?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.
Common problems
Section titled “Common problems”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.