Lines 100.00% 81 / 81
Methods 100.00% 11 / 11
Classes 100.00% 1 / 1
Name Lines Methods CRAP
 __construct 100.00% 1 / 1 100.00% 1 / 1 1
 encodeSnapshot 100.00% 5 / 5 100.00% 1 / 1 1
 buildSnapshotArray 100.00% 9 / 9 100.00% 1 / 1 1
 buildAreaArray 100.00% 8 / 8 100.00% 1 / 1 1
 buildFloorArray 100.00% 7 / 7 100.00% 1 / 1 1
 buildLabelArray 100.00% 7 / 7 100.00% 1 / 1 1
 buildEntityStateArray 100.00% 9 / 9 100.00% 1 / 1 2
 decodeSnapshot 100.00% 6 / 6 100.00% 1 / 1 4
 buildSnapshotFromDecodedJson 100.00% 12 / 12 100.00% 1 / 1 4
 decodeRegistryRows 100.00% 6 / 6 100.00% 1 / 1 5
 decodeEntriesSkippingInvalidIds 100.00% 11 / 11 100.00% 1 / 1 6
23final readonly class SnapshotCodec
24{
25    public function __construct(
26        private EntityStateDecoder $entityStateDecoder,
27        private RegistryDecoder $registryDecoder = new RegistryDecoder(),
28    ) {}
29
30    public function encodeSnapshot(Snapshot $snapshot): string
31    {
32        return json_encode(
33            $this->buildSnapshotArray($snapshot),
34            // Keeps 1.0 a float; as 1 it would decode as int and change the inferred type.
35            \JSON_PRETTY_PRINT | \JSON_UNESCAPED_SLASHES | \JSON_UNESCAPED_UNICODE
36                | \JSON_PRESERVE_ZERO_FRACTION | \JSON_THROW_ON_ERROR,
37        ) . "\n";
38    }
39
40    /** @return array<string, mixed> */
41    private function buildSnapshotArray(Snapshot $snapshot): array
42    {
43        return [
44            'ha_version' => $snapshot->haVersion,
45            'states' => $snapshot->states->mapToList($this->buildEntityStateArray(...)),
46            'registry' => $snapshot->registry->mapToList(static fn(EntityRegistryEntry $entry): array => $entry->toArray()),
47            'services' => $snapshot->services,
48            'areas' => $snapshot->areas->mapToList($this->buildAreaArray(...)),
49            'floors' => $snapshot->floors->mapToList($this->buildFloorArray(...)),
50            'labels' => $snapshot->labels->mapToList($this->buildLabelArray(...)),
51        ];
52    }
53
54    /** @return array<string, mixed> */
55    private function buildAreaArray(Area $area): array
56    {
57        return [
58            'area_id' => $area->areaId->value,
59            'name' => $area->name,
60            'floor_id' => $area->floorId?->value,
61            'aliases' => $area->aliases,
62            'labels' => $area->listLabelIds()->toStrings(),
63            'icon' => $area->icon,
64        ];
65    }
66
67    /** @return array<string, mixed> */
68    private function buildFloorArray(Floor $floor): array
69    {
70        return [
71            'floor_id' => $floor->floorId->value,
72            'name' => $floor->name,
73            'level' => $floor->level,
74            'aliases' => $floor->aliases,
75            'icon' => $floor->icon,
76        ];
77    }
78
79    /** @return array<string, mixed> */
80    private function buildLabelArray(Label $label): array
81    {
82        return [
83            'label_id' => $label->labelId->value,
84            'name' => $label->name,
85            'color' => $label->color,
86            'icon' => $label->icon,
87            'description' => $label->description,
88        ];
89    }
90
91    /** @return array<string, mixed> */
92    private function buildEntityStateArray(EntityState $state): array
93    {
94        $context = $state->context;
95
96        return [
97            'entity_id' => $state->entityId->value,
98            'state' => $state->state,
99            'attributes' => $state->attributes,
100            'last_changed' => $state->lastChangedAt?->toIso8601(),
101            'last_updated' => $state->lastUpdatedAt?->toIso8601(),
102            'context' => $context === null ? null : ['id' => $context->id, 'parent_id' => $context->parentId, 'user_id' => $context->userId],
103        ];
104    }
105
106    /** @throws CodegenException */
107    public function decodeSnapshot(string $json, string $origin, LoggerInterface $logger = new NullLogger()): Snapshot
108    {
109        try {
110            $decoded = JsonDecoder::decodeJson($json);
111        } catch (JsonException $e) {
112            throw CodegenException::createForReason(CodegenError::SnapshotNotJson, path: $origin, reason: $e->getMessage());
113        }
114
115        if (!\is_array($decoded) || !\array_key_exists('states', $decoded)) {
116            throw CodegenException::createForReason(CodegenError::NotASnapshot, path: $origin);
117        }
118
119        return $this->buildSnapshotFromDecodedJson($decoded, $logger);
120    }
121
122    /** @param array<array-key, mixed> $decoded */
123    private function buildSnapshotFromDecodedJson(array $decoded, LoggerInterface $logger): Snapshot
124    {
125        $services = [];
126
127        foreach (\is_array($decoded['services'] ?? null) ? $decoded['services'] : [] as $domain => $definitions) {
128            $services[(string) $domain] = $definitions;
129        }
130
131        return Snapshot::fromParts(
132            haVersion: \is_string($decoded['ha_version'] ?? null) ? $decoded['ha_version'] : 'unknown',
133            states: $this->decodeEntriesSkippingInvalidIds($decoded['states'] ?? null, $this->entityStateDecoder->decodeEntityStateOrSkip(...), $logger),
134            registry: $this->decodeEntriesSkippingInvalidIds($decoded['registry'] ?? null, EntityRegistryEntry::fromArray(...), $logger),
135            services: $services,
136            areas: $this->decodeRegistryRows($decoded['areas'] ?? null, $this->registryDecoder->decodeAreaOrSkip(...)),
137            floors: $this->decodeRegistryRows($decoded['floors'] ?? null, $this->registryDecoder->decodeFloorOrSkip(...)),
138            labels: $this->decodeRegistryRows($decoded['labels'] ?? null, $this->registryDecoder->decodeLabelOrSkip(...)),
139        );
140    }
141
142    /**
143     * @template T of object
144     * @param Closure(array<array-key, mixed>): ?T $decodeRow
145     * @return list<T>
146     */
147    private function decodeRegistryRows(mixed $rows, Closure $decodeRow): array
148    {
149        $decoded = [];
150
151        foreach (\is_array($rows) ? $rows : [] as $row) {
152            $entry = \is_array($row) ? $decodeRow($row) : null;
153
154            if ($entry !== null) {
155                $decoded[] = $entry;
156            }
157        }
158
159        return $decoded;
160    }
161
162    /**
163     * @template T of object
164     * @param Closure(array<array-key, mixed>): ?T $decodeEntry
165     * @return list<T>
166     */
167    private function decodeEntriesSkippingInvalidIds(mixed $entries, Closure $decodeEntry, LoggerInterface $logger): array
168    {
169        $decoded = [];
170
171        foreach (\is_array($entries) ? $entries : [] as $entry) {
172            if (!\is_array($entry)) {
173                continue;
174            }
175
176            try {
177                $decodedEntry = $decodeEntry($entry);
178            } catch (IdentifierException $e) {
179                $decodedEntry = null;
180            }
181
182            if ($decodedEntry === null) {
183                $logger->warning('Skipped a snapshot entry without a valid entity id', ['entry' => $entry['entity_id'] ?? null]);
184
185                continue;
186            }
187
188            $decoded[] = $decodedEntry;
189        }
190
191        return $decoded;
192    }
193}