Lines 87.15% 156 / 179
Functions and Methods 77.27% 34 / 44
Classes and Traits 0.00% 0 / 1
Name Lines Functions and Methods CRAP Classes and Traits
HaClient 87.15% 156 / 179 77.27% 34 / 44 126.95 0.00% 0 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 fromConnectionConfig 100.00% 2 / 2 100.00% 1 / 1 1
 connect 100.00% 1 / 1 100.00% 1 / 1 1
 close 100.00% 1 / 1 100.00% 1 / 1 1
 isConnected 100.00% 1 / 1 100.00% 1 / 1 1
 onDisconnect 100.00% 1 / 1 100.00% 1 / 1 1
 getHaVersion 100.00% 1 / 1 100.00% 1 / 1 1
 flushEvents 100.00% 1 / 1 100.00% 1 / 1 1
 getStates 0.00% 0 / 6 0.00% 0 / 1 20
 getEntityRegistry 100.00% 7 / 7 100.00% 1 / 1 4
 getEntityRegistryEntry 100.00% 3 / 3 100.00% 1 / 1 2
 updateEntityRegistryEntry 100.00% 4 / 4 100.00% 1 / 1 2
 getAreaRegistry 100.00% 1 / 1 100.00% 1 / 1 1
 getFloorRegistry 100.00% 1 / 1 100.00% 1 / 1 1
 getLabelRegistry 100.00% 1 / 1 100.00% 1 / 1 1
 getDeviceRegistry 100.00% 1 / 1 100.00% 1 / 1 1
 getRegistry 100.00% 7 / 7 100.00% 1 / 1 1
 getCurrentUserId 100.00% 2 / 2 100.00% 1 / 1 3
 getConfig 100.00% 1 / 1 100.00% 1 / 1 1
 getTimeZone 100.00% 1 / 1 100.00% 1 / 1 1
 getSiteSettings 100.00% 6 / 6 100.00% 1 / 1 3
 getServices 0.00% 0 / 1 0.00% 0 / 1 2
 callService 100.00% 11 / 11 100.00% 1 / 1 4
 fireEvent 100.00% 5 / 5 100.00% 1 / 1 3
 fetchHistory 100.00% 10 / 10 100.00% 1 / 1 6
 subscribeAllEvents 100.00% 11 / 11 100.00% 1 / 1 4
 subscribeTrigger 100.00% 9 / 9 100.00% 1 / 1 2
 findComponentVersion 80.00% 4 / 5 0.00% 0 / 1 4.13
 subscribeComponentSession 100.00% 9 / 9 100.00% 1 / 1 2
 answerComponentCommand 0.00% 0 / 6 0.00% 0 / 1 12
 upsertExposedEntity 0.00% 0 / 1 0.00% 0 / 1 2
 upsertExposedAction 0.00% 0 / 3 0.00% 0 / 1 6
 updateExposedEntityState 0.00% 0 / 1 0.00% 0 / 1 2
 removeExposedEntity 0.00% 0 / 2 0.00% 0 / 1 6
 reconcileExposedEntities 0.00% 0 / 1 0.00% 0 / 1 2
 unsubscribeEvents 100.00% 1 / 1 100.00% 1 / 1 1
 subscribe 100.00% 5 / 5 100.00% 1 / 1 3
 decodeRows 100.00% 6 / 6 100.00% 1 / 1 4
 sendUnlessUnknown 100.00% 5 / 5 100.00% 1 / 1 4
 toHistoryException 100.00% 8 / 8 100.00% 1 / 1 6
 readEntityRegistryEntry 66.66% 2 / 3 0.00% 0 / 1 3.33
 toRegistryEditException 100.00% 8 / 8 100.00% 1 / 1 6
 toServiceCallException 100.00% 12 / 12 100.00% 1 / 1 4
 toEventFireException 100.00% 6 / 6 100.00% 1 / 1 4
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Client;
6
7use Amp\Websocket\Client\WebsocketConnector;
8use Closure;
9use DateTimeZone;
10use Psr\Log\LoggerInterface;
11use Psr\Log\NullLogger;
12use Stewart\Client\Component\ComponentCommandAnswer;
13use Stewart\Client\Component\ComponentErrorCode;
14use Stewart\Client\Component\ComponentEventDecoder;
15use Stewart\Client\Component\ComponentSessionEvent;
16use Stewart\Client\Component\ComponentSessionRequest;
17use Stewart\Client\Component\ComponentVersion;
18use Stewart\Client\Component\ExposedEntityAddress;
19use Stewart\Client\Component\ExposedEntityDefinition;
20use Stewart\Client\Component\ExposedEntityReconcile;
21use Stewart\Client\Component\ExposedEntitySnapshotReader;
22use Stewart\Client\Component\ReconcileResultReader;
23use Stewart\Client\Connection\Command\CallService;
24use Stewart\Client\Connection\Command\Component\AnswerComponentCommand;
25use Stewart\Client\Connection\Command\Component\GetComponentVersion;
26use Stewart\Client\Connection\Command\Component\ReconcileExposedEntities;
27use Stewart\Client\Connection\Command\Component\RemoveExposedEntity;
28use Stewart\Client\Connection\Command\Component\SubscribeComponentSession;
29use Stewart\Client\Connection\Command\Component\UpdateExposedEntityState;
30use Stewart\Client\Connection\Command\Component\UpsertExposedEntity;
31use Stewart\Client\Connection\Command\FireEvent;
32use Stewart\Client\Connection\Command\GetAreaRegistry;
33use Stewart\Client\Connection\Command\GetConfig;
34use Stewart\Client\Connection\Command\GetCurrentUser;
35use Stewart\Client\Connection\Command\GetDeviceRegistry;
36use Stewart\Client\Connection\Command\GetEntityRegistry;
37use Stewart\Client\Connection\Command\GetEntityRegistryEntry;
38use Stewart\Client\Connection\Command\GetFloorRegistry;
39use Stewart\Client\Connection\Command\GetHistoryDuringPeriod;
40use Stewart\Client\Connection\Command\GetLabelRegistry;
41use Stewart\Client\Connection\Command\GetServices;
42use Stewart\Client\Connection\Command\GetStates;
43use Stewart\Client\Connection\Command\HaCommand;
44use Stewart\Client\Connection\Command\SubscribeEvents;
45use Stewart\Client\Connection\Command\SubscribeTrigger;
46use Stewart\Client\Connection\Command\SubscriptionCommand;
47use Stewart\Client\Connection\Command\UpdateEntityRegistryEntry;
48use Stewart\Client\Connection\ConnectionConfig;
49use Stewart\Client\Connection\HaConnection;
50use Stewart\Client\Event\EventDecoder;
51use Stewart\Client\Exception\HaClientError;
52use Stewart\Client\Exception\HaClientException;
53use Stewart\Client\Registry\Collection\EntityRegistryCollection;
54use Stewart\Client\Registry\EntityRegistryEntry;
55use Stewart\Client\Registry\RegistryDecoder;
56use Stewart\Client\State\EntityStateDecoder;
57use Stewart\Contracts\Entity\Collection\EntityIdCollection;
58use Stewart\Contracts\Entity\EntityId;
59use Stewart\Contracts\Event\HaEvent;
60use Stewart\Contracts\Exception\EventFireException;
61use Stewart\Contracts\Exception\HistoryError;
62use Stewart\Contracts\Exception\HistoryException;
63use Stewart\Contracts\Exception\IdentifierException;
64use Stewart\Contracts\Exception\RegistryEditException;
65use Stewart\Contracts\Exception\ServiceCallException;
66use Stewart\Contracts\Exposure\ExposedEntitySnapshot;
67use Stewart\Contracts\Exposure\ExposedStateChange;
68use Stewart\Contracts\History\Collection\HistoricalStateCollection;
69use Stewart\Contracts\History\EntityStateHistory;
70use Stewart\Contracts\History\HistoryDetail;
71use Stewart\Contracts\History\HistoryWindow;
72use Stewart\Contracts\Registry\Collection\AreaCollection;
73use Stewart\Contracts\Registry\Collection\DeviceCollection;
74use Stewart\Contracts\Registry\Collection\FloorCollection;
75use Stewart\Contracts\Registry\Collection\LabelCollection;
76use Stewart\Contracts\Registry\Update\EntityRegistryUpdate;
77use Stewart\Contracts\Service\ServiceResponse;
78use Stewart\Contracts\Service\ServiceTarget;
79use Stewart\Contracts\State\Collection\EntityStateCollection;
80use Stewart\Contracts\State\EventContext;
81use Stewart\Contracts\State\StateChange;
82use Stewart\Contracts\Trigger\Collection\HaTriggerCollection;
83use Stewart\Contracts\Trigger\TriggerEvent;
84use Stewart\Support\Json\JsonShape;
85use Stewart\Support\State\IndexedRegistry;
86use Stewart\Support\Time\Deadlines;
87use Stewart\Support\Wire\EventPayload;
88
89final class HaClient
90{
91    private const string UNKNOWN_COMMAND_CODE = 'unknown_command';
92
93    private const string NOT_FOUND_CODE = 'not_found';
94
95    public function __construct(
96        private readonly HaConnection $connection,
97        private readonly EventDecoder $decoder,
98        private readonly EntityStateDecoder $states,
99        private readonly RegistryDecoder $registries,
100        private readonly ComponentEventDecoder $componentEvents,
101        private readonly LoggerInterface $logger = new NullLogger(),
102    ) {}
103
104    public static function fromConnectionConfig(
105        ConnectionConfig $config,
106        Deadlines $deadlines,
107        LoggerInterface $logger = new NullLogger(),
108        ?WebsocketConnector $connector = null,
109    ): self {
110        $states = new EntityStateDecoder($logger);
111
112        return new self(new HaConnection($config, $deadlines, $logger, $connector), new EventDecoder($states), $states, new RegistryDecoder($logger), new ComponentEventDecoder(), $logger);
113    }
114
115    public function connect(): void
116    {
117        $this->connection->connect();
118    }
119
120    public function close(): void
121    {
122        $this->connection->close();
123    }
124
125    public function isConnected(): bool
126    {
127        return $this->connection->isConnected();
128    }
129
130    /** @param Closure(HaClientException): void $handler */
131    public function onDisconnect(Closure $handler): void
132    {
133        $this->connection->onDisconnect($handler);
134    }
135
136    public function getHaVersion(): ?string
137    {
138        return $this->connection->haVersion;
139    }
140
141    public function flushEvents(): void
142    {
143        $this->connection->flushEvents();
144    }
145
146    public function getStates(): EntityStateCollection
147    {
148        $states = [];
149
150        foreach ($this->connection->send(new GetStates()) as $entry) {
151            $state = \is_array($entry) ? $this->states->decodeEntityStateOrSkip($entry) : null;
152
153            if ($state !== null) {
154                $states[] = $state;
155            }
156        }
157
158        return EntityStateCollection::keyedByEntityId($states);
159    }
160
161    public function getEntityRegistry(): EntityRegistryCollection
162    {
163        $entries = [];
164
165        foreach ($this->connection->send(new GetEntityRegistry()) as $entry) {
166            if (!\is_array($entry)) {
167                continue;
168            }
169
170            try {
171                $entries[] = EntityRegistryEntry::fromArray($entry);
172            } catch (IdentifierException $e) {
173                $this->logger->debug('Skipped an entity registry entry without a valid entity id', ['exception' => $e]);
174            }
175        }
176
177        return EntityRegistryCollection::keyedByEntityId($entries);
178    }
179
180    /** @throws RegistryEditException */
181    public function getEntityRegistryEntry(EntityId $entityId): EntityRegistryEntry
182    {
183        try {
184            return $this->readEntityRegistryEntry($this->connection->send(new GetEntityRegistryEntry($entityId)), 'config/entity_registry/get');
185        } catch (HaClientException $e) {
186            throw $this->toRegistryEditException($entityId, $e);
187        }
188    }
189
190    /** @throws RegistryEditException */
191    public function updateEntityRegistryEntry(EntityId $entityId, EntityRegistryUpdate $update): EntityRegistryEntry
192    {
193        try {
194            $result = $this->connection->send(new UpdateEntityRegistryEntry($entityId, $update));
195
196            return $this->readEntityRegistryEntry($result['entity_entry'] ?? null, 'config/entity_registry/update');
197        } catch (HaClientException $e) {
198            throw $this->toRegistryEditException($entityId, $e);
199        }
200    }
201
202    public function getAreaRegistry(): AreaCollection
203    {
204        return AreaCollection::keyedByAreaId($this->decodeRows($this->connection->send(new GetAreaRegistry()), $this->registries->decodeAreaOrSkip(...)));
205    }
206
207    public function getFloorRegistry(): FloorCollection
208    {
209        return FloorCollection::keyedByFloorId($this->decodeRows($this->sendUnlessUnknown(new GetFloorRegistry()), $this->registries->decodeFloorOrSkip(...)));
210    }
211
212    public function getLabelRegistry(): LabelCollection
213    {
214        return LabelCollection::keyedByLabelId($this->decodeRows($this->sendUnlessUnknown(new GetLabelRegistry()), $this->registries->decodeLabelOrSkip(...)));
215    }
216
217    public function getDeviceRegistry(): DeviceCollection
218    {
219        return DeviceCollection::keyedByDeviceId($this->decodeRows($this->connection->send(new GetDeviceRegistry()), $this->registries->decodeDeviceOrSkip(...)));
220    }
221
222    public function getRegistry(): IndexedRegistry
223    {
224        return IndexedRegistry::fromParts(
225            $this->getAreaRegistry(),
226            $this->getFloorRegistry(),
227            $this->getLabelRegistry(),
228            $this->getDeviceRegistry(),
229            $this->getEntityRegistry()->toRegisteredEntities(),
230        );
231    }
232
233    /** @throws HaClientException */
234    public function getCurrentUserId(): string
235    {
236        $id = $this->connection->send(new GetCurrentUser())['id'] ?? null;
237
238        return \is_string($id) && $id !== '' ? $id : throw HaClientException::protocolViolation('a current user without an id');
239    }
240
241    public function getConfig(): HaConfig
242    {
243        return HaConfig::fromGetConfigResult($this->connection->send(new GetConfig()));
244    }
245
246    public function getTimeZone(): DateTimeZone
247    {
248        return $this->getSiteSettings()->timeZone;
249    }
250
251    public function getSiteSettings(): HaSiteSettings
252    {
253        $config = $this->getConfig();
254
255        if ($config->timeZone === null && $config->timeZoneName !== null) {
256            $this->logger->warning('Home Assistant reported an unusable timezone; falling back to UTC.', [
257                'time_zone' => $config->timeZoneName,
258            ]);
259        }
260
261        return new HaSiteSettings($config->timeZone ?? new DateTimeZone('UTC'), $config->location);
262    }
263
264    /** @return array<string, mixed> */
265    public function getServices(): array
266    {
267        return JsonShape::treatKeysAsStrings($this->connection->send(new GetServices()));
268    }
269
270    /**
271     * @param array<string, mixed> $data
272     * @throws ServiceCallException
273     */
274    public function callService(
275        string $domain,
276        string $service,
277        array $data = [],
278        ?ServiceTarget $target = null,
279        bool $returnResponse = false,
280    ): ServiceResponse {
281        try {
282            $result = $this->connection->send(new CallService($domain, $service, $data, $target, $returnResponse));
283        } catch (HaClientException $e) {
284            throw $this->toServiceCallException($domain, $service, $e);
285        }
286
287        $response = $result['response'] ?? null;
288        $context = $result['context'] ?? null;
289
290        return new ServiceResponse(
291            $domain,
292            $service,
293            \is_array($response) ? JsonShape::treatKeysAsStrings($response) : [],
294            \is_array($context) ? EventContext::fromArray($context) : null,
295        );
296    }
297
298    /** @throws EventFireException */
299    public function fireEvent(EventPayload $payload): EventContext
300    {
301        try {
302            $result = $this->connection->send(new FireEvent($payload));
303        } catch (HaClientException $e) {
304            throw $this->toEventFireException($payload->eventType, $e);
305        }
306
307        $context = $result['context'] ?? null;
308
309        return \is_array($context) ? EventContext::fromArray($context) : EventContext::unknown();
310    }
311
312    /** @throws HistoryException */
313    public function fetchHistory(EntityId $entityId, HistoryWindow $window, HistoryDetail $detail): EntityStateHistory
314    {
315        try {
316            $result = $this->connection->send(new GetHistoryDuringPeriod($entityId, $window, $detail));
317        } catch (HaClientException $e) {
318            throw $this->toHistoryException($entityId, $e);
319        }
320
321        $rows = $result[$entityId->value] ?? [];
322        $states = [];
323
324        foreach (\is_array($rows) ? $rows : [] as $row) {
325            $state = \is_array($row) ? $this->states->decodeCompressedHistoryRowOrSkip($entityId, $row) : null;
326
327            if ($state !== null) {
328                $states[] = $state;
329            }
330        }
331
332        return new EntityStateHistory($entityId, $window, HistoricalStateCollection::fromList($states));
333    }
334
335    /**
336     * @param Closure(StateChange): void $onStateChange
337     * @param Closure(HaEvent): void $onEvent
338     * @throws HaClientException
339     */
340    public function subscribeAllEvents(Closure $onStateChange, Closure $onEvent): int
341    {
342        $decoder = $this->decoder;
343
344        return $this->subscribe(new SubscribeEvents(), static function (array $event) use ($decoder, $onStateChange, $onEvent): void {
345            if (($event['event_type'] ?? null) === HaEvent::STATE_CHANGED) {
346                $change = $decoder->decodeStateChange($event);
347
348                if ($change !== null) {
349                    $onStateChange($change);
350                }
351
352                return;
353            }
354
355            $decoded = $decoder->decodeEvent($event);
356
357            if ($decoded !== null) {
358                $onEvent($decoded);
359            }
360        });
361    }
362
363    /**
364     * @param array<string, mixed> $variables
365     * @param Closure(TriggerEvent): void $onTrigger
366     * @throws HaClientException
367     */
368    public function subscribeTrigger(HaTriggerCollection $triggers, array $variables, Closure $onTrigger): int
369    {
370        $decoder = $this->decoder;
371        $logger = $this->logger;
372
373        return $this->subscribe(new SubscribeTrigger($triggers, $variables), static function (array $event) use ($decoder, $logger, $onTrigger): void {
374            $decoded = $decoder->decodeTriggerEvent($event);
375
376            if ($decoded === null) {
377                $logger->warning('Skipped a trigger event without variables.trigger');
378
379                return;
380            }
381
382            $onTrigger($decoded);
383        });
384    }
385
386    /** @throws HaClientException */
387    public function findComponentVersion(): ?ComponentVersion
388    {
389        try {
390            return ComponentVersion::fromVersionResult($this->connection->send(new GetComponentVersion()));
391        } catch (HaClientException $e) {
392            if ($e->reason === HaClientError::CommandRejected && $e->findErrorCode() === self::UNKNOWN_COMMAND_CODE) {
393                return null;
394            }
395
396            throw $e;
397        }
398    }
399
400    /**
401     * @param Closure(ComponentSessionEvent): void $onEvent
402     * @throws HaClientException
403     */
404    public function subscribeComponentSession(ComponentSessionRequest $request, Closure $onEvent): int
405    {
406        $decoder = $this->componentEvents;
407        $logger = $this->logger;
408
409        return $this->subscribe(new SubscribeComponentSession($request), static function (array $event) use ($decoder, $logger, $onEvent): void {
410            $decoded = $decoder->decodeSessionEvent($event);
411
412            if ($decoded === null) {
413                $logger->debug('Skipped a Stewart component event of an unknown type', ['type' => $event['type'] ?? null]);
414
415                return;
416            }
417
418            $onEvent($decoded);
419        });
420    }
421
422    /** @throws HaClientException */
423    public function answerComponentCommand(ComponentCommandAnswer $answer): bool
424    {
425        try {
426            $this->connection->sendIgnoringResult(new AnswerComponentCommand($answer));
427        } catch (HaClientException $e) {
428            if (ComponentErrorCode::tryFromException($e) === ComponentErrorCode::NotFound) {
429                return false;
430            }
431
432            throw $e;
433        }
434
435        return true;
436    }
437
438    /** @throws HaClientException */
439    public function upsertExposedEntity(ExposedEntityAddress $address, ExposedEntityDefinition $definition, ExposedStateChange $change): ExposedEntitySnapshot
440    {
441        return ExposedEntitySnapshotReader::readUpsertResult($this->connection->send(new UpsertExposedEntity($address, $definition, $change)));
442    }
443
444    // An action registers a service, not an entity, so it has no snapshot to read back.
445    /** @throws HaClientException */
446    public function upsertExposedAction(ExposedEntityAddress $address, ExposedEntityDefinition $definition): void
447    {
448        $result = $this->connection->send(new UpsertExposedEntity($address, $definition, new ExposedStateChange()));
449
450        if (!\is_string($result['service'] ?? null)) {
451            throw HaClientException::protocolViolation('a stewart/entity/upsert result for an action without service');
452        }
453    }
454
455    /** @throws HaClientException */
456    public function updateExposedEntityState(ExposedEntityAddress $address, ExposedStateChange $change): void
457    {
458        $this->connection->sendIgnoringResult(new UpdateExposedEntityState($address, $change));
459    }
460
461    /** @throws HaClientException */
462    public function removeExposedEntity(ExposedEntityAddress $address): bool
463    {
464        $removed = $this->connection->send(new RemoveExposedEntity($address))['removed'] ?? null;
465
466        return \is_bool($removed) ? $removed : throw HaClientException::protocolViolation('a stewart/entity/remove result without removed');
467    }
468
469    /** @throws HaClientException */
470    public function reconcileExposedEntities(ExposedEntityReconcile $reconcile): EntityIdCollection
471    {
472        return ReconcileResultReader::readRemovedEntityIds($this->connection->send(new ReconcileExposedEntities($reconcile)));
473    }
474
475    /** @throws HaClientException */
476    public function unsubscribeEvents(int $subscriptionId): void
477    {
478        $this->connection->unsubscribeEvents($subscriptionId);
479    }
480
481    /**
482     * @param Closure(array<string, mixed>): void $handler
483     * @throws HaClientException
484     */
485    private function subscribe(SubscriptionCommand $command, Closure $handler): int
486    {
487        try {
488            return $this->connection->subscribeEvents($command, $handler);
489        } catch (HaClientException $e) {
490            throw $e->reason === HaClientError::CommandUnauthorized
491                ? HaClientException::createForReason(HaClientError::AdministratorRequired, $e)
492                : $e;
493        }
494    }
495
496    /**
497     * @template T of object
498     * @param array<array-key, mixed> $rows
499     * @param Closure(array<array-key, mixed>): ?T $decodeOrSkip
500     * @return list<T>
501     */
502    private function decodeRows(array $rows, Closure $decodeOrSkip): array
503    {
504        $decoded = [];
505
506        foreach ($rows as $row) {
507            $entry = \is_array($row) ? $decodeOrSkip($row) : null;
508
509            if ($entry !== null) {
510                $decoded[] = $entry;
511            }
512        }
513
514        return $decoded;
515    }
516
517    /** @return array<array-key, mixed> */
518    private function sendUnlessUnknown(HaCommand $command): array
519    {
520        // Floors and labels arrived in Home Assistant 2024.4.
521        try {
522            return $this->connection->send($command);
523        } catch (HaClientException $e) {
524            if ($e->reason === HaClientError::CommandRejected && $e->findErrorCode() === self::UNKNOWN_COMMAND_CODE) {
525                return [];
526            }
527
528            throw $e;
529        }
530    }
531
532    private function toHistoryException(EntityId $entityId, HaClientException $e): HistoryException
533    {
534        if ($e->reason === HaClientError::CommandRejected && $e->findErrorCode() === self::UNKNOWN_COMMAND_CODE) {
535            return HistoryException::createForReason(HistoryError::RecorderUnavailable, $e, entityId: $entityId->value);
536        }
537
538        return match ($e->reason) {
539            HaClientError::CommandRejected,
540            HaClientError::CommandUnauthorized,
541            HaClientError::CommandUnencodable => HistoryException::rejected($entityId, $e->findDetail() ?? $e->getMessage(), $e->findErrorCode(), $e),
542            HaClientError::CommandTimedOut => HistoryException::timedOut($entityId, $e->getMessage(), $e),
543            default => HistoryException::unreachable($entityId, $e->getMessage(), $e),
544        };
545    }
546
547    /** @throws HaClientException */
548    private function readEntityRegistryEntry(mixed $row, string $command): EntityRegistryEntry
549    {
550        try {
551            return \is_array($row) ? EntityRegistryEntry::fromArray($row) : throw HaClientException::protocolViolation(\sprintf('a %s result without an entity entry', $command));
552        } catch (IdentifierException $e) {
553            throw HaClientException::protocolViolation(\sprintf('a %s result without a valid entity id', $command), $e);
554        }
555    }
556
557    private function toRegistryEditException(EntityId $entityId, HaClientException $e): RegistryEditException
558    {
559        if ($e->reason === HaClientError::CommandRejected && $e->findErrorCode() === self::NOT_FOUND_CODE) {
560            return RegistryEditException::notFound($entityId);
561        }
562
563        return match ($e->reason) {
564            HaClientError::CommandRejected,
565            HaClientError::CommandUnauthorized,
566            HaClientError::CommandUnencodable => RegistryEditException::rejected($entityId, $e->findDetail() ?? $e->getMessage(), $e->findErrorCode(), $e),
567            HaClientError::CommandTimedOut => RegistryEditException::timedOut($entityId, $e->getMessage(), $e),
568            default => RegistryEditException::unreachable($entityId, $e->getMessage(), $e),
569        };
570    }
571
572    private function toServiceCallException(string $domain, string $service, HaClientException $e): ServiceCallException
573    {
574        return match ($e->reason) {
575            HaClientError::CommandRejected,
576            HaClientError::CommandUnauthorized,
577            HaClientError::CommandUnencodable => ServiceCallException::rejected(
578                $domain,
579                $service,
580                $e->findDetail() ?? $e->getMessage(),
581                $e->findErrorCode(),
582                $e,
583            ),
584            HaClientError::CommandTimedOut => ServiceCallException::timedOut($domain, $service, $e->getMessage(), $e),
585            default => ServiceCallException::unreachable($domain, $service, $e->getMessage(), $e),
586        };
587    }
588
589    private function toEventFireException(string $eventType, HaClientException $e): EventFireException
590    {
591        return match ($e->reason) {
592            HaClientError::CommandRejected,
593            HaClientError::CommandUnauthorized,
594            HaClientError::CommandUnencodable => EventFireException::rejected($eventType, $e->findDetail() ?? $e->getMessage(), $e->findErrorCode(), $e),
595            HaClientError::CommandTimedOut => EventFireException::timedOut($eventType, $e->getMessage(), $e),
596            default => EventFireException::unreachable($eventType, $e->getMessage(), $e),
597        };
598    }
599}