Lines 100.00% 53 / 53
Functions and Methods 100.00% 7 / 7
Classes and Traits 100.00% 1 / 1
Name Lines Functions and Methods CRAP Classes and Traits
EntityStateDecoder 100.00% 53 / 53 100.00% 7 / 7 25 100.00% 1 / 1
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 decodeEntityStateOrSkip 100.00% 14 / 14 100.00% 1 / 1 5
 decodeCompressedHistoryRowOrSkip 100.00% 13 / 13 100.00% 1 / 1 3
 parseEntityIdOrWarn 100.00% 4 / 4 100.00% 1 / 1 4
 parseInstantOrWarn 100.00% 6 / 6 100.00% 1 / 1 5
 parseEpochSecondsOrWarn 100.00% 6 / 6 100.00% 1 / 1 4
 warnOncePerField 100.00% 9 / 9 100.00% 1 / 1 3
1<?php
2
3declare(strict_types=1);
4
5namespace Stewart\Client\State;
6
7use Psr\Log\LoggerInterface;
8use Psr\Log\NullLogger;
9use Stewart\Contracts\Entity\EntityId;
10use Stewart\Contracts\State\EntityState;
11use Stewart\Contracts\State\EventContext;
12use Stewart\Contracts\Time\Instant;
13use Stewart\Support\Json\JsonShape;
14
15final class EntityStateDecoder
16{
17    private const int MICROS_PER_SECOND = 1_000_000;
18
19    /** @var array<string, true> */
20    private array $warnedFields = [];
21
22    public function __construct(private readonly LoggerInterface $logger = new NullLogger()) {}
23
24    /** @param array<array-key, mixed> $raw */
25    public function decodeEntityStateOrSkip(array $raw): ?EntityState
26    {
27        $entityId = $this->parseEntityIdOrWarn($raw['entity_id'] ?? null);
28
29        if ($entityId === null) {
30            return null;
31        }
32
33        $attributes = $raw['attributes'] ?? [];
34        $context = $raw['context'] ?? null;
35        $state = $raw['state'] ?? '';
36
37        return new EntityState(
38            entityId: $entityId,
39            state: \is_scalar($state) ? (string) $state : '',
40            attributes: \is_array($attributes) ? JsonShape::treatKeysAsStrings($attributes) : [],
41            lastChangedAt: $this->parseInstantOrWarn($raw['last_changed'] ?? null, $entityId->value, 'last_changed'),
42            lastUpdatedAt: $this->parseInstantOrWarn($raw['last_updated'] ?? null, $entityId->value, 'last_updated'),
43            context: \is_array($context) ? EventContext::fromArray(JsonShape::treatKeysAsStrings($context)) : null,
44        );
45    }
46
47    /** @param array<array-key, mixed> $raw */
48    public function decodeCompressedHistoryRowOrSkip(EntityId $entityId, array $raw): ?EntityState
49    {
50        $state = $raw['s'] ?? null;
51
52        if (!\is_scalar($state)) {
53            $this->warnOncePerField($entityId->value, 's', $state, 'Home Assistant sent a history row without a state; it is skipped');
54
55            return null;
56        }
57
58        $attributes = $raw['a'] ?? [];
59        $lastUpdatedAt = $this->parseEpochSecondsOrWarn($raw['lu'] ?? null, $entityId->value, 'lu');
60
61        return new EntityState(
62            entityId: $entityId,
63            state: (string) $state,
64            attributes: \is_array($attributes) ? JsonShape::treatKeysAsStrings($attributes) : [],
65            lastChangedAt: $this->parseEpochSecondsOrWarn($raw['lc'] ?? null, $entityId->value, 'lc') ?? $lastUpdatedAt,
66            lastUpdatedAt: $lastUpdatedAt,
67        );
68    }
69
70    public function parseEntityIdOrWarn(mixed $raw): ?EntityId
71    {
72        $entityId = \is_string($raw) ? EntityId::tryFromString($raw) : null;
73
74        if ($entityId === null) {
75            $this->warnOncePerField(\is_string($raw) ? $raw : '', 'entity_id', $raw, 'Home Assistant sent an entity without a valid entity id; it is skipped');
76        }
77
78        return $entityId;
79    }
80
81    public function parseInstantOrWarn(mixed $raw, string $source, string $field): ?Instant
82    {
83        if ($raw === null || $raw === '') {
84            return null;
85        }
86
87        $instant = \is_string($raw) ? Instant::tryFromIso($raw) : null;
88
89        if ($instant === null) {
90            $this->warnOncePerField($source, $field, $raw, 'Home Assistant sent a timestamp that is not ISO-8601; it is treated as missing');
91        }
92
93        return $instant;
94    }
95
96    private function parseEpochSecondsOrWarn(mixed $raw, string $source, string $field): ?Instant
97    {
98        if ($raw === null) {
99            return null;
100        }
101
102        if (!\is_int($raw) && !\is_float($raw)) {
103            $this->warnOncePerField($source, $field, $raw, 'Home Assistant sent a timestamp that is not epoch seconds; it is treated as missing');
104
105            return null;
106        }
107
108        return Instant::fromEpochMicroseconds((int) round($raw * self::MICROS_PER_SECOND));
109    }
110
111    private function warnOncePerField(string $source, string $field, mixed $raw, string $message): void
112    {
113        // A broken integration sends the same bad value on every change, so each field warns once.
114        $key = $source . "\0" . $field;
115
116        if (isset($this->warnedFields[$key])) {
117            return;
118        }
119
120        $this->warnedFields[$key] = true;
121        $this->logger->warning($message, [
122            'source' => $source,
123            'field' => $field,
124            'value' => \is_scalar($raw) ? (string) $raw : get_debug_type($raw),
125        ]);
126    }
127}