Skip to content

Areas, floors and labels

Entity IDs say what a thing is. Home Assistant’s areas, floors and labels say where it is and what it is for. An EntityFilter picks entities by them. This app turns on an area’s lights when a motion sensor in that area fires:

apps/RoomMotion.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\Registry\EntityFilter;
use Stewart\Contracts\Service\ServiceTarget;
#[Automation(id: 'room-motion')]
final readonly class RoomMotion implements App
{
public function __construct(
private HaContext $ha,
private string $area = 'kitchen',
) {}
public function initialize(): void
{
$this->ha->watchStateChanges(EntityFilter::inArea($this->area)->withDomain('binary_sensor')->withLabel('motion'))
->whenChangedTo('on')
->subscribe(function (): void {
$this->ha->callService('light', 'turn_on', target: ServiceTarget::forAreas($this->area));
});
}
public function dispose(): void {}
}

A filter works wherever entities are chosen: watchStateChanges() and listStates(). Add a new motion sensor to the kitchen in Home Assistant, label it motion, and the app uses it without a change.

Start with one condition and add more with with…():

Start Add Matches
EntityFilter::inArea('kitchen') ->withArea(…) The entity’s area
EntityFilter::onFloor('upstairs') ->withFloor(…) The floor of that area
EntityFilter::labelled('night') ->withLabel(…) A label on the entity, its device or its area
EntityFilter::ofDevice($deviceId) ->withDevice(…) The entity’s device
EntityFilter::inDomain('light') ->withDomain(…) The part of the ID before the dot
EntityFilter::matching('sensor.*_battery') ->withSelector(…) An ID or pattern

Several values in one condition mean “any of these”; all conditions must match. EntityFilter::inArea('kitchen', 'hall')->withDomain('light')->withLabel('night') is a light in the kitchen or the hall, labelled night.

Areas, floors, labels and devices are named by their ID, not their display name: kitchen, not Kitchen. Find an ID in Home Assistant under Settings → Areas, labels & zones, or look it up by name (below). After make generate, PhpStorm completes the IDs.

Stewart follows Home Assistant’s rules, so a filter and ServiceTarget::forAreas('kitchen') reach the same entities:

  • An entity’s area is its own, or else its device’s.
  • Its floor is the floor of that area.
  • Its labels are its own, its device’s and its area’s.
  • A hidden entity, or a configuration or diagnostic one, never matches an area, floor, label or device.

An entity without an entry in the entity registry, such as a YAML entity without a unique ID, has no area, floor, labels or device.

$this->ha->getRegistry() returns Stewart’s copy of the registries. Lookups are instant.

Method Returns
listAreas(), findArea($id), findAreaByName('Kitchen') Areas; a name also matches an alias, ignoring case
listAreasOnFloor($floorId) The areas on one floor
listFloors(), findFloor($id), findFloorByName(…) Floors
listLabels(), findLabel($id), findLabelByName(…) Labels
listDevices(), findDevice($id), listDevicesInArea($areaId) Devices
listEntities(), findEntity($entityId) Entity registry entries
findEntityPlacement($entityId) The device, area, floor and labels an entity ends up with

To turn a display name into an ID: $this->ha->getRegistry()->findAreaByName('Kitchen')?->areaId.

Stewart reads the registries on every connect, and again a second after Home Assistant reports a change. A running watch follows the new registries from the next state change. Moving a light into the kitchen does not change its state, so a kitchen watch notices that light the next time it changes.

An app that asks for RegistryEditor can change any entity’s entry: name, icon, area, labels, aliases, and whether it is hidden or disabled. This app hides battery sensors and labels them:

