Lines 97.82% 45 / 46
Functions and Methods 94.73% 18 / 19
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
EntityFilter 97.82% 45 / 46 94.73% 18 / 19 35 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 inArea 100.00% 1 / 1 100.00% 1 / 1 1
 onFloor 100.00% 1 / 1 100.00% 1 / 1 1
 labelled 100.00% 1 / 1 100.00% 1 / 1 1
 ofDevice 100.00% 1 / 1 100.00% 1 / 1 1
 inDomain 100.00% 1 / 1 100.00% 1 / 1 1
 matching 100.00% 1 / 1 100.00% 1 / 1 1
 withArea 100.00% 2 / 2 100.00% 1 / 1 1
 withFloor 100.00% 2 / 2 100.00% 1 / 1 1
 withLabel 100.00% 2 / 2 100.00% 1 / 1 1
 withDevice 100.00% 2 / 2 100.00% 1 / 1 1
 withDomain 100.00% 1 / 1 100.00% 1 / 1 1
 withSelector 100.00% 3 / 3 100.00% 1 / 1 2
 matchesEntity 85.71% 6 / 7 0.00% 0 / 1 9.24
 toCanonicalKey 100.00% 10 / 10 100.00% 1 / 1 1
 unconstrained 100.00% 1 / 1 100.00% 1 / 1 1
 matchesPlacement 100.00% 5 / 5 100.00% 1 / 1 6
 containsWhenConstrained 100.00% 1 / 1 100.00% 1 / 1 3
 listSortedValues 100.00% 3 / 3 100.00% 1 / 1 1
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Contracts\Registry;
6
7use Stewart\Contracts\Entity\EntityId;
8use Stewart\Contracts\Exception\IdentifierException;
9use Stewart\Contracts\Exception\SelectorException;
10use Stewart\Contracts\Registry\Collection\AreaIdCollection;
11use Stewart\Contracts\Registry\Collection\DeviceIdCollection;
12use Stewart\Contracts\Registry\Collection\FloorIdCollection;
13use Stewart\Contracts\Registry\Collection\LabelIdCollection;
14use Stewart\Contracts\Registry\Collection\RegistryIdCollection;
15use Stewart\Contracts\Selector\Collection\SelectorCollection;
16use Stewart\Contracts\Selector\Selector;
17
18final readonly class EntityFilter
19{
20    /** @param list<string> $domains */
21    private function __construct(
22        private AreaIdCollection $areaIds,
23        private FloorIdCollection $floorIds,
24        private LabelIdCollection $labelIds,
25        private DeviceIdCollection $deviceIds,
26        private array $domains,
27        private ?Selector $selector,
28    ) {}
29
30    /** @throws IdentifierException */
31    public static function inArea(AreaId|string $areaId, AreaId|string ...$moreAreaIds): self
32    {
33        return self::unconstrained()->withArea($areaId, ...$moreAreaIds);
34    }
35
36    /** @throws IdentifierException */
37    public static function onFloor(FloorId|string $floorId, FloorId|string ...$moreFloorIds): self
38    {
39        return self::unconstrained()->withFloor($floorId, ...$moreFloorIds);
40    }
41
42    /** @throws IdentifierException */
43    public static function labelled(LabelId|string $labelId, LabelId|string ...$moreLabelIds): self
44    {
45        return self::unconstrained()->withLabel($labelId, ...$moreLabelIds);
46    }
47
48    /** @throws IdentifierException */
49    public static function ofDevice(DeviceId|string $deviceId, DeviceId|string ...$moreDeviceIds): self
50    {
51        return self::unconstrained()->withDevice($deviceId, ...$moreDeviceIds);
52    }
53
54    public static function inDomain(string $domain, string ...$moreDomains): self
55    {
56        return self::unconstrained()->withDomain($domain, ...$moreDomains);
57    }
58
59    /** @throws SelectorException */
60    public static function matching(string|EntityId|Selector|SelectorCollection $selector): self
61    {
62        return self::unconstrained()->withSelector($selector);
63    }
64
65    /** @throws IdentifierException */
66    public function withArea(AreaId|string ...$areaIds): self
67    {
68        $added = array_map(AreaId::fromStringOrId(...), $areaIds);
69
70        return new self(AreaIdCollection::fromList([...$this->areaIds, ...$added]), $this->floorIds, $this->labelIds, $this->deviceIds, $this->domains, $this->selector);
71    }
72
73    /** @throws IdentifierException */
74    public function withFloor(FloorId|string ...$floorIds): self
75    {
76        $added = array_map(FloorId::fromStringOrId(...), $floorIds);
77
78        return new self($this->areaIds, FloorIdCollection::fromList([...$this->floorIds, ...$added]), $this->labelIds, $this->deviceIds, $this->domains, $this->selector);
79    }
80
81    /** @throws IdentifierException */
82    public function withLabel(LabelId|string ...$labelIds): self
83    {
84        $added = array_map(LabelId::fromStringOrId(...), $labelIds);
85
86        return new self($this->areaIds, $this->floorIds, LabelIdCollection::fromList([...$this->labelIds, ...$added]), $this->deviceIds, $this->domains, $this->selector);
87    }
88
89    /** @throws IdentifierException */
90    public function withDevice(DeviceId|string ...$deviceIds): self
91    {
92        $added = array_map(DeviceId::fromStringOrId(...), $deviceIds);
93
94        return new self($this->areaIds, $this->floorIds, $this->labelIds, DeviceIdCollection::fromList([...$this->deviceIds, ...$added]), $this->domains, $this->selector);
95    }
96
97    public function withDomain(string ...$domains): self
98    {
99        return new self($this->areaIds, $this->floorIds, $this->labelIds, $this->deviceIds, array_values(array_unique([...$this->domains, ...$domains])), $this->selector);
100    }
101
102    /** @throws SelectorException */
103    public function withSelector(string|EntityId|Selector|SelectorCollection $selector): self
104    {
105        $added = Selector::fromSpec($selector);
106        $combined = $this->selector === null ? $added : Selector::anyOf($this->selector, $added);
107
108        return new self($this->areaIds, $this->floorIds, $this->labelIds, $this->deviceIds, $this->domains, $combined);
109    }
110
111    public function matchesEntity(EntityId $entityId, Registry $registry): bool
112    {
113        if ($this->domains !== [] && !\in_array($entityId->domain, $this->domains, true)) {
114            return false;
115        }
116
117        if ($this->selector !== null && !$this->selector->matches($entityId->value)) {
118            return false;
119        }
120
121        if ($this->areaIds->isEmpty() && $this->floorIds->isEmpty() && $this->labelIds->isEmpty() && $this->deviceIds->isEmpty()) {
122            return true;
123        }
124
125        return $this->matchesPlacement($registry->findEntityPlacement($entityId));
126    }
127
128    public function toCanonicalKey(): string
129    {
130        $domains = $this->domains;
131        sort($domains);
132
133        return 'entity-filter:' . json_encode([
134            'areas' => $this->listSortedValues($this->areaIds),
135            'floors' => $this->listSortedValues($this->floorIds),
136            'labels' => $this->listSortedValues($this->labelIds),
137            'devices' => $this->listSortedValues($this->deviceIds),
138            'domains' => $domains,
139            'selector' => $this->selector?->toCanonicalKey(),
140        ], \JSON_THROW_ON_ERROR);
141    }
142
143    private static function unconstrained(): self
144    {
145        return new self(AreaIdCollection::empty(), FloorIdCollection::empty(), LabelIdCollection::empty(), DeviceIdCollection::empty(), [], null);
146    }
147
148    // Home Assistant skips hidden and config or diagnostic entities reached through the registry.
149    private function matchesPlacement(EntityPlacement $placement): bool
150    {
151        return $placement->indirectlyTargetable
152            && $this->containsWhenConstrained($this->areaIds, $placement->areaId)
153            && $this->containsWhenConstrained($this->floorIds, $placement->floorId)
154            && $this->containsWhenConstrained($this->deviceIds, $placement->deviceId)
155            && ($this->labelIds->isEmpty() || $this->labelIds->containsAnyOf($placement->labelIds));
156    }
157
158    /**
159     * @template T of RegistryId
160     * @param RegistryIdCollection<T> $ids
161     * @param T|null $id
162     */
163    private function containsWhenConstrained(RegistryIdCollection $ids, ?RegistryId $id): bool
164    {
165        return $ids->isEmpty() || ($id !== null && $ids->contains($id));
166    }
167
168    /**
169     * @template T of RegistryId
170     * @param RegistryIdCollection<T> $ids
171     * @return list<string>
172     */
173    private function listSortedValues(RegistryIdCollection $ids): array
174    {
175        $values = array_values(array_unique($ids->toStrings()));
176        sort($values);
177
178        return $values;
179    }
180}