apps/HideBatteries.php
<?php
declare(strict_types=1);
namespace App;
use Stewart\Contracts\App;
use Stewart\Contracts\Automation;
use Stewart\Contracts\HaContext;
use Stewart\Contracts\Registry\RegistryEditor;
use Stewart\Contracts\Registry\Update\EntityRegistryUpdate;
#[Automation(id: 'hide-batteries')]
final readonly class HideBatteries implements App
{
public function __construct(
private HaContext $ha,
private RegistryEditor $registry,
) {}
public function initialize(): void
{
foreach ($this->ha->listStates('sensor.*_battery') as $battery) {
$this->registry->updateEntity($battery->entityId, new EntityRegistryUpdate()
->withAddedLabels('battery')
->withHidden(true));
}
}
public function dispose(): void {}
}
  • Fields the update leaves out stay as they are. withoutName(), withoutIcon() and withoutArea() hand a field back to the integration or device.
  • withLabels() and withAliases() replace the whole list. withAddedLabels() and withRemovedLabels() (and the same for aliases) keep the entries others set.
  • updateEntity() returns the entry Home Assistant saved. Stewart’s copy catches up about a second later.
  • Edits count against the service call limits, and with dry run on they are logged, not sent.

It throws RegistryEditException:

Reason When
NotFound The entity has no registry entry
NothingToUpdate The update changes nothing
Rejected Home Assistant refused it, for example enabling an entity of a disabled device
Unreachable, TimedOut, Overloaded As for service calls

$harness->ha->registry is an in-memory registry. Seed areas, floors, labels, devices and entities, and filters resolve against them:

tests/RoomMotionTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\RoomMotion;
use PHPUnit\Framework\TestCase;
use Stewart\Contracts\Entity\EntityId;
use Stewart\Contracts\Registry\Area;
use Stewart\Contracts\Registry\AreaId;
use Stewart\Contracts\Registry\LabelId;
use Stewart\Contracts\Registry\RegisteredEntity;
use Stewart\Testing\App\AppHarness;
final class RoomMotionTest extends TestCase
{
public function testMotionInTheAreaTurnsItsLightsOn(): void
{
$harness = new AppHarness();
$harness->ha->registry
->seedArea(new Area(new AreaId('kitchen'), 'Kitchen'))
->seedEntity(new RegisteredEntity(new EntityId('binary_sensor.kitchen_pir'), areaId: new AreaId('kitchen'), labelIds: [new LabelId('motion')]))
->seedEntity(new RegisteredEntity(new EntityId('binary_sensor.hall_pir'), areaId: new AreaId('hall'), labelIds: [new LabelId('motion')]));
$harness->ha->seedState('binary_sensor.kitchen_pir', 'off');
$harness->ha->seedState('binary_sensor.hall_pir', 'off');
$harness->startApp(RoomMotion::class);
$harness->ha->pushState('binary_sensor.hall_pir', 'on');
self::assertTrue($harness->ha->calls->isEmpty());
$harness->ha->pushState('binary_sensor.kitchen_pir', 'on');
self::assertSame('light.turn_on', $harness->ha->getLastCall()->getServiceName());
}
}

Edits through $harness->registryEditor change the same in-memory registry, and its updates property lists them:

tests/HideBatteriesTest.php
<?php
declare(strict_types=1);
namespace App\Tests;
use App\HideBatteries;
use PHPUnit\Framework\TestCase;
use Stewart\Contracts\Entity\EntityId;
use Stewart\Contracts\Registry\RegisteredEntity;
use Stewart\Testing\App\AppHarness;
final class HideBatteriesTest extends TestCase
{
public function testBatterySensorsAreHiddenAndLabelled(): void
{
$harness = new AppHarness();
$harness->ha->registry->seedEntity(new RegisteredEntity(new EntityId('sensor.hall_battery')));
$harness->ha->seedState('sensor.hall_battery', '80');
$harness->startApp(HideBatteries::class);
$entry = $harness->ha->registry->findEntity('sensor.hall_battery');
self::assertTrue($entry?->isHidden());
self::assertSame(['battery'], array_map(strval(...), $entry->labelIds));
}
}

The filter matches nothing. It needs IDs, not display names. Check the entity’s area under Settings → Devices & services → Entities, and whether the entity is hidden.

Floors and labels are empty. Home Assistant before 2024.4 has none.