@camstack/addon-provider-homeassistant 1.2.13 → 1.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +414 -31331
- package/dist/addon.mjs +67 -30984
- package/dist/dist-DJqSvADu.js +33568 -0
- package/dist/dist-DdyXnBOa.mjs +33107 -0
- package/dist/ha-export/ha-export.addon.js +2298 -0
- package/dist/ha-export/ha-export.addon.mjs +2293 -0
- package/dist/index.js +2 -0
- package/dist/index.mjs +2 -1
- package/package.json +33 -4
|
@@ -0,0 +1,2293 @@
|
|
|
1
|
+
import { Ct as EventCategory, O as deviceExportCapability, c as addonRoutesCapability, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, st as BaseAddon, v as buildAddonRouteProvider, yt as string } from "../dist-DdyXnBOa.mjs";
|
|
2
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
|
+
//#region src/ha-export/topics.ts
|
|
4
|
+
/**
|
|
5
|
+
* The topic grammar. Every topic in the export is built HERE.
|
|
6
|
+
*
|
|
7
|
+
* The previous HA exporter learned this the hard way: deriving one topic
|
|
8
|
+
* by string-editing another (`stateTopic.replace(/\/[^/]+$/, '/availability')`)
|
|
9
|
+
* broke the moment two components had different layouts. There is one
|
|
10
|
+
* builder per topic kind and no exceptions.
|
|
11
|
+
*
|
|
12
|
+
* A topic is a wire format. `state` and `command` are matched verbatim by
|
|
13
|
+
* the component, so a change here silently detaches every entity from its
|
|
14
|
+
* value.
|
|
15
|
+
*/
|
|
16
|
+
/** Root of every topic. Namespaces the export inside HA's event bus. */
|
|
17
|
+
var TOPIC_ROOT = "camstack";
|
|
18
|
+
/** Prefix that turns a device's `stableId` into its HA `device_id`. */
|
|
19
|
+
var DEVICE_KEY_PREFIX = "camstack-";
|
|
20
|
+
/**
|
|
21
|
+
* The HA `device_id` for a camstack device.
|
|
22
|
+
*
|
|
23
|
+
* Derived from `stableId`, never from the numeric id: numeric ids are
|
|
24
|
+
* reallocated by a re-sync, and an operator who re-adopts a camera would
|
|
25
|
+
* get a second HA device with every automation pointing at the orphan.
|
|
26
|
+
*/
|
|
27
|
+
function deviceKeyFor(stableId) {
|
|
28
|
+
return `${DEVICE_KEY_PREFIX}${stableId}`;
|
|
29
|
+
}
|
|
30
|
+
function stateTopic(deviceKey, entity) {
|
|
31
|
+
return `${TOPIC_ROOT}/${deviceKey}/${entity}`;
|
|
32
|
+
}
|
|
33
|
+
function commandTopic(deviceKey, entity) {
|
|
34
|
+
return `${TOPIC_ROOT}/${deviceKey}/${entity}/set`;
|
|
35
|
+
}
|
|
36
|
+
function parseCommandTopic(topic) {
|
|
37
|
+
const parts = topic.split("/");
|
|
38
|
+
if (parts.length !== 4) return null;
|
|
39
|
+
if (parts[0] !== "camstack") return null;
|
|
40
|
+
if (parts[3] !== "set") return null;
|
|
41
|
+
const deviceKey = parts[1];
|
|
42
|
+
const entity = parts[2];
|
|
43
|
+
if (deviceKey === void 0 || entity === void 0) return null;
|
|
44
|
+
if (deviceKey.length === 0 || entity.length === 0) return null;
|
|
45
|
+
return {
|
|
46
|
+
deviceKey,
|
|
47
|
+
entity
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
/** `Person last image` ← `person_last_image`. Display only, never an id. */
|
|
51
|
+
function humanise(entity) {
|
|
52
|
+
const words = entity.replace(/[_-]+/g, " ").trim();
|
|
53
|
+
return words.charAt(0).toUpperCase() + words.slice(1);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* `person_detected` → `person-detected`.
|
|
57
|
+
*
|
|
58
|
+
* Component keys are kebab; entity/topic segments are snake. They are two
|
|
59
|
+
* different namespaces and conflating them produces entities that build
|
|
60
|
+
* but never receive a value.
|
|
61
|
+
*/
|
|
62
|
+
function toComponentKey(platform, entity) {
|
|
63
|
+
return `${platform}-${entity.replace(/_/g, "-").replace(/[^a-zA-Z0-9_-]/g, "_")}`;
|
|
64
|
+
}
|
|
65
|
+
/** Lower-case, underscore-joined, diacritic-free. Ids only, never labels. */
|
|
66
|
+
function toSlug(value) {
|
|
67
|
+
return value.normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/([a-z0-9])([A-Z])/g, "$1_$2").replace(/[^a-zA-Z0-9]+/g, "_").replace(/^_+|_+$/g, "").toLowerCase();
|
|
68
|
+
}
|
|
69
|
+
//#endregion
|
|
70
|
+
//#region src/ha-export/entity-catalog.ts
|
|
71
|
+
/**
|
|
72
|
+
* The entity catalog — pure. Device + zones + macros → the `cmps` set.
|
|
73
|
+
*
|
|
74
|
+
* No I/O lives here on purpose: this is the 1:1 mapping the design
|
|
75
|
+
* agreed, and it is where the mapping is tested. A change to this file
|
|
76
|
+
* changes what an operator sees in Home Assistant, so it is tested
|
|
77
|
+
* rather than reasoned about.
|
|
78
|
+
*
|
|
79
|
+
* The split is principled, not expedient (design addendum):
|
|
80
|
+
*
|
|
81
|
+
* - **Cameras** get a HAND-WRITTEN catalog, because their entities are
|
|
82
|
+
* projections of pipeline and notification-center output — there is
|
|
83
|
+
* no capability to derive `last_detection`, `last_label` or a
|
|
84
|
+
* per-zone count from.
|
|
85
|
+
* - **Every other kind** is DERIVED from the capability the device
|
|
86
|
+
* declares. A switch's state IS modelled by a cap; an alarm panel's
|
|
87
|
+
* state IS `AlarmStateSchema`.
|
|
88
|
+
*
|
|
89
|
+
* Two guards protect the half that a future change would silently break:
|
|
90
|
+
* `MACROS_NOT_EXPORTED` and `CAPS_NOT_EXPORTED` force a written decision
|
|
91
|
+
* for every macro and every mapped capability. Something declared that
|
|
92
|
+
* nothing produces is the failure this repo keeps paying for.
|
|
93
|
+
*/
|
|
94
|
+
/**
|
|
95
|
+
* The macro classes an operator automates on.
|
|
96
|
+
*
|
|
97
|
+
* Deliberately the macros and NOT the 21 fine classes: those include
|
|
98
|
+
* `kite` and `toothbrush`, and 21 × 4 entities per camera buys nothing.
|
|
99
|
+
*/
|
|
100
|
+
var EXPORTED_MACROS = [
|
|
101
|
+
"motion",
|
|
102
|
+
"audio",
|
|
103
|
+
"person",
|
|
104
|
+
"vehicle",
|
|
105
|
+
"animal",
|
|
106
|
+
"package"
|
|
107
|
+
];
|
|
108
|
+
/**
|
|
109
|
+
* Macros that carry an IDENTIFICATION: the recognised face for `person`,
|
|
110
|
+
* the plate for `vehicle`, the sub-class for `animal`.
|
|
111
|
+
*
|
|
112
|
+
* `package`, `motion` and `audio` have no label, so the entity is NOT
|
|
113
|
+
* created for them rather than created empty — an entity that can never
|
|
114
|
+
* receive a value reads to an operator as a broken integration.
|
|
115
|
+
*/
|
|
116
|
+
var LABELLED_MACROS = [
|
|
117
|
+
"person",
|
|
118
|
+
"vehicle",
|
|
119
|
+
"animal"
|
|
120
|
+
];
|
|
121
|
+
/** Per zone the macro set is restricted — matching the reference. */
|
|
122
|
+
var ZONE_MACROS = [
|
|
123
|
+
"person",
|
|
124
|
+
"vehicle",
|
|
125
|
+
"animal"
|
|
126
|
+
];
|
|
127
|
+
/**
|
|
128
|
+
* ~73 entities per camera with 3 zones. Only the ones people automate on
|
|
129
|
+
* arrive enabled; everything else is registered and switched off, for the
|
|
130
|
+
* operator to enable when they want it.
|
|
131
|
+
*/
|
|
132
|
+
var ENABLED_BY_DEFAULT_SUFFIXES = ["detected"];
|
|
133
|
+
var ENABLED_BY_DEFAULT_ENTITIES = [
|
|
134
|
+
"triggered",
|
|
135
|
+
"online",
|
|
136
|
+
"last_image"
|
|
137
|
+
];
|
|
138
|
+
function isEnabledByDefault(entity, platform) {
|
|
139
|
+
if (platform === "switch" || platform === "button" || platform === "select") return true;
|
|
140
|
+
if (ENABLED_BY_DEFAULT_ENTITIES.includes(entity)) return true;
|
|
141
|
+
return ENABLED_BY_DEFAULT_SUFFIXES.some((suffix) => entity.endsWith(`_${suffix}`));
|
|
142
|
+
}
|
|
143
|
+
/**
|
|
144
|
+
* The snooze surface, as a `select`.
|
|
145
|
+
*
|
|
146
|
+
* `NC_SNOOZE_MAX_MINUTES` is 1440 server-side and there is no enum of
|
|
147
|
+
* preset durations, so the presets are chosen here. `Off` cancels.
|
|
148
|
+
*/
|
|
149
|
+
var SNOOZE_OPTIONS = [
|
|
150
|
+
"Off",
|
|
151
|
+
"15 minutes",
|
|
152
|
+
"30 minutes",
|
|
153
|
+
"1 hour",
|
|
154
|
+
"2 hours",
|
|
155
|
+
"4 hours",
|
|
156
|
+
"8 hours",
|
|
157
|
+
"24 hours"
|
|
158
|
+
];
|
|
159
|
+
var SNOOZE_MINUTES = {
|
|
160
|
+
"15 minutes": 15,
|
|
161
|
+
"30 minutes": 30,
|
|
162
|
+
"1 hour": 60,
|
|
163
|
+
"2 hours": 120,
|
|
164
|
+
"4 hours": 240,
|
|
165
|
+
"8 hours": 480,
|
|
166
|
+
"24 hours": 1440
|
|
167
|
+
};
|
|
168
|
+
var PTZ_BUTTONS = [
|
|
169
|
+
"ptz_up",
|
|
170
|
+
"ptz_down",
|
|
171
|
+
"ptz_left",
|
|
172
|
+
"ptz_right",
|
|
173
|
+
"ptz_zoom_in",
|
|
174
|
+
"ptz_zoom_out"
|
|
175
|
+
];
|
|
176
|
+
function buildComponent(device, spec) {
|
|
177
|
+
const deviceKey = deviceKeyFor(device.stableId);
|
|
178
|
+
const enabled = isEnabledByDefault(spec.entity, spec.platform);
|
|
179
|
+
return {
|
|
180
|
+
platform: spec.platform,
|
|
181
|
+
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
182
|
+
name: spec.label,
|
|
183
|
+
...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
184
|
+
...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
185
|
+
...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
|
|
186
|
+
...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
|
|
187
|
+
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
188
|
+
...spec.options !== void 0 ? { options: spec.options } : {},
|
|
189
|
+
...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
|
|
190
|
+
...enabled ? {} : { enabled_by_default: false },
|
|
191
|
+
...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
|
|
192
|
+
payload_on: "true",
|
|
193
|
+
payload_off: "false"
|
|
194
|
+
} : {},
|
|
195
|
+
...spec.platform === "button" ? { payload_press: "PRESS" } : {}
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
function deviceBlock(device) {
|
|
199
|
+
return {
|
|
200
|
+
ids: [deviceKeyFor(device.stableId)],
|
|
201
|
+
name: device.name,
|
|
202
|
+
mf: device.manufacturer ?? "CamStack",
|
|
203
|
+
mdl: device.model ?? device.type
|
|
204
|
+
};
|
|
205
|
+
}
|
|
206
|
+
function zoneSpecs(zone) {
|
|
207
|
+
const slug = toSlug(zone.id);
|
|
208
|
+
const specs = [];
|
|
209
|
+
for (const macro of ZONE_MACROS) {
|
|
210
|
+
const label = `${zone.name} ${macro}`;
|
|
211
|
+
specs.push({
|
|
212
|
+
entity: `${slug}_${macro}_detected`,
|
|
213
|
+
platform: "binary_sensor",
|
|
214
|
+
label: `${label} detected`,
|
|
215
|
+
uniqueSuffix: `${zone.id}_${macro}_detected`,
|
|
216
|
+
deviceClass: "motion"
|
|
217
|
+
}, {
|
|
218
|
+
entity: `${slug}_${macro}_last_image`,
|
|
219
|
+
platform: "image",
|
|
220
|
+
label: `${label} last image`,
|
|
221
|
+
uniqueSuffix: `${zone.id}_${macro}_last_image`
|
|
222
|
+
}, {
|
|
223
|
+
entity: `${slug}_${macro}_last_detection`,
|
|
224
|
+
platform: "sensor",
|
|
225
|
+
label: `${label} last detection`,
|
|
226
|
+
uniqueSuffix: `${zone.id}_${macro}_last_detection`,
|
|
227
|
+
deviceClass: "timestamp"
|
|
228
|
+
}, {
|
|
229
|
+
entity: `${slug}_${macro}_objects`,
|
|
230
|
+
platform: "sensor",
|
|
231
|
+
label: `${label} objects`,
|
|
232
|
+
uniqueSuffix: `${zone.id}_${macro}_objects`,
|
|
233
|
+
icon: "mdi:counter"
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
return specs;
|
|
237
|
+
}
|
|
238
|
+
function cameraSpecs(device) {
|
|
239
|
+
const specs = [
|
|
240
|
+
{
|
|
241
|
+
entity: "triggered",
|
|
242
|
+
platform: "binary_sensor",
|
|
243
|
+
label: "Triggered",
|
|
244
|
+
deviceClass: "motion"
|
|
245
|
+
},
|
|
246
|
+
{
|
|
247
|
+
entity: "online",
|
|
248
|
+
platform: "binary_sensor",
|
|
249
|
+
label: "Online",
|
|
250
|
+
deviceClass: "connectivity",
|
|
251
|
+
entityCategory: "diagnostic"
|
|
252
|
+
},
|
|
253
|
+
{
|
|
254
|
+
entity: "last_image",
|
|
255
|
+
platform: "image",
|
|
256
|
+
label: "Last image"
|
|
257
|
+
},
|
|
258
|
+
{
|
|
259
|
+
entity: "last_detection",
|
|
260
|
+
platform: "sensor",
|
|
261
|
+
label: "Last detection",
|
|
262
|
+
deviceClass: "timestamp",
|
|
263
|
+
icon: "mdi:clock"
|
|
264
|
+
}
|
|
265
|
+
];
|
|
266
|
+
if (device.slices.includes("battery")) specs.push({
|
|
267
|
+
entity: "battery",
|
|
268
|
+
platform: "sensor",
|
|
269
|
+
label: "Battery",
|
|
270
|
+
deviceClass: "battery",
|
|
271
|
+
unit: "%",
|
|
272
|
+
entityCategory: "diagnostic"
|
|
273
|
+
}, {
|
|
274
|
+
entity: "charger",
|
|
275
|
+
platform: "binary_sensor",
|
|
276
|
+
label: "Charging",
|
|
277
|
+
deviceClass: "battery_charging",
|
|
278
|
+
entityCategory: "diagnostic"
|
|
279
|
+
}, {
|
|
280
|
+
entity: "sleeping",
|
|
281
|
+
platform: "binary_sensor",
|
|
282
|
+
label: "Sleeping",
|
|
283
|
+
entityCategory: "diagnostic"
|
|
284
|
+
});
|
|
285
|
+
/**
|
|
286
|
+
* One HA switch per AVAILABLE camera switch, rendered from
|
|
287
|
+
* `getCameraSwitches` and commanded back through `setCameraSwitch`.
|
|
288
|
+
*
|
|
289
|
+
* The export never writes an authority itself: a second knob that can
|
|
290
|
+
* disagree with the admin UI is worse than no knob (D62). An
|
|
291
|
+
* unavailable switch produces NO entity — a control defaulted to `on`
|
|
292
|
+
* because a source did not answer is exactly the lie that rule exists
|
|
293
|
+
* to prevent.
|
|
294
|
+
*/
|
|
295
|
+
for (const sw of device.switches) {
|
|
296
|
+
if (!sw.available) continue;
|
|
297
|
+
specs.push({
|
|
298
|
+
entity: toSlug(sw.id),
|
|
299
|
+
platform: "switch",
|
|
300
|
+
label: sw.label,
|
|
301
|
+
writable: true
|
|
302
|
+
});
|
|
303
|
+
}
|
|
304
|
+
if (device.features.includes("rebootable")) specs.push({
|
|
305
|
+
entity: "reboot",
|
|
306
|
+
platform: "button",
|
|
307
|
+
label: "Reboot",
|
|
308
|
+
writable: true
|
|
309
|
+
});
|
|
310
|
+
if (device.boundCaps.includes("ptz")) {
|
|
311
|
+
for (const entity of PTZ_BUTTONS) specs.push({
|
|
312
|
+
entity,
|
|
313
|
+
platform: "button",
|
|
314
|
+
label: humanise(entity),
|
|
315
|
+
writable: true
|
|
316
|
+
});
|
|
317
|
+
specs.push({
|
|
318
|
+
entity: "ptz_preset",
|
|
319
|
+
platform: "select",
|
|
320
|
+
label: "PTZ preset",
|
|
321
|
+
writable: true,
|
|
322
|
+
options: device.ptzPresets ?? []
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
specs.push({
|
|
326
|
+
entity: "snooze",
|
|
327
|
+
platform: "select",
|
|
328
|
+
label: "Snooze notifications",
|
|
329
|
+
writable: true,
|
|
330
|
+
options: SNOOZE_OPTIONS
|
|
331
|
+
});
|
|
332
|
+
for (const macro of EXPORTED_MACROS) {
|
|
333
|
+
const label = humanise(macro);
|
|
334
|
+
specs.push({
|
|
335
|
+
entity: `${macro}_detected`,
|
|
336
|
+
platform: "binary_sensor",
|
|
337
|
+
label: `${label} detected`,
|
|
338
|
+
deviceClass: "motion"
|
|
339
|
+
}, {
|
|
340
|
+
entity: `${macro}_last_image`,
|
|
341
|
+
platform: "image",
|
|
342
|
+
label: `${label} last image`
|
|
343
|
+
}, {
|
|
344
|
+
entity: `${macro}_last_detection`,
|
|
345
|
+
platform: "sensor",
|
|
346
|
+
label: `${label} last detection`,
|
|
347
|
+
deviceClass: "timestamp"
|
|
348
|
+
});
|
|
349
|
+
if (LABELLED_MACROS.includes(macro)) specs.push({
|
|
350
|
+
entity: `${macro}_last_label`,
|
|
351
|
+
platform: "sensor",
|
|
352
|
+
label: `${label} last label`,
|
|
353
|
+
icon: "mdi:tag"
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
for (const zone of device.zones) specs.push(...zoneSpecs(zone));
|
|
357
|
+
return specs;
|
|
358
|
+
}
|
|
359
|
+
function assemble(device, specs) {
|
|
360
|
+
const cmps = {};
|
|
361
|
+
for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = buildComponent(device, spec);
|
|
362
|
+
return {
|
|
363
|
+
deviceKey: deviceKeyFor(device.stableId),
|
|
364
|
+
deviceId: device.id,
|
|
365
|
+
dev: deviceBlock(device),
|
|
366
|
+
cmps
|
|
367
|
+
};
|
|
368
|
+
}
|
|
369
|
+
/** The hand-written half: a camera's entities come from analytics. */
|
|
370
|
+
function buildCameraPlan(device) {
|
|
371
|
+
return assemble(device, cameraSpecs(device));
|
|
372
|
+
}
|
|
373
|
+
var CAP_ENTITY_MAP = {
|
|
374
|
+
switch: {
|
|
375
|
+
platform: "switch",
|
|
376
|
+
field: "on",
|
|
377
|
+
writable: true
|
|
378
|
+
},
|
|
379
|
+
"lock-control": {
|
|
380
|
+
platform: "switch",
|
|
381
|
+
field: "locked",
|
|
382
|
+
deviceClass: "lock",
|
|
383
|
+
writable: true
|
|
384
|
+
},
|
|
385
|
+
siren: {
|
|
386
|
+
platform: "switch",
|
|
387
|
+
field: "active",
|
|
388
|
+
writable: true
|
|
389
|
+
},
|
|
390
|
+
button: {
|
|
391
|
+
platform: "button",
|
|
392
|
+
field: "pressed",
|
|
393
|
+
writable: true
|
|
394
|
+
},
|
|
395
|
+
brightness: {
|
|
396
|
+
platform: "number",
|
|
397
|
+
field: "brightness",
|
|
398
|
+
writable: true
|
|
399
|
+
},
|
|
400
|
+
binary: {
|
|
401
|
+
platform: "binary_sensor",
|
|
402
|
+
field: "state"
|
|
403
|
+
},
|
|
404
|
+
motion: {
|
|
405
|
+
platform: "binary_sensor",
|
|
406
|
+
field: "detected",
|
|
407
|
+
deviceClass: "motion"
|
|
408
|
+
},
|
|
409
|
+
contact: {
|
|
410
|
+
platform: "binary_sensor",
|
|
411
|
+
field: "open",
|
|
412
|
+
deviceClass: "door"
|
|
413
|
+
},
|
|
414
|
+
presence: {
|
|
415
|
+
platform: "binary_sensor",
|
|
416
|
+
field: "present",
|
|
417
|
+
deviceClass: "presence"
|
|
418
|
+
},
|
|
419
|
+
connectivity: {
|
|
420
|
+
platform: "binary_sensor",
|
|
421
|
+
field: "online",
|
|
422
|
+
deviceClass: "connectivity"
|
|
423
|
+
},
|
|
424
|
+
flood: {
|
|
425
|
+
platform: "binary_sensor",
|
|
426
|
+
field: "detected",
|
|
427
|
+
deviceClass: "moisture"
|
|
428
|
+
},
|
|
429
|
+
smoke: {
|
|
430
|
+
platform: "binary_sensor",
|
|
431
|
+
field: "detected",
|
|
432
|
+
deviceClass: "smoke"
|
|
433
|
+
},
|
|
434
|
+
gas: {
|
|
435
|
+
platform: "binary_sensor",
|
|
436
|
+
field: "detected",
|
|
437
|
+
deviceClass: "gas"
|
|
438
|
+
},
|
|
439
|
+
"carbon-monoxide": {
|
|
440
|
+
platform: "binary_sensor",
|
|
441
|
+
field: "detected",
|
|
442
|
+
deviceClass: "carbon_monoxide"
|
|
443
|
+
},
|
|
444
|
+
tamper: {
|
|
445
|
+
platform: "binary_sensor",
|
|
446
|
+
field: "detected",
|
|
447
|
+
deviceClass: "tamper"
|
|
448
|
+
},
|
|
449
|
+
vibration: {
|
|
450
|
+
platform: "binary_sensor",
|
|
451
|
+
field: "detected",
|
|
452
|
+
deviceClass: "vibration"
|
|
453
|
+
},
|
|
454
|
+
doorbell: {
|
|
455
|
+
platform: "sensor",
|
|
456
|
+
field: "lastPressedAt",
|
|
457
|
+
deviceClass: "timestamp"
|
|
458
|
+
},
|
|
459
|
+
"temperature-sensor": {
|
|
460
|
+
platform: "sensor",
|
|
461
|
+
field: "temperature",
|
|
462
|
+
deviceClass: "temperature",
|
|
463
|
+
unit: "°C"
|
|
464
|
+
},
|
|
465
|
+
"humidity-sensor": {
|
|
466
|
+
platform: "sensor",
|
|
467
|
+
field: "humidity",
|
|
468
|
+
deviceClass: "humidity",
|
|
469
|
+
unit: "%"
|
|
470
|
+
},
|
|
471
|
+
"pressure-sensor": {
|
|
472
|
+
platform: "sensor",
|
|
473
|
+
field: "pressure",
|
|
474
|
+
deviceClass: "pressure",
|
|
475
|
+
unit: "hPa"
|
|
476
|
+
},
|
|
477
|
+
"ambient-light-sensor": {
|
|
478
|
+
platform: "sensor",
|
|
479
|
+
field: "illuminance",
|
|
480
|
+
deviceClass: "illuminance",
|
|
481
|
+
unit: "lx"
|
|
482
|
+
},
|
|
483
|
+
"numeric-sensor": {
|
|
484
|
+
platform: "sensor",
|
|
485
|
+
field: "value"
|
|
486
|
+
},
|
|
487
|
+
"enum-sensor": {
|
|
488
|
+
platform: "sensor",
|
|
489
|
+
field: "value"
|
|
490
|
+
},
|
|
491
|
+
battery: {
|
|
492
|
+
platform: "sensor",
|
|
493
|
+
field: "percentage",
|
|
494
|
+
deviceClass: "battery",
|
|
495
|
+
unit: "%"
|
|
496
|
+
},
|
|
497
|
+
"power-meter": {
|
|
498
|
+
platform: "sensor",
|
|
499
|
+
field: "watts",
|
|
500
|
+
deviceClass: "power",
|
|
501
|
+
unit: "W"
|
|
502
|
+
},
|
|
503
|
+
"air-quality-sensor": {
|
|
504
|
+
platform: "sensor",
|
|
505
|
+
field: "aqi",
|
|
506
|
+
deviceClass: "aqi"
|
|
507
|
+
},
|
|
508
|
+
"alarm-panel": {
|
|
509
|
+
platform: "alarm_control_panel",
|
|
510
|
+
field: "state",
|
|
511
|
+
writable: true
|
|
512
|
+
}
|
|
513
|
+
};
|
|
514
|
+
var CAPS_NOT_EXPORTED = [
|
|
515
|
+
{
|
|
516
|
+
cap: "snapshot",
|
|
517
|
+
reason: "Carried by the camera catalog as `last_image`; a second image entity from the cap would duplicate it."
|
|
518
|
+
},
|
|
519
|
+
{
|
|
520
|
+
cap: "privacy-mask",
|
|
521
|
+
reason: "Rendered as a camera switch through getCameraSwitches, never written directly (D62)."
|
|
522
|
+
},
|
|
523
|
+
{
|
|
524
|
+
cap: "zone-analytics",
|
|
525
|
+
reason: "Projected into the per-zone object counts on the camera, not an entity of its own."
|
|
526
|
+
},
|
|
527
|
+
{
|
|
528
|
+
cap: "native-object-detection",
|
|
529
|
+
reason: "On-camera AI feeds the same per-macro entities as the pipeline; a parallel set would disagree with them."
|
|
530
|
+
},
|
|
531
|
+
{
|
|
532
|
+
cap: "ptz",
|
|
533
|
+
reason: "Rendered as buttons and a preset select by the camera catalog."
|
|
534
|
+
},
|
|
535
|
+
{
|
|
536
|
+
cap: "reboot",
|
|
537
|
+
reason: "Rendered as a button, gated on the `rebootable` feature rather than the binding."
|
|
538
|
+
}
|
|
539
|
+
];
|
|
540
|
+
/** A capability in neither list — the thing the guard exists to surface. */
|
|
541
|
+
function unclassifiedCaps(caps) {
|
|
542
|
+
const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
|
|
543
|
+
return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
|
|
544
|
+
}
|
|
545
|
+
/** The derived half: every non-camera kind, from the caps it declares. */
|
|
546
|
+
function buildDerivedPlan(device) {
|
|
547
|
+
const specs = [{
|
|
548
|
+
entity: "online",
|
|
549
|
+
platform: "binary_sensor",
|
|
550
|
+
label: "Online",
|
|
551
|
+
deviceClass: "connectivity",
|
|
552
|
+
entityCategory: "diagnostic"
|
|
553
|
+
}];
|
|
554
|
+
for (const cap of device.boundCaps) {
|
|
555
|
+
const mapping = CAP_ENTITY_MAP[cap];
|
|
556
|
+
if (mapping === void 0) continue;
|
|
557
|
+
specs.push({
|
|
558
|
+
entity: toSlug(cap),
|
|
559
|
+
platform: mapping.platform,
|
|
560
|
+
label: humanise(cap),
|
|
561
|
+
...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
|
|
562
|
+
...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
|
|
563
|
+
...mapping.writable === true ? { writable: true } : {}
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
return assemble(device, specs);
|
|
567
|
+
}
|
|
568
|
+
/** Camera or base kind — the only place that decision is made. */
|
|
569
|
+
function buildDevicePlan(device) {
|
|
570
|
+
return device.type === "camera" ? buildCameraPlan(device) : buildDerivedPlan(device);
|
|
571
|
+
}
|
|
572
|
+
//#endregion
|
|
573
|
+
//#region src/ha-export/command-routes.ts
|
|
574
|
+
/**
|
|
575
|
+
* Home Assistant → camstack.
|
|
576
|
+
*
|
|
577
|
+
* The component POSTs `{topic, value}` onto this addon's `addon-routes`
|
|
578
|
+
* surface; this module turns that pair into a typed command. Resolution
|
|
579
|
+
* is pure so it can be tested without a device, and the addon does the
|
|
580
|
+
* dispatching.
|
|
581
|
+
*
|
|
582
|
+
* The rule that shapes every branch: **an unroutable or malformed
|
|
583
|
+
* command is refused, never approximated.** A `switch` payload that is
|
|
584
|
+
* neither `true` nor `false` is not "off"; a PTZ press on a camera that
|
|
585
|
+
* declares no `ptz` cap is not a no-op worth pretending succeeded. Both
|
|
586
|
+
* return `null`, and the caller logs the drop.
|
|
587
|
+
*/
|
|
588
|
+
/** The camera switch ids, keyed by the entity slug the catalog emits. */
|
|
589
|
+
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries([
|
|
590
|
+
"stream-broker",
|
|
591
|
+
"object-detection",
|
|
592
|
+
"privacy-mask",
|
|
593
|
+
"device-audio",
|
|
594
|
+
"broker-audio",
|
|
595
|
+
"audio-analysis",
|
|
596
|
+
"recording",
|
|
597
|
+
"notifications"
|
|
598
|
+
].map((id) => [toSlug(id), id]));
|
|
599
|
+
var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
|
|
600
|
+
function parseBool(value) {
|
|
601
|
+
const lower = value.trim().toLowerCase();
|
|
602
|
+
if (lower === "true" || lower === "on" || lower === "1") return true;
|
|
603
|
+
if (lower === "false" || lower === "off" || lower === "0") return false;
|
|
604
|
+
return null;
|
|
605
|
+
}
|
|
606
|
+
function resolveCommand(target, entity, value) {
|
|
607
|
+
if (target.type === "camera") {
|
|
608
|
+
const switchId = CAMERA_SWITCH_BY_SLUG[entity];
|
|
609
|
+
if (switchId !== void 0) {
|
|
610
|
+
const enabled = parseBool(value);
|
|
611
|
+
if (enabled === null) return null;
|
|
612
|
+
return {
|
|
613
|
+
kind: "camera-switch",
|
|
614
|
+
deviceId: target.deviceId,
|
|
615
|
+
switchId,
|
|
616
|
+
enabled
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
if (entity === "reboot") return {
|
|
620
|
+
kind: "reboot",
|
|
621
|
+
deviceId: target.deviceId
|
|
622
|
+
};
|
|
623
|
+
const direction = PTZ_DIRECTION_BY_ENTITY[entity];
|
|
624
|
+
if (direction !== void 0) {
|
|
625
|
+
if (!target.boundCaps.includes("ptz")) return null;
|
|
626
|
+
return {
|
|
627
|
+
kind: "ptz-move",
|
|
628
|
+
deviceId: target.deviceId,
|
|
629
|
+
direction
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
if (entity === "ptz_preset") {
|
|
633
|
+
if (!target.boundCaps.includes("ptz")) return null;
|
|
634
|
+
if (value.trim().length === 0) return null;
|
|
635
|
+
return {
|
|
636
|
+
kind: "ptz-preset",
|
|
637
|
+
deviceId: target.deviceId,
|
|
638
|
+
preset: value
|
|
639
|
+
};
|
|
640
|
+
}
|
|
641
|
+
if (entity === "snooze") {
|
|
642
|
+
if (!SNOOZE_OPTIONS.includes(value)) return null;
|
|
643
|
+
const minutes = SNOOZE_MINUTES[value];
|
|
644
|
+
return {
|
|
645
|
+
kind: "snooze",
|
|
646
|
+
deviceId: target.deviceId,
|
|
647
|
+
minutes: minutes ?? null
|
|
648
|
+
};
|
|
649
|
+
}
|
|
650
|
+
return null;
|
|
651
|
+
}
|
|
652
|
+
/**
|
|
653
|
+
* The derived half. The entity slug is the capability name, so the
|
|
654
|
+
* mapping table decides both the platform and whether it is writable —
|
|
655
|
+
* there is no separate list to fall out of sync with the catalog.
|
|
656
|
+
*/
|
|
657
|
+
for (const [capName, mapping] of Object.entries(CAP_ENTITY_MAP)) {
|
|
658
|
+
if (toSlug(capName) !== entity) continue;
|
|
659
|
+
if (mapping.writable !== true) return null;
|
|
660
|
+
if (!target.boundCaps.includes(capName)) return null;
|
|
661
|
+
if (mapping.platform === "button") return {
|
|
662
|
+
kind: "cap-button",
|
|
663
|
+
deviceId: target.deviceId,
|
|
664
|
+
capName
|
|
665
|
+
};
|
|
666
|
+
const on = parseBool(value);
|
|
667
|
+
if (on === null) return null;
|
|
668
|
+
return {
|
|
669
|
+
kind: "cap-switch",
|
|
670
|
+
deviceId: target.deviceId,
|
|
671
|
+
capName,
|
|
672
|
+
on
|
|
673
|
+
};
|
|
674
|
+
}
|
|
675
|
+
return null;
|
|
676
|
+
}
|
|
677
|
+
//#endregion
|
|
678
|
+
//#region src/ha-export/media-url.ts
|
|
679
|
+
/**
|
|
680
|
+
* Signed, expiring media links.
|
|
681
|
+
*
|
|
682
|
+
* Images never cross the push transport. Home Assistant is handed a URL
|
|
683
|
+
* and fetches the bytes itself, on demand, which is what keeps a camera
|
|
684
|
+
* with a `last_image` entity from turning every detection into a
|
|
685
|
+
* multi-megabyte POST.
|
|
686
|
+
*
|
|
687
|
+
* The link is signed rather than protected by a session, because the
|
|
688
|
+
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
689
|
+
* camstack token. This mirrors the notification artifact plane's scheme
|
|
690
|
+
* (that module lives in another addon and addons never import each other,
|
|
691
|
+
* so the crypto is restated here rather than reached for).
|
|
692
|
+
*
|
|
693
|
+
* Expiry is checked BEFORE the constant-time compare: an expired link is
|
|
694
|
+
* refused whether or not its signature is valid, so a leaked URL stops
|
|
695
|
+
* working on its own.
|
|
696
|
+
*/
|
|
697
|
+
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
698
|
+
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
699
|
+
function signMedia(secret, id, expMs) {
|
|
700
|
+
return createHmac("sha256", secret).update(`${id}:${expMs}`).digest("hex");
|
|
701
|
+
}
|
|
702
|
+
function buildMediaUrl(input) {
|
|
703
|
+
const base = input.baseUrl.replace(/\/+$/, "");
|
|
704
|
+
const sig = signMedia(input.secret, input.id, input.expMs);
|
|
705
|
+
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
706
|
+
}
|
|
707
|
+
function verifyMediaSignature(input) {
|
|
708
|
+
if (input.exp === void 0 || input.sig === void 0) return false;
|
|
709
|
+
const expMs = Number(input.exp);
|
|
710
|
+
if (!Number.isFinite(expMs)) return false;
|
|
711
|
+
if (expMs < input.nowMs) return false;
|
|
712
|
+
const expected = signMedia(input.secret, input.id, expMs);
|
|
713
|
+
if (expected.length !== input.sig.length) return false;
|
|
714
|
+
return timingSafeEqual(Buffer.from(expected), Buffer.from(input.sig));
|
|
715
|
+
}
|
|
716
|
+
//#endregion
|
|
717
|
+
//#region src/ha-export/membership.ts
|
|
718
|
+
function devicesForBroker(membership, brokerId) {
|
|
719
|
+
return membership[brokerId] ?? [];
|
|
720
|
+
}
|
|
721
|
+
function isExposed(membership, brokerId, deviceId) {
|
|
722
|
+
return devicesForBroker(membership, brokerId).includes(deviceId);
|
|
723
|
+
}
|
|
724
|
+
/** Every broker a device reaches. Drives the N switches in the panel. */
|
|
725
|
+
function brokersExposing(membership, deviceId) {
|
|
726
|
+
return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
|
|
727
|
+
}
|
|
728
|
+
/** Every device exported to at least one of the given brokers. */
|
|
729
|
+
function exposedDeviceIds(membership, brokerIds) {
|
|
730
|
+
const ids = /* @__PURE__ */ new Set();
|
|
731
|
+
for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
|
|
732
|
+
return [...ids].sort();
|
|
733
|
+
}
|
|
734
|
+
function setExposed(membership, brokerId, deviceId, exposed) {
|
|
735
|
+
const current = devicesForBroker(membership, brokerId);
|
|
736
|
+
if (exposed === current.includes(deviceId)) return membership;
|
|
737
|
+
const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
|
|
738
|
+
return {
|
|
739
|
+
...membership,
|
|
740
|
+
[brokerId]: next
|
|
741
|
+
};
|
|
742
|
+
}
|
|
743
|
+
/**
|
|
744
|
+
* Forget brokers that no longer exist.
|
|
745
|
+
*
|
|
746
|
+
* A removed HA instance must not keep a membership list that reappears if
|
|
747
|
+
* a broker id is ever reused — and a stale key would also inflate
|
|
748
|
+
* `exposedDeviceCount` with devices going nowhere.
|
|
749
|
+
*/
|
|
750
|
+
function pruneBrokers(membership, knownBrokerIds) {
|
|
751
|
+
const known = new Set(knownBrokerIds);
|
|
752
|
+
const kept = Object.keys(membership).filter((brokerId) => known.has(brokerId));
|
|
753
|
+
if (kept.length === Object.keys(membership).length) return membership;
|
|
754
|
+
const next = {};
|
|
755
|
+
for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
|
|
756
|
+
return next;
|
|
757
|
+
}
|
|
758
|
+
//#endregion
|
|
759
|
+
//#region src/ha-export/push-client.ts
|
|
760
|
+
/** How long state updates accumulate before one POST carries them all. */
|
|
761
|
+
var BATCH_FLUSH_MS = 1e3;
|
|
762
|
+
/** Availability cadence. The component's timeout is a multiple of this. */
|
|
763
|
+
var HEARTBEAT_INTERVAL_MS = 3e4;
|
|
764
|
+
var IMAGE_SIGNAL_PREFIX = "__image_updated__:";
|
|
765
|
+
/** Rate limit for the "HA is not answering" warning. */
|
|
766
|
+
var DROP_LOG_INTERVAL_MS = 3e4;
|
|
767
|
+
var PushClient = class {
|
|
768
|
+
transport;
|
|
769
|
+
logger;
|
|
770
|
+
brokerId;
|
|
771
|
+
/** topic → last value SENT. Dropped whenever the link is lost. */
|
|
772
|
+
stateCache = /* @__PURE__ */ new Map();
|
|
773
|
+
buffer = [];
|
|
774
|
+
flushTimer = null;
|
|
775
|
+
heartbeatTimer = null;
|
|
776
|
+
available = true;
|
|
777
|
+
dropped = 0;
|
|
778
|
+
droppedLoggedAt = 0;
|
|
779
|
+
disposed = false;
|
|
780
|
+
constructor(options) {
|
|
781
|
+
this.transport = options.transport;
|
|
782
|
+
this.logger = options.logger;
|
|
783
|
+
this.brokerId = options.brokerId;
|
|
784
|
+
}
|
|
785
|
+
/** `false` once a POST has failed, `true` again on the next success. */
|
|
786
|
+
get healthy() {
|
|
787
|
+
return this.available;
|
|
788
|
+
}
|
|
789
|
+
/** Messages discarded because HA did not answer. Never silent. */
|
|
790
|
+
get droppedCount() {
|
|
791
|
+
return this.dropped;
|
|
792
|
+
}
|
|
793
|
+
start() {
|
|
794
|
+
if (this.heartbeatTimer !== null) return;
|
|
795
|
+
this.heartbeatTimer = setInterval(() => {
|
|
796
|
+
this.send({
|
|
797
|
+
type: "heartbeat",
|
|
798
|
+
ts: Date.now()
|
|
799
|
+
});
|
|
800
|
+
}, HEARTBEAT_INTERVAL_MS);
|
|
801
|
+
}
|
|
802
|
+
async dispose() {
|
|
803
|
+
this.disposed = true;
|
|
804
|
+
if (this.heartbeatTimer !== null) clearInterval(this.heartbeatTimer);
|
|
805
|
+
this.heartbeatTimer = null;
|
|
806
|
+
if (this.flushTimer !== null) clearTimeout(this.flushTimer);
|
|
807
|
+
this.flushTimer = null;
|
|
808
|
+
this.buffer = [];
|
|
809
|
+
}
|
|
810
|
+
/**
|
|
811
|
+
* Queue one state value.
|
|
812
|
+
*
|
|
813
|
+
* Deduped per topic, so a camera reporting `online: true` every five
|
|
814
|
+
* seconds costs nothing after the first.
|
|
815
|
+
*/
|
|
816
|
+
publishState(topic, value) {
|
|
817
|
+
if (this.disposed) return;
|
|
818
|
+
const payload = value.length > 2048 ? `${IMAGE_SIGNAL_PREFIX}${Date.now()}` : value;
|
|
819
|
+
if (this.stateCache.get(topic) === payload) return;
|
|
820
|
+
this.stateCache.set(topic, payload);
|
|
821
|
+
this.buffer.push({
|
|
822
|
+
type: "state_update",
|
|
823
|
+
topic,
|
|
824
|
+
value: payload
|
|
825
|
+
});
|
|
826
|
+
if (this.flushTimer === null) this.flushTimer = setTimeout(() => void this.flush(), BATCH_FLUSH_MS);
|
|
827
|
+
}
|
|
828
|
+
/**
|
|
829
|
+
* Announce a device's COMPLETE component set.
|
|
830
|
+
*
|
|
831
|
+
* Never a delta, and re-sent in full on reconnect: an event is
|
|
832
|
+
* telemetry and may be dropped (D8), so a lost structure message must
|
|
833
|
+
* not be able to leave HA with half a device. The pending state batch
|
|
834
|
+
* is flushed first — a value for an entity HA has not built yet is a
|
|
835
|
+
* value it discards.
|
|
836
|
+
*/
|
|
837
|
+
async publishEntityChange(deviceKey, cmps, dev) {
|
|
838
|
+
if (this.disposed) return;
|
|
839
|
+
await this.flush();
|
|
840
|
+
await this.send({
|
|
841
|
+
type: "entity_change",
|
|
842
|
+
device_id: deviceKey,
|
|
843
|
+
cmps,
|
|
844
|
+
dev
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
/** Send everything buffered now. Called before any structure change. */
|
|
848
|
+
async flush() {
|
|
849
|
+
if (this.flushTimer !== null) {
|
|
850
|
+
clearTimeout(this.flushTimer);
|
|
851
|
+
this.flushTimer = null;
|
|
852
|
+
}
|
|
853
|
+
if (this.buffer.length === 0) return;
|
|
854
|
+
const items = this.buffer;
|
|
855
|
+
this.buffer = [];
|
|
856
|
+
await this.send({
|
|
857
|
+
type: "batch",
|
|
858
|
+
items
|
|
859
|
+
});
|
|
860
|
+
}
|
|
861
|
+
async send(message) {
|
|
862
|
+
try {
|
|
863
|
+
await this.transport(message);
|
|
864
|
+
this.onReachable();
|
|
865
|
+
} catch (err) {
|
|
866
|
+
this.onUnreachable(message, err);
|
|
867
|
+
}
|
|
868
|
+
}
|
|
869
|
+
onReachable() {
|
|
870
|
+
if (this.available) return;
|
|
871
|
+
this.available = true;
|
|
872
|
+
/**
|
|
873
|
+
* The link came back. Drop the dedup cache so the reconcile that
|
|
874
|
+
* follows re-pushes every value: HA may have restarted and lost its
|
|
875
|
+
* state, and a cache that still remembers the old value would
|
|
876
|
+
* suppress exactly the messages needed to repair it.
|
|
877
|
+
*/
|
|
878
|
+
this.stateCache.clear();
|
|
879
|
+
this.logger.info("ha-export: Home Assistant reachable again, state cache cleared", { meta: {
|
|
880
|
+
brokerId: this.brokerId,
|
|
881
|
+
droppedWhileDown: this.dropped
|
|
882
|
+
} });
|
|
883
|
+
this.dropped = 0;
|
|
884
|
+
this.droppedLoggedAt = 0;
|
|
885
|
+
}
|
|
886
|
+
/**
|
|
887
|
+
* A failed POST discards work, so it may not be silent — an operator
|
|
888
|
+
* whose entities froze two days ago needs the log to say camstack is
|
|
889
|
+
* pushing into a wall, not merely that the addon is "running". Rate
|
|
890
|
+
* limited, because the alternative is a storm that buries the line.
|
|
891
|
+
*/
|
|
892
|
+
onUnreachable(message, err) {
|
|
893
|
+
this.available = false;
|
|
894
|
+
this.dropped += message.type === "batch" ? message.items.length : 1;
|
|
895
|
+
const now = Date.now();
|
|
896
|
+
if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
|
|
897
|
+
this.droppedLoggedAt = now;
|
|
898
|
+
this.logger.warn("ha-export: push to Home Assistant failed, messages dropped", { meta: {
|
|
899
|
+
brokerId: this.brokerId,
|
|
900
|
+
dropped: this.dropped,
|
|
901
|
+
messageType: message.type,
|
|
902
|
+
error: err instanceof Error ? err.message : String(err)
|
|
903
|
+
} });
|
|
904
|
+
}
|
|
905
|
+
};
|
|
906
|
+
//#endregion
|
|
907
|
+
//#region src/ha-export/state-projector.ts
|
|
908
|
+
/**
|
|
909
|
+
* Pipeline / notification-center output → `{topic, value}`.
|
|
910
|
+
*
|
|
911
|
+
* Pure. This is where `last_detection`, `last_label` and the per-zone
|
|
912
|
+
* counts become state, and it is the half of the export the operator's
|
|
913
|
+
* requirement lands on: **every value has a listener in the addon that
|
|
914
|
+
* publishes it.** Nothing in Home Assistant polls camstack — each
|
|
915
|
+
* function here is the projection of ONE internal source, and the addon
|
|
916
|
+
* subscribes to that source.
|
|
917
|
+
*
|
|
918
|
+
* Every value is a STRING. The component lowercases binary values
|
|
919
|
+
* verbatim; a JSON number or boolean raises inside its state callback,
|
|
920
|
+
* which it swallows — so the entity silently never updates again.
|
|
921
|
+
*/
|
|
922
|
+
/**
|
|
923
|
+
* A detection class → the macro an operator automates on.
|
|
924
|
+
*
|
|
925
|
+
* Returns `null` rather than a fallback: the executor drops classes with
|
|
926
|
+
* no macro upstream (`preserveOriginal: false`), so one arriving here is
|
|
927
|
+
* a contract break, and inventing a macro for it would put a toothbrush
|
|
928
|
+
* on the person sensor.
|
|
929
|
+
*/
|
|
930
|
+
function macroOf(className) {
|
|
931
|
+
const lower = className.toLowerCase();
|
|
932
|
+
if (EXPORTED_MACROS.includes(lower)) return lower;
|
|
933
|
+
const mapped = COCO_TO_MACRO.mapping[lower];
|
|
934
|
+
return typeof mapped === "string" ? mapped : null;
|
|
935
|
+
}
|
|
936
|
+
function iso(timestamp) {
|
|
937
|
+
return new Date(timestamp).toISOString();
|
|
938
|
+
}
|
|
939
|
+
function bool(value) {
|
|
940
|
+
return value ? "true" : "false";
|
|
941
|
+
}
|
|
942
|
+
/**
|
|
943
|
+
* Reachability.
|
|
944
|
+
*
|
|
945
|
+
* `device.sleeping` deliberately does NOT feed this: a battery camera in
|
|
946
|
+
* low-power mode is reachable on demand, and marking it unavailable
|
|
947
|
+
* would break every automation that wakes it.
|
|
948
|
+
*/
|
|
949
|
+
function projectDeviceStatus(deviceKey, online) {
|
|
950
|
+
return [{
|
|
951
|
+
topic: stateTopic(deviceKey, "online"),
|
|
952
|
+
value: bool(online)
|
|
953
|
+
}];
|
|
954
|
+
}
|
|
955
|
+
function projectMotion(deviceKey, input) {
|
|
956
|
+
const values = [{
|
|
957
|
+
topic: stateTopic(deviceKey, "motion_detected"),
|
|
958
|
+
value: bool(input.detected)
|
|
959
|
+
}, {
|
|
960
|
+
topic: stateTopic(deviceKey, "triggered"),
|
|
961
|
+
value: bool(input.detected)
|
|
962
|
+
}];
|
|
963
|
+
/**
|
|
964
|
+
* A clearing edge carries no timestamp. `last_detection` answers "when
|
|
965
|
+
* did something last happen", and restamping it when nothing happened
|
|
966
|
+
* makes an idle camera look permanently busy.
|
|
967
|
+
*/
|
|
968
|
+
if (input.detected) values.push({
|
|
969
|
+
topic: stateTopic(deviceKey, "motion_last_detection"),
|
|
970
|
+
value: iso(input.timestamp)
|
|
971
|
+
});
|
|
972
|
+
return values;
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* @param mediaUrl a signed, expiring URL for this track's best crop, or
|
|
976
|
+
* `null` when there is none yet. Images NEVER cross the transport —
|
|
977
|
+
* camstack mints the URL and HA fetches the bytes on demand.
|
|
978
|
+
*/
|
|
979
|
+
function projectTrackLifecycle(deviceKey, track, mediaUrl) {
|
|
980
|
+
const className = track.bestClassName ?? track.classes[0];
|
|
981
|
+
if (className === void 0) return [];
|
|
982
|
+
const macro = macroOf(className);
|
|
983
|
+
if (macro === null) return [];
|
|
984
|
+
const active = track.phase !== "end";
|
|
985
|
+
const values = [{
|
|
986
|
+
topic: stateTopic(deviceKey, `${macro}_detected`),
|
|
987
|
+
value: bool(active)
|
|
988
|
+
}, {
|
|
989
|
+
topic: stateTopic(deviceKey, "triggered"),
|
|
990
|
+
value: bool(active)
|
|
991
|
+
}];
|
|
992
|
+
if (active) values.push({
|
|
993
|
+
topic: stateTopic(deviceKey, `${macro}_last_detection`),
|
|
994
|
+
value: iso(track.lastSeen)
|
|
995
|
+
}, {
|
|
996
|
+
topic: stateTopic(deviceKey, "last_detection"),
|
|
997
|
+
value: iso(track.lastSeen)
|
|
998
|
+
});
|
|
999
|
+
/**
|
|
1000
|
+
* The identification: the recognised face for `person`, the plate for
|
|
1001
|
+
* `vehicle`, the sub-class for `animal`. `package`, `motion` and
|
|
1002
|
+
* `audio` have no label and the entity does not exist for them — so a
|
|
1003
|
+
* label arriving on one of those is dropped rather than published to a
|
|
1004
|
+
* topic nothing is subscribed to.
|
|
1005
|
+
*/
|
|
1006
|
+
const label = track.plateText ?? track.label;
|
|
1007
|
+
if (label !== void 0 && label.length > 0 && LABELLED_MACROS.includes(macro)) values.push({
|
|
1008
|
+
topic: stateTopic(deviceKey, `${macro}_last_label`),
|
|
1009
|
+
value: label
|
|
1010
|
+
});
|
|
1011
|
+
if (mediaUrl !== null) values.push({
|
|
1012
|
+
topic: stateTopic(deviceKey, "last_image"),
|
|
1013
|
+
value: mediaUrl
|
|
1014
|
+
}, {
|
|
1015
|
+
topic: stateTopic(deviceKey, `${macro}_last_image`),
|
|
1016
|
+
value: mediaUrl
|
|
1017
|
+
});
|
|
1018
|
+
if (ZONE_MACROS.includes(macro)) for (const zoneId of track.zonesVisited ?? []) {
|
|
1019
|
+
const slug = toSlug(zoneId);
|
|
1020
|
+
values.push({
|
|
1021
|
+
topic: stateTopic(deviceKey, `${slug}_${macro}_detected`),
|
|
1022
|
+
value: bool(active)
|
|
1023
|
+
});
|
|
1024
|
+
if (active) values.push({
|
|
1025
|
+
topic: stateTopic(deviceKey, `${slug}_${macro}_last_detection`),
|
|
1026
|
+
value: iso(track.lastSeen)
|
|
1027
|
+
});
|
|
1028
|
+
if (mediaUrl !== null) values.push({
|
|
1029
|
+
topic: stateTopic(deviceKey, `${slug}_${macro}_last_image`),
|
|
1030
|
+
value: mediaUrl
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
return values;
|
|
1034
|
+
}
|
|
1035
|
+
/**
|
|
1036
|
+
* Per-zone counts.
|
|
1037
|
+
*
|
|
1038
|
+
* A macro with nobody in the zone publishes `0`, not nothing: an entity
|
|
1039
|
+
* that goes silent renders in HA as its last value, so a driveway that
|
|
1040
|
+
* emptied would read as still occupied until someone walked through it
|
|
1041
|
+
* again.
|
|
1042
|
+
*/
|
|
1043
|
+
function projectZoneOccupancy(deviceKey, input) {
|
|
1044
|
+
const values = [];
|
|
1045
|
+
for (const zone of input.zones) {
|
|
1046
|
+
const slug = toSlug(zone.zoneId);
|
|
1047
|
+
const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
|
|
1048
|
+
for (const [className, count] of Object.entries(zone.byClass)) {
|
|
1049
|
+
const macro = macroOf(className);
|
|
1050
|
+
if (macro === null || !totals.has(macro)) continue;
|
|
1051
|
+
totals.set(macro, (totals.get(macro) ?? 0) + count);
|
|
1052
|
+
}
|
|
1053
|
+
for (const [macro, count] of totals) values.push({
|
|
1054
|
+
topic: stateTopic(deviceKey, `${slug}_${macro}_objects`),
|
|
1055
|
+
value: String(count)
|
|
1056
|
+
});
|
|
1057
|
+
}
|
|
1058
|
+
return values;
|
|
1059
|
+
}
|
|
1060
|
+
function projectBatterySlice(deviceKey, slice) {
|
|
1061
|
+
const values = [{
|
|
1062
|
+
topic: stateTopic(deviceKey, "charger"),
|
|
1063
|
+
value: bool(slice.charging !== "none")
|
|
1064
|
+
}, {
|
|
1065
|
+
topic: stateTopic(deviceKey, "sleeping"),
|
|
1066
|
+
value: bool(slice.sleeping)
|
|
1067
|
+
}];
|
|
1068
|
+
/**
|
|
1069
|
+
* `binary: true` means the device reports "low" or "not low" and the
|
|
1070
|
+
* percentage is a stand-in. Publishing it would put a fabricated
|
|
1071
|
+
* number on a battery gauge, which is worse than an empty one.
|
|
1072
|
+
*/
|
|
1073
|
+
if (slice.binary !== true) values.push({
|
|
1074
|
+
topic: stateTopic(deviceKey, "battery"),
|
|
1075
|
+
value: String(slice.percentage)
|
|
1076
|
+
});
|
|
1077
|
+
return values;
|
|
1078
|
+
}
|
|
1079
|
+
/**
|
|
1080
|
+
* The per-camera function switches, mirrored 1:1 from
|
|
1081
|
+
* `pipelineOrchestrator.getCameraSwitches`.
|
|
1082
|
+
*
|
|
1083
|
+
* An UNAVAILABLE switch publishes nothing, because `enabled` is
|
|
1084
|
+
* meaningless when `available` is false — a source that did not answer
|
|
1085
|
+
* must never become a control rendered `on`.
|
|
1086
|
+
*/
|
|
1087
|
+
function projectCameraSwitches(deviceKey, switches) {
|
|
1088
|
+
return switches.filter((sw) => sw.available).map((sw) => ({
|
|
1089
|
+
topic: stateTopic(deviceKey, toSlug(sw.id)),
|
|
1090
|
+
value: bool(sw.enabled)
|
|
1091
|
+
}));
|
|
1092
|
+
}
|
|
1093
|
+
//#endregion
|
|
1094
|
+
//#region src/ha-export/ha-export.addon.ts
|
|
1095
|
+
/**
|
|
1096
|
+
* Native Home Assistant export — a custom component fed by HTTP push.
|
|
1097
|
+
*
|
|
1098
|
+
* Contract: `docs/superpowers/specs/2026-08-04-ha-native-export-design.md`.
|
|
1099
|
+
* Read it before changing the entity map or the transport.
|
|
1100
|
+
*
|
|
1101
|
+
* **This addon PUSHES. Home Assistant never polls camstack for a value.**
|
|
1102
|
+
* Every entity in the catalog is fed by a listener here, on the authority
|
|
1103
|
+
* that already owns the value — device slices, motion, the track
|
|
1104
|
+
* lifecycle, zone occupancy, the camera switches. The projector turns
|
|
1105
|
+
* each into `{topic, value}` and the push client batches, dedups and
|
|
1106
|
+
* heartbeats them out.
|
|
1107
|
+
*
|
|
1108
|
+
* Where it lives, and why it is not inside the provider's process: it
|
|
1109
|
+
* ships in the SAME bundle as `provider-homeassistant` (so the HA URL and
|
|
1110
|
+
* token are not duplicated — they are read through the `broker` cap, and
|
|
1111
|
+
* addons never import each other even in one bundle), but as a SEPARATE
|
|
1112
|
+
* manifest entry with its own runner. D29: a defect in export must not be
|
|
1113
|
+
* able to respawn the runner that owns the devices.
|
|
1114
|
+
*
|
|
1115
|
+
* Three things a future session will want to know:
|
|
1116
|
+
*
|
|
1117
|
+
* 1. **Push authenticates with Home Assistant's OWN token.** The
|
|
1118
|
+
* component registers its view with HA auth, so the long-lived token
|
|
1119
|
+
* already stored on the broker is the credential. There is no second
|
|
1120
|
+
* shared secret to configure, rotate or leak.
|
|
1121
|
+
* 2. **Events optimise; the reconcile is the contract.** Bus events are
|
|
1122
|
+
* telemetry and may be dropped (D8), so a full reconcile runs on
|
|
1123
|
+
* boot, on config change, on a timer, and whenever the link to HA
|
|
1124
|
+
* returns. A dropped event costs one interval of staleness rather
|
|
1125
|
+
* than permanent divergence.
|
|
1126
|
+
* 3. **Membership is per broker.** Two Home Assistant instances see
|
|
1127
|
+
* different sets of cameras, and the per-device Export panel shows
|
|
1128
|
+
* one switch per broker over the single membership store.
|
|
1129
|
+
*/
|
|
1130
|
+
/** The addon that owns the import direction — never export back to it. */
|
|
1131
|
+
var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
|
|
1132
|
+
var ADDON_ID = "homeassistant-export";
|
|
1133
|
+
/** Where the custom component registers its push view inside HA. */
|
|
1134
|
+
var PUSH_PATH = "/api/camstack/push";
|
|
1135
|
+
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
1136
|
+
/** The hub's API port — where the data plane is reverse-proxied. */
|
|
1137
|
+
var HUB_API_PORT = 4443;
|
|
1138
|
+
/** One PTZ button press, as a relative move. */
|
|
1139
|
+
var PTZ_MOVE_BY_DIRECTION = {
|
|
1140
|
+
up: { tilt: .1 },
|
|
1141
|
+
down: { tilt: -.1 },
|
|
1142
|
+
left: { pan: -.1 },
|
|
1143
|
+
right: { pan: .1 },
|
|
1144
|
+
zoom_in: { zoom: .1 },
|
|
1145
|
+
zoom_out: { zoom: -.1 }
|
|
1146
|
+
};
|
|
1147
|
+
var DEFAULT_CONFIG = {
|
|
1148
|
+
reconcileIntervalSec: 300,
|
|
1149
|
+
membership: {},
|
|
1150
|
+
knownBrokers: [],
|
|
1151
|
+
publicBaseUrl: ""
|
|
1152
|
+
};
|
|
1153
|
+
var HaExportAddon = class extends BaseAddon {
|
|
1154
|
+
links = /* @__PURE__ */ new Map();
|
|
1155
|
+
/** deviceKey → the device it addresses. Rebuilt on every reconcile. */
|
|
1156
|
+
exported = /* @__PURE__ */ new Map();
|
|
1157
|
+
/** numeric deviceId → deviceKey, for the event path. */
|
|
1158
|
+
keyByDeviceId = /* @__PURE__ */ new Map();
|
|
1159
|
+
mediaSecret = "";
|
|
1160
|
+
mediaBaseUrl = null;
|
|
1161
|
+
reconcileTimer = null;
|
|
1162
|
+
reconcileInFlight = false;
|
|
1163
|
+
lastError;
|
|
1164
|
+
entityCount = 0;
|
|
1165
|
+
unclassified = [];
|
|
1166
|
+
constructor() {
|
|
1167
|
+
super({ ...DEFAULT_CONFIG });
|
|
1168
|
+
}
|
|
1169
|
+
async onInitialize() {
|
|
1170
|
+
const secret = this.state("haExportMediaSecret", string(), "");
|
|
1171
|
+
const existing = await secret.get();
|
|
1172
|
+
if (existing.length === 0) {
|
|
1173
|
+
const minted = randomSecret();
|
|
1174
|
+
await secret.set(minted);
|
|
1175
|
+
this.mediaSecret = minted;
|
|
1176
|
+
} else this.mediaSecret = existing;
|
|
1177
|
+
this.wireListeners();
|
|
1178
|
+
await this.serveMediaPlane();
|
|
1179
|
+
try {
|
|
1180
|
+
await this.reconcile("boot");
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
this.lastError = errMsg(err);
|
|
1183
|
+
this.ctx.logger.warn("ha-export: initial reconcile failed", { meta: { error: this.lastError } });
|
|
1184
|
+
}
|
|
1185
|
+
this.startTimer();
|
|
1186
|
+
this.ctx.addDisposer(async () => {
|
|
1187
|
+
this.stopTimer();
|
|
1188
|
+
for (const link of this.links.values()) await link.client.dispose();
|
|
1189
|
+
this.links.clear();
|
|
1190
|
+
});
|
|
1191
|
+
return [{
|
|
1192
|
+
capability: deviceExportCapability,
|
|
1193
|
+
provider: {
|
|
1194
|
+
getStatus: async () => this.buildStatus(),
|
|
1195
|
+
listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
|
|
1196
|
+
listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
|
|
1197
|
+
deviceId,
|
|
1198
|
+
exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
|
|
1199
|
+
})),
|
|
1200
|
+
exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
|
|
1201
|
+
unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
|
|
1202
|
+
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
1203
|
+
getDeviceLiveContribution: async () => null,
|
|
1204
|
+
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
1205
|
+
}
|
|
1206
|
+
}, {
|
|
1207
|
+
capability: addonRoutesCapability,
|
|
1208
|
+
provider: buildAddonRouteProvider(ADDON_ID, [{
|
|
1209
|
+
method: "POST",
|
|
1210
|
+
path: "/command",
|
|
1211
|
+
access: "authenticated",
|
|
1212
|
+
description: "Home Assistant → CamStack command: {topic, value}",
|
|
1213
|
+
handler: async (request, reply) => {
|
|
1214
|
+
await this.handleCommandRoute(request.body, reply);
|
|
1215
|
+
}
|
|
1216
|
+
}])
|
|
1217
|
+
}];
|
|
1218
|
+
}
|
|
1219
|
+
async onConfigChanged() {
|
|
1220
|
+
await this.reconcile("config-changed");
|
|
1221
|
+
this.startTimer();
|
|
1222
|
+
}
|
|
1223
|
+
wireListeners() {
|
|
1224
|
+
this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
1225
|
+
this.onSliceChanged(event.data);
|
|
1226
|
+
});
|
|
1227
|
+
this.subscribe({ category: EventCategory.DeviceOnline }, (event) => {
|
|
1228
|
+
this.onOnlineChanged(event.data, true);
|
|
1229
|
+
});
|
|
1230
|
+
this.subscribe({ category: EventCategory.DeviceOffline }, (event) => {
|
|
1231
|
+
this.onOnlineChanged(event.data, false);
|
|
1232
|
+
});
|
|
1233
|
+
this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
|
|
1234
|
+
this.onMotion(event.data);
|
|
1235
|
+
});
|
|
1236
|
+
this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
1237
|
+
this.onTrackLifecycle(event.data);
|
|
1238
|
+
});
|
|
1239
|
+
this.subscribe({ category: EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
|
|
1240
|
+
this.onZoneOccupancy(event.data);
|
|
1241
|
+
});
|
|
1242
|
+
/**
|
|
1243
|
+
* Structure, not value. A new device, a lost one or a changed
|
|
1244
|
+
* binding changes the `cmps` set, so it needs a full reconcile
|
|
1245
|
+
* rather than a state push.
|
|
1246
|
+
*/
|
|
1247
|
+
for (const category of [
|
|
1248
|
+
EventCategory.DeviceRegistered,
|
|
1249
|
+
EventCategory.DeviceUnregistered,
|
|
1250
|
+
EventCategory.DeviceBindingsChanged,
|
|
1251
|
+
EventCategory.DeviceMetaChanged,
|
|
1252
|
+
EventCategory.BrokerStatusChanged
|
|
1253
|
+
]) this.subscribe({ category }, () => {
|
|
1254
|
+
this.reconcileSoon();
|
|
1255
|
+
});
|
|
1256
|
+
}
|
|
1257
|
+
onSliceChanged(data) {
|
|
1258
|
+
const deviceId = readNumber(data, "deviceId");
|
|
1259
|
+
const capName = readString(data, "capName");
|
|
1260
|
+
const slice = readRecord(data, "slice");
|
|
1261
|
+
if (deviceId === null || capName === null || slice === null) return;
|
|
1262
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1263
|
+
if (deviceKey === void 0) return;
|
|
1264
|
+
if (capName === "device-status") {
|
|
1265
|
+
const online = slice["online"];
|
|
1266
|
+
if (typeof online === "boolean") this.push(deviceId, projectDeviceStatus(deviceKey, online));
|
|
1267
|
+
return;
|
|
1268
|
+
}
|
|
1269
|
+
if (capName === "battery") {
|
|
1270
|
+
const percentage = readNumber(slice, "percentage");
|
|
1271
|
+
const charging = readString(slice, "charging");
|
|
1272
|
+
const sleeping = slice["sleeping"];
|
|
1273
|
+
if (percentage === null || charging === null || typeof sleeping !== "boolean") return;
|
|
1274
|
+
const binary = slice["binary"];
|
|
1275
|
+
this.push(deviceId, projectBatterySlice(deviceKey, {
|
|
1276
|
+
percentage,
|
|
1277
|
+
charging,
|
|
1278
|
+
sleeping,
|
|
1279
|
+
...typeof binary === "boolean" ? { binary } : {}
|
|
1280
|
+
}));
|
|
1281
|
+
return;
|
|
1282
|
+
}
|
|
1283
|
+
if (capName === "motion") {
|
|
1284
|
+
const detected = slice["detected"];
|
|
1285
|
+
const lastDetectedAt = readNumber(slice, "lastDetectedAt");
|
|
1286
|
+
if (typeof detected !== "boolean") return;
|
|
1287
|
+
this.push(deviceId, projectMotion(deviceKey, {
|
|
1288
|
+
detected,
|
|
1289
|
+
timestamp: lastDetectedAt ?? Date.now()
|
|
1290
|
+
}));
|
|
1291
|
+
}
|
|
1292
|
+
}
|
|
1293
|
+
onOnlineChanged(data, online) {
|
|
1294
|
+
const deviceId = readNumber(data, "deviceId");
|
|
1295
|
+
if (deviceId === null) return;
|
|
1296
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1297
|
+
if (deviceKey === void 0) return;
|
|
1298
|
+
this.push(deviceId, projectDeviceStatus(deviceKey, online));
|
|
1299
|
+
}
|
|
1300
|
+
onMotion(data) {
|
|
1301
|
+
const deviceId = readNumber(data, "deviceId");
|
|
1302
|
+
const detected = readRecordField(data, "detected");
|
|
1303
|
+
if (deviceId === null || typeof detected !== "boolean") return;
|
|
1304
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1305
|
+
if (deviceKey === void 0) return;
|
|
1306
|
+
const timestamp = readNumber(data, "timestamp") ?? Date.now();
|
|
1307
|
+
this.push(deviceId, projectMotion(deviceKey, {
|
|
1308
|
+
detected,
|
|
1309
|
+
timestamp
|
|
1310
|
+
}));
|
|
1311
|
+
}
|
|
1312
|
+
/**
|
|
1313
|
+
* The richest source: class, zone, identity, plate and the media key in
|
|
1314
|
+
* one payload. One subscription feeds most of a camera's catalog.
|
|
1315
|
+
*/
|
|
1316
|
+
async onTrackLifecycle(data) {
|
|
1317
|
+
const deviceId = readNumber(data, "deviceId");
|
|
1318
|
+
const trackId = readString(data, "trackId");
|
|
1319
|
+
const phase = readString(data, "phase");
|
|
1320
|
+
if (deviceId === null || trackId === null) return;
|
|
1321
|
+
if (phase !== "start" && phase !== "update" && phase !== "end") return;
|
|
1322
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1323
|
+
if (deviceKey === void 0) return;
|
|
1324
|
+
const classes = readStringArray(data, "classes");
|
|
1325
|
+
const bestClassName = readString(data, "bestClassName");
|
|
1326
|
+
const label = readString(data, "label");
|
|
1327
|
+
const plateText = readString(data, "plateText");
|
|
1328
|
+
const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
|
|
1329
|
+
const zonesVisited = readStringArray(data, "zonesVisited");
|
|
1330
|
+
const mediaUrl = this.mintTrackMediaUrl(trackId);
|
|
1331
|
+
const values = projectTrackLifecycle(deviceKey, {
|
|
1332
|
+
deviceId,
|
|
1333
|
+
trackId,
|
|
1334
|
+
phase,
|
|
1335
|
+
classes,
|
|
1336
|
+
...bestClassName !== null ? { bestClassName } : {},
|
|
1337
|
+
...label !== null ? { label } : {},
|
|
1338
|
+
...plateText !== null ? { plateText } : {},
|
|
1339
|
+
lastSeen,
|
|
1340
|
+
zonesVisited
|
|
1341
|
+
}, mediaUrl);
|
|
1342
|
+
if (values.length === 0) {
|
|
1343
|
+
this.ctx.logger.debug("ha-export: track produced no exportable state", {
|
|
1344
|
+
tags: { deviceId },
|
|
1345
|
+
meta: {
|
|
1346
|
+
trackId,
|
|
1347
|
+
bestClassName,
|
|
1348
|
+
phase
|
|
1349
|
+
}
|
|
1350
|
+
});
|
|
1351
|
+
return;
|
|
1352
|
+
}
|
|
1353
|
+
this.push(deviceId, values);
|
|
1354
|
+
}
|
|
1355
|
+
onZoneOccupancy(data) {
|
|
1356
|
+
const deviceId = readNumber(data, "deviceId");
|
|
1357
|
+
if (deviceId === null) return;
|
|
1358
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1359
|
+
if (deviceKey === void 0) return;
|
|
1360
|
+
const zonesRaw = readRecordField(data, "zones");
|
|
1361
|
+
if (!Array.isArray(zonesRaw)) return;
|
|
1362
|
+
const zones = zonesRaw.flatMap((entry) => {
|
|
1363
|
+
const zoneId = readString(entry, "zoneId");
|
|
1364
|
+
const byClass = readRecord(entry, "byClass");
|
|
1365
|
+
if (zoneId === null) return [];
|
|
1366
|
+
return [{
|
|
1367
|
+
zoneId,
|
|
1368
|
+
byClass: toCountRecord(byClass)
|
|
1369
|
+
}];
|
|
1370
|
+
});
|
|
1371
|
+
if (zones.length === 0) return;
|
|
1372
|
+
this.push(deviceId, projectZoneOccupancy(deviceKey, { zones }));
|
|
1373
|
+
}
|
|
1374
|
+
/**
|
|
1375
|
+
* Send a device's values to every broker it is exported to.
|
|
1376
|
+
*
|
|
1377
|
+
* A failure on one broker's client cannot reach another: each has its
|
|
1378
|
+
* own dedup cache and its own availability, so one unreachable Home
|
|
1379
|
+
* Assistant degrades only its own entities.
|
|
1380
|
+
*/
|
|
1381
|
+
push(deviceId, values) {
|
|
1382
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1383
|
+
if (deviceKey === void 0) return;
|
|
1384
|
+
const exported = this.exported.get(deviceKey);
|
|
1385
|
+
if (exported === void 0) return;
|
|
1386
|
+
for (const brokerId of exported.brokerIds) {
|
|
1387
|
+
const link = this.links.get(brokerId);
|
|
1388
|
+
if (link === void 0) continue;
|
|
1389
|
+
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
reconcileQueued = false;
|
|
1393
|
+
async reconcileSoon() {
|
|
1394
|
+
if (this.reconcileQueued) return;
|
|
1395
|
+
this.reconcileQueued = true;
|
|
1396
|
+
setTimeout(() => {
|
|
1397
|
+
this.reconcileQueued = false;
|
|
1398
|
+
this.reconcile("structure-changed");
|
|
1399
|
+
}, 2e3);
|
|
1400
|
+
}
|
|
1401
|
+
/**
|
|
1402
|
+
* The contract.
|
|
1403
|
+
*
|
|
1404
|
+
* Re-resolves the brokers, rebuilds every plan, re-sends the COMPLETE
|
|
1405
|
+
* `cmps` set for every exported device, and re-pushes every value.
|
|
1406
|
+
* Events are an optimisation over this, never a substitute (D8/D11).
|
|
1407
|
+
*/
|
|
1408
|
+
async reconcile(reason) {
|
|
1409
|
+
if (this.reconcileInFlight) return;
|
|
1410
|
+
this.reconcileInFlight = true;
|
|
1411
|
+
const startedAt = Date.now();
|
|
1412
|
+
try {
|
|
1413
|
+
await this.refreshBrokers();
|
|
1414
|
+
await this.refreshMediaBaseUrl();
|
|
1415
|
+
const enabled = this.enabledBrokerIds();
|
|
1416
|
+
const devices = await this.ctx.api.deviceManager.listAll.query({});
|
|
1417
|
+
const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
|
|
1418
|
+
const byId = new Map(devices.map((device) => [device.id, device]));
|
|
1419
|
+
const exported = /* @__PURE__ */ new Map();
|
|
1420
|
+
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
1421
|
+
const allCaps = /* @__PURE__ */ new Set();
|
|
1422
|
+
let entityCount = 0;
|
|
1423
|
+
for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
|
|
1424
|
+
const numericId = Number(deviceIdStr);
|
|
1425
|
+
if (!Number.isFinite(numericId)) {
|
|
1426
|
+
this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
|
|
1427
|
+
continue;
|
|
1428
|
+
}
|
|
1429
|
+
const device = byId.get(numericId);
|
|
1430
|
+
if (device === void 0) {
|
|
1431
|
+
this.ctx.logger.warn("ha-export: exported device is not in the registry, skipping", { tags: { deviceId: numericId } });
|
|
1432
|
+
continue;
|
|
1433
|
+
}
|
|
1434
|
+
/**
|
|
1435
|
+
* The echo guard. A device imported FROM Home Assistant is never
|
|
1436
|
+
* exported back to it: HA would mirror its own entity across the
|
|
1437
|
+
* bridge, and any automation touching it would round-trip.
|
|
1438
|
+
*/
|
|
1439
|
+
if (device.addonId === HA_PROVIDER_ADDON_ID) {
|
|
1440
|
+
this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
|
|
1441
|
+
continue;
|
|
1442
|
+
}
|
|
1443
|
+
const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)]);
|
|
1444
|
+
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
1445
|
+
const plan = buildDevicePlan(catalogDevice);
|
|
1446
|
+
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
1447
|
+
exported.set(plan.deviceKey, {
|
|
1448
|
+
plan,
|
|
1449
|
+
deviceId: numericId,
|
|
1450
|
+
target: {
|
|
1451
|
+
deviceId: numericId,
|
|
1452
|
+
type: device.type,
|
|
1453
|
+
boundCaps: catalogDevice.boundCaps
|
|
1454
|
+
},
|
|
1455
|
+
brokerIds
|
|
1456
|
+
});
|
|
1457
|
+
keyByDeviceId.set(numericId, plan.deviceKey);
|
|
1458
|
+
entityCount += Object.keys(plan.cmps).length;
|
|
1459
|
+
/**
|
|
1460
|
+
* The positive branch. Every other line in this loop describes a
|
|
1461
|
+
* device being DROPPED; without this one an operator can see why
|
|
1462
|
+
* a device is missing but cannot confirm which devices were
|
|
1463
|
+
* actually exported, to which Home Assistant, with which
|
|
1464
|
+
* entities. Silence reads as "never happened" both ways.
|
|
1465
|
+
*/
|
|
1466
|
+
this.ctx.logger.info("ha-export: exporting device", {
|
|
1467
|
+
tags: { deviceId: numericId },
|
|
1468
|
+
meta: {
|
|
1469
|
+
deviceType: device.type,
|
|
1470
|
+
deviceKey: plan.deviceKey,
|
|
1471
|
+
brokers: brokerIds,
|
|
1472
|
+
entities: Object.keys(plan.cmps).length
|
|
1473
|
+
}
|
|
1474
|
+
});
|
|
1475
|
+
}
|
|
1476
|
+
this.exported = exported;
|
|
1477
|
+
this.keyByDeviceId = keyByDeviceId;
|
|
1478
|
+
this.entityCount = entityCount;
|
|
1479
|
+
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
1480
|
+
await this.announce(exported);
|
|
1481
|
+
await this.pushFullState(exported, snapshots);
|
|
1482
|
+
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
1483
|
+
reason,
|
|
1484
|
+
brokers: enabled.length,
|
|
1485
|
+
devices: exported.size,
|
|
1486
|
+
entities: entityCount,
|
|
1487
|
+
unclassifiedCaps: this.unclassified.length,
|
|
1488
|
+
durationMs: Date.now() - startedAt
|
|
1489
|
+
} });
|
|
1490
|
+
if (this.unclassified.length > 0) this.ctx.logger.warn("ha-export: capabilities in neither the map nor the exclusion list", { meta: { caps: this.unclassified } });
|
|
1491
|
+
this.lastError = void 0;
|
|
1492
|
+
} catch (err) {
|
|
1493
|
+
this.lastError = errMsg(err);
|
|
1494
|
+
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
1495
|
+
} finally {
|
|
1496
|
+
this.reconcileInFlight = false;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
/**
|
|
1500
|
+
* Send every exported device's COMPLETE component set, per broker.
|
|
1501
|
+
*
|
|
1502
|
+
* Wrapped per device: a device whose plan cannot be announced degrades
|
|
1503
|
+
* that device only and can never take the loop — or the runner — down
|
|
1504
|
+
* with it.
|
|
1505
|
+
*/
|
|
1506
|
+
async announce(exported) {
|
|
1507
|
+
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
1508
|
+
const link = this.links.get(brokerId);
|
|
1509
|
+
if (link === void 0) continue;
|
|
1510
|
+
try {
|
|
1511
|
+
await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
|
|
1512
|
+
} catch (err) {
|
|
1513
|
+
this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
|
|
1514
|
+
tags: { deviceId: entry.deviceId },
|
|
1515
|
+
meta: {
|
|
1516
|
+
brokerId,
|
|
1517
|
+
error: errMsg(err)
|
|
1518
|
+
}
|
|
1519
|
+
});
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
async pushFullState(exported, snapshots) {
|
|
1524
|
+
for (const entry of exported.values()) try {
|
|
1525
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
1526
|
+
this.push(entry.deviceId, values);
|
|
1527
|
+
} catch (err) {
|
|
1528
|
+
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
1529
|
+
tags: { deviceId: entry.deviceId },
|
|
1530
|
+
meta: { error: errMsg(err) }
|
|
1531
|
+
});
|
|
1532
|
+
}
|
|
1533
|
+
for (const link of this.links.values()) await link.client.flush();
|
|
1534
|
+
}
|
|
1535
|
+
/** Everything readable without waiting for an event. */
|
|
1536
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
1537
|
+
const key = entry.plan.deviceKey;
|
|
1538
|
+
const values = [];
|
|
1539
|
+
const snapshot = toSnapshot(snapshotRaw);
|
|
1540
|
+
const online = snapshot["device-status"]?.["online"];
|
|
1541
|
+
if (typeof online === "boolean") values.push(...projectDeviceStatus(key, online));
|
|
1542
|
+
const battery = snapshot["battery"];
|
|
1543
|
+
if (battery !== void 0) {
|
|
1544
|
+
const percentage = readNumber(battery, "percentage");
|
|
1545
|
+
const charging = readString(battery, "charging");
|
|
1546
|
+
const sleeping = battery["sleeping"];
|
|
1547
|
+
if (percentage !== null && charging !== null && typeof sleeping === "boolean") {
|
|
1548
|
+
const binary = battery["binary"];
|
|
1549
|
+
values.push(...projectBatterySlice(key, {
|
|
1550
|
+
percentage,
|
|
1551
|
+
charging,
|
|
1552
|
+
sleeping,
|
|
1553
|
+
...typeof binary === "boolean" ? { binary } : {}
|
|
1554
|
+
}));
|
|
1555
|
+
}
|
|
1556
|
+
}
|
|
1557
|
+
if (entry.target.type === "camera") {
|
|
1558
|
+
const group = await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId: entry.deviceId });
|
|
1559
|
+
values.push(...projectCameraSwitches(key, group.switches.map((sw) => ({
|
|
1560
|
+
id: sw.id,
|
|
1561
|
+
label: sw.label,
|
|
1562
|
+
available: sw.available,
|
|
1563
|
+
enabled: sw.enabled
|
|
1564
|
+
}))));
|
|
1565
|
+
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
1566
|
+
if (occupancy !== null) values.push(...projectZoneOccupancy(key, { zones: occupancy.zones.map((zone) => ({
|
|
1567
|
+
zoneId: zone.zoneId,
|
|
1568
|
+
byClass: toCountRecord(zone.byClass)
|
|
1569
|
+
})) }));
|
|
1570
|
+
}
|
|
1571
|
+
return values;
|
|
1572
|
+
}
|
|
1573
|
+
async buildCatalogDevice(device, snapshotRaw) {
|
|
1574
|
+
const snapshot = toSnapshot(snapshotRaw);
|
|
1575
|
+
const boundCaps = await this.loadBoundCaps(device.id);
|
|
1576
|
+
const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
|
|
1577
|
+
const switches = device.type === "camera" ? await this.loadSwitches(device.id) : [];
|
|
1578
|
+
const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
|
|
1579
|
+
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
1580
|
+
const model = readString(device.metadata ?? {}, "model");
|
|
1581
|
+
return {
|
|
1582
|
+
id: device.id,
|
|
1583
|
+
stableId: device.stableId,
|
|
1584
|
+
name: device.name,
|
|
1585
|
+
type: device.type,
|
|
1586
|
+
...manufacturer !== null ? { manufacturer } : {},
|
|
1587
|
+
...model !== null ? { model } : {},
|
|
1588
|
+
features: device.features,
|
|
1589
|
+
boundCaps,
|
|
1590
|
+
zones,
|
|
1591
|
+
switches,
|
|
1592
|
+
ptzPresets,
|
|
1593
|
+
slices: Object.keys(snapshot)
|
|
1594
|
+
};
|
|
1595
|
+
}
|
|
1596
|
+
async loadBoundCaps(deviceId) {
|
|
1597
|
+
try {
|
|
1598
|
+
return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
|
|
1599
|
+
} catch (err) {
|
|
1600
|
+
this.ctx.logger.warn("ha-export: could not read bindings, exporting without them", {
|
|
1601
|
+
tags: { deviceId },
|
|
1602
|
+
meta: { error: errMsg(err) }
|
|
1603
|
+
});
|
|
1604
|
+
return [];
|
|
1605
|
+
}
|
|
1606
|
+
}
|
|
1607
|
+
async loadZones(deviceId) {
|
|
1608
|
+
try {
|
|
1609
|
+
const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
|
|
1610
|
+
if (snapshot === null) return [];
|
|
1611
|
+
return snapshot.zones.map((zone) => ({
|
|
1612
|
+
id: zone.zoneId,
|
|
1613
|
+
name: zone.zoneName
|
|
1614
|
+
}));
|
|
1615
|
+
} catch (err) {
|
|
1616
|
+
this.ctx.logger.warn("ha-export: could not read zones, exporting no zone entities", {
|
|
1617
|
+
tags: { deviceId },
|
|
1618
|
+
meta: { error: errMsg(err) }
|
|
1619
|
+
});
|
|
1620
|
+
return [];
|
|
1621
|
+
}
|
|
1622
|
+
}
|
|
1623
|
+
async loadSwitches(deviceId) {
|
|
1624
|
+
try {
|
|
1625
|
+
return (await this.ctx.api.pipelineOrchestrator.getCameraSwitches.query({ deviceId })).switches.map((sw) => ({
|
|
1626
|
+
id: sw.id,
|
|
1627
|
+
label: sw.label,
|
|
1628
|
+
available: sw.available
|
|
1629
|
+
}));
|
|
1630
|
+
} catch (err) {
|
|
1631
|
+
/**
|
|
1632
|
+
* A source that did not answer produces NO control, never one
|
|
1633
|
+
* defaulted to on. This is the rule `CameraSwitch.available`
|
|
1634
|
+
* exists to enforce, and it has to survive the read failing.
|
|
1635
|
+
*/
|
|
1636
|
+
this.ctx.logger.warn("ha-export: could not read camera switches, exporting no controls", {
|
|
1637
|
+
tags: { deviceId },
|
|
1638
|
+
meta: { error: errMsg(err) }
|
|
1639
|
+
});
|
|
1640
|
+
return [];
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
async loadPresets(deviceId) {
|
|
1644
|
+
try {
|
|
1645
|
+
const device = await this.ctx.fetchDevice(deviceId);
|
|
1646
|
+
if (device.ptz === void 0) return [];
|
|
1647
|
+
return (await device.ptz.getPresets({})).map((preset) => preset.name);
|
|
1648
|
+
} catch (err) {
|
|
1649
|
+
this.ctx.logger.debug("ha-export: could not probe PTZ presets", {
|
|
1650
|
+
tags: { deviceId },
|
|
1651
|
+
meta: { error: errMsg(err) }
|
|
1652
|
+
});
|
|
1653
|
+
return [];
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
/**
|
|
1657
|
+
* Resolve every Home Assistant broker, and open a push client for the
|
|
1658
|
+
* ones the operator ticked.
|
|
1659
|
+
*
|
|
1660
|
+
* Credentials come through the `broker` cap — the sanctioned no-import
|
|
1661
|
+
* path — and the owning `addonId` is sent with the read: `broker` is a
|
|
1662
|
+
* COLLECTION cap, so an unpinned id-keyed call routes to the
|
|
1663
|
+
* first-registered provider, which returns `null` for a broker it does
|
|
1664
|
+
* not own.
|
|
1665
|
+
*/
|
|
1666
|
+
async refreshBrokers() {
|
|
1667
|
+
const haBrokers = (await this.ctx.api.broker.list.query({})).filter((broker) => broker.kind === "home-assistant");
|
|
1668
|
+
const known = haBrokers.map((broker) => ({
|
|
1669
|
+
id: broker.id,
|
|
1670
|
+
name: broker.name
|
|
1671
|
+
}));
|
|
1672
|
+
const pruned = pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
|
|
1673
|
+
if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
|
|
1674
|
+
knownBrokers: known,
|
|
1675
|
+
membership: pruned
|
|
1676
|
+
});
|
|
1677
|
+
const wanted = new Set(this.enabledBrokerIds());
|
|
1678
|
+
for (const [brokerId, link] of this.links) {
|
|
1679
|
+
if (wanted.has(brokerId)) continue;
|
|
1680
|
+
await link.client.dispose();
|
|
1681
|
+
this.links.delete(brokerId);
|
|
1682
|
+
this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
|
|
1683
|
+
}
|
|
1684
|
+
for (const broker of haBrokers) {
|
|
1685
|
+
if (!wanted.has(broker.id)) continue;
|
|
1686
|
+
if (this.links.has(broker.id)) continue;
|
|
1687
|
+
const resolved = await this.resolveBrokerConnection(broker.id, broker.addonId);
|
|
1688
|
+
if (resolved === null) continue;
|
|
1689
|
+
const client = new PushClient({
|
|
1690
|
+
transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
|
|
1691
|
+
logger: this.ctx.logger,
|
|
1692
|
+
brokerId: broker.id
|
|
1693
|
+
});
|
|
1694
|
+
client.start();
|
|
1695
|
+
this.links.set(broker.id, {
|
|
1696
|
+
brokerId: broker.id,
|
|
1697
|
+
name: broker.name,
|
|
1698
|
+
baseUrl: resolved.baseUrl,
|
|
1699
|
+
token: resolved.token,
|
|
1700
|
+
client
|
|
1701
|
+
});
|
|
1702
|
+
this.ctx.logger.info("ha-export: exporting to Home Assistant", { meta: {
|
|
1703
|
+
brokerId: broker.id,
|
|
1704
|
+
baseUrl: resolved.baseUrl
|
|
1705
|
+
} });
|
|
1706
|
+
}
|
|
1707
|
+
}
|
|
1708
|
+
async resolveBrokerConnection(brokerId, addonId) {
|
|
1709
|
+
try {
|
|
1710
|
+
const raw = await this.ctx.api.broker.getBrokerConfig.query({
|
|
1711
|
+
id: brokerId,
|
|
1712
|
+
addonId
|
|
1713
|
+
});
|
|
1714
|
+
if (raw === null) {
|
|
1715
|
+
this.ctx.logger.warn("ha-export: broker returned no configuration, not exporting to it", { meta: { brokerId } });
|
|
1716
|
+
return null;
|
|
1717
|
+
}
|
|
1718
|
+
const supervisorToken = readString(raw, "supervisorToken");
|
|
1719
|
+
if (supervisorToken !== null)
|
|
1720
|
+
/**
|
|
1721
|
+
* A Home Assistant OS install has no `baseUrl` — the supervisor
|
|
1722
|
+
* proxies Core on a fixed internal address. Handling this branch
|
|
1723
|
+
* is the difference between working and pushing at `undefined`.
|
|
1724
|
+
*/
|
|
1725
|
+
return {
|
|
1726
|
+
baseUrl: "http://supervisor/core",
|
|
1727
|
+
token: supervisorToken
|
|
1728
|
+
};
|
|
1729
|
+
const baseUrl = readString(raw, "baseUrl");
|
|
1730
|
+
const accessToken = readString(raw, "accessToken");
|
|
1731
|
+
if (baseUrl === null || accessToken === null) {
|
|
1732
|
+
this.ctx.logger.warn("ha-export: broker configuration has no URL or token, skipping it", { meta: { brokerId } });
|
|
1733
|
+
return null;
|
|
1734
|
+
}
|
|
1735
|
+
return {
|
|
1736
|
+
baseUrl,
|
|
1737
|
+
token: accessToken
|
|
1738
|
+
};
|
|
1739
|
+
} catch (err) {
|
|
1740
|
+
this.ctx.logger.warn("ha-export: could not read broker configuration", { meta: {
|
|
1741
|
+
brokerId,
|
|
1742
|
+
error: errMsg(err)
|
|
1743
|
+
} });
|
|
1744
|
+
return null;
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
enabledBrokerIds() {
|
|
1748
|
+
return this.config.knownBrokers.filter((broker) => this.config[`exportTo:${broker.id}`] === true).map((broker) => broker.id);
|
|
1749
|
+
}
|
|
1750
|
+
brokerName(brokerId) {
|
|
1751
|
+
return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
|
|
1752
|
+
}
|
|
1753
|
+
async handleCommandRoute(body, reply) {
|
|
1754
|
+
const topic = readString(body, "topic");
|
|
1755
|
+
const value = readString(body, "value");
|
|
1756
|
+
if (topic === null || value === null) {
|
|
1757
|
+
reply.status(422);
|
|
1758
|
+
reply.send({ error: "expected {topic, value}" });
|
|
1759
|
+
return;
|
|
1760
|
+
}
|
|
1761
|
+
const parsed = parseCommandTopic(topic);
|
|
1762
|
+
if (parsed === null) {
|
|
1763
|
+
this.ctx.logger.warn("ha-export: dropping a command on a malformed topic", { meta: { topic } });
|
|
1764
|
+
reply.status(422);
|
|
1765
|
+
reply.send({ error: "malformed topic" });
|
|
1766
|
+
return;
|
|
1767
|
+
}
|
|
1768
|
+
const exported = this.exported.get(parsed.deviceKey);
|
|
1769
|
+
if (exported === void 0) {
|
|
1770
|
+
this.ctx.logger.warn("ha-export: dropping a command for a device that is not exported", { meta: {
|
|
1771
|
+
topic,
|
|
1772
|
+
deviceKey: parsed.deviceKey
|
|
1773
|
+
} });
|
|
1774
|
+
reply.status(422);
|
|
1775
|
+
reply.send({ error: "device not exported" });
|
|
1776
|
+
return;
|
|
1777
|
+
}
|
|
1778
|
+
const command = resolveCommand(exported.target, parsed.entity, value);
|
|
1779
|
+
if (command === null) {
|
|
1780
|
+
this.ctx.logger.warn("ha-export: dropping an unroutable command", {
|
|
1781
|
+
tags: { deviceId: exported.deviceId },
|
|
1782
|
+
meta: {
|
|
1783
|
+
topic,
|
|
1784
|
+
entity: parsed.entity
|
|
1785
|
+
}
|
|
1786
|
+
});
|
|
1787
|
+
reply.status(422);
|
|
1788
|
+
reply.send({ error: "unroutable command" });
|
|
1789
|
+
return;
|
|
1790
|
+
}
|
|
1791
|
+
const applied = await this.dispatch(command);
|
|
1792
|
+
reply.status(applied ? 200 : 422);
|
|
1793
|
+
reply.send(applied ? {} : { error: "command not applied" });
|
|
1794
|
+
}
|
|
1795
|
+
async dispatch(command) {
|
|
1796
|
+
try {
|
|
1797
|
+
switch (command.kind) {
|
|
1798
|
+
case "camera-switch": {
|
|
1799
|
+
const parsed = CameraSwitchIdSchema.safeParse(command.switchId);
|
|
1800
|
+
if (!parsed.success) {
|
|
1801
|
+
this.ctx.logger.warn("ha-export: dropping a command for an unknown camera switch", {
|
|
1802
|
+
tags: { deviceId: command.deviceId },
|
|
1803
|
+
meta: { switchId: command.switchId }
|
|
1804
|
+
});
|
|
1805
|
+
return false;
|
|
1806
|
+
}
|
|
1807
|
+
await this.ctx.api.pipelineOrchestrator.setCameraSwitch.mutate({
|
|
1808
|
+
deviceId: command.deviceId,
|
|
1809
|
+
switchId: parsed.data,
|
|
1810
|
+
enabled: command.enabled
|
|
1811
|
+
});
|
|
1812
|
+
return true;
|
|
1813
|
+
}
|
|
1814
|
+
case "reboot": {
|
|
1815
|
+
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
1816
|
+
if (device.reboot === void 0) return this.noProvider(command.deviceId, "reboot");
|
|
1817
|
+
await device.reboot.reboot({});
|
|
1818
|
+
return true;
|
|
1819
|
+
}
|
|
1820
|
+
case "ptz-move": {
|
|
1821
|
+
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
1822
|
+
if (device.ptz === void 0) return this.noProvider(command.deviceId, "ptz");
|
|
1823
|
+
await device.ptz.move(PTZ_MOVE_BY_DIRECTION[command.direction] ?? {});
|
|
1824
|
+
return true;
|
|
1825
|
+
}
|
|
1826
|
+
case "ptz-preset": {
|
|
1827
|
+
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
1828
|
+
if (device.ptz === void 0) return this.noProvider(command.deviceId, "ptz");
|
|
1829
|
+
const preset = (await device.ptz.getPresets({})).find((entry) => entry.name === command.preset);
|
|
1830
|
+
if (preset === void 0) {
|
|
1831
|
+
this.ctx.logger.warn("ha-export: dropping a preset command, no preset by that name", {
|
|
1832
|
+
tags: { deviceId: command.deviceId },
|
|
1833
|
+
meta: { preset: command.preset }
|
|
1834
|
+
});
|
|
1835
|
+
return false;
|
|
1836
|
+
}
|
|
1837
|
+
await device.ptz.goToPreset({ presetId: preset.id });
|
|
1838
|
+
return true;
|
|
1839
|
+
}
|
|
1840
|
+
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
1841
|
+
case "cap-switch": {
|
|
1842
|
+
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
1843
|
+
if (device.switch === void 0) return this.noProvider(command.deviceId, "switch");
|
|
1844
|
+
await device.switch.setState({ on: command.on });
|
|
1845
|
+
return true;
|
|
1846
|
+
}
|
|
1847
|
+
case "cap-button": {
|
|
1848
|
+
const device = await this.ctx.fetchDevice(command.deviceId);
|
|
1849
|
+
if (device.button === void 0) return this.noProvider(command.deviceId, "button");
|
|
1850
|
+
await device.button.press({});
|
|
1851
|
+
return true;
|
|
1852
|
+
}
|
|
1853
|
+
}
|
|
1854
|
+
} catch (err) {
|
|
1855
|
+
/**
|
|
1856
|
+
* No optimistic ack. Every writable entity promises HA that the
|
|
1857
|
+
* state topic is reality; acking a failed call makes that a lie in
|
|
1858
|
+
* the only case that matters. The next reconcile corrects HA's UI.
|
|
1859
|
+
*/
|
|
1860
|
+
this.ctx.logger.warn("ha-export: command not applied", {
|
|
1861
|
+
tags: { deviceId: command.deviceId },
|
|
1862
|
+
meta: {
|
|
1863
|
+
kind: command.kind,
|
|
1864
|
+
error: errMsg(err)
|
|
1865
|
+
}
|
|
1866
|
+
});
|
|
1867
|
+
return false;
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1870
|
+
/** A cap the device does not provide. Refused, and never silently. */
|
|
1871
|
+
noProvider(deviceId, capName) {
|
|
1872
|
+
this.ctx.logger.warn("ha-export: dropping a command, the device has no provider for it", {
|
|
1873
|
+
tags: { deviceId },
|
|
1874
|
+
meta: { capName }
|
|
1875
|
+
});
|
|
1876
|
+
return false;
|
|
1877
|
+
}
|
|
1878
|
+
async applySnooze(deviceId, minutes) {
|
|
1879
|
+
if (minutes === null) {
|
|
1880
|
+
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
1881
|
+
const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
|
|
1882
|
+
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
1883
|
+
this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
|
|
1884
|
+
tags: { deviceId },
|
|
1885
|
+
meta: { cancelled: mine.length }
|
|
1886
|
+
});
|
|
1887
|
+
return true;
|
|
1888
|
+
}
|
|
1889
|
+
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
1890
|
+
scope: "device",
|
|
1891
|
+
deviceId,
|
|
1892
|
+
durationMinutes: minutes
|
|
1893
|
+
} });
|
|
1894
|
+
this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
|
|
1895
|
+
tags: { deviceId },
|
|
1896
|
+
meta: { minutes }
|
|
1897
|
+
});
|
|
1898
|
+
return true;
|
|
1899
|
+
}
|
|
1900
|
+
/**
|
|
1901
|
+
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
1902
|
+
*
|
|
1903
|
+
* Public because the fetcher is HA's frontend or a phone, neither of
|
|
1904
|
+
* which holds a camstack token; signed and expiring so a leaked link
|
|
1905
|
+
* stops working on its own.
|
|
1906
|
+
*/
|
|
1907
|
+
async serveMediaPlane() {
|
|
1908
|
+
const dataPlane = this.ctx.dataPlane;
|
|
1909
|
+
if (dataPlane === void 0) {
|
|
1910
|
+
this.ctx.logger.warn("ha-export: no data plane on this runner — image entities will carry no URL");
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
const served = await dataPlane.serve({
|
|
1914
|
+
prefix: "ha-media",
|
|
1915
|
+
access: "public",
|
|
1916
|
+
handler: async (req, res) => {
|
|
1917
|
+
await this.serveMedia(req, res);
|
|
1918
|
+
}
|
|
1919
|
+
});
|
|
1920
|
+
this.ctx.addDisposer(async () => {
|
|
1921
|
+
await served.dispose();
|
|
1922
|
+
});
|
|
1923
|
+
}
|
|
1924
|
+
async serveMedia(req, res) {
|
|
1925
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
1926
|
+
const segments = url.pathname.split("/").filter((part) => part.length > 0);
|
|
1927
|
+
const id = segments[segments.length - 1];
|
|
1928
|
+
if (id === void 0) {
|
|
1929
|
+
res.statusCode = 400;
|
|
1930
|
+
res.end();
|
|
1931
|
+
return;
|
|
1932
|
+
}
|
|
1933
|
+
const trackId = decodeURIComponent(id);
|
|
1934
|
+
if (!verifyMediaSignature({
|
|
1935
|
+
secret: this.mediaSecret,
|
|
1936
|
+
id: trackId,
|
|
1937
|
+
exp: url.searchParams.get("exp") ?? void 0,
|
|
1938
|
+
sig: url.searchParams.get("sig") ?? void 0,
|
|
1939
|
+
nowMs: Date.now()
|
|
1940
|
+
})) {
|
|
1941
|
+
res.statusCode = 403;
|
|
1942
|
+
res.end();
|
|
1943
|
+
return;
|
|
1944
|
+
}
|
|
1945
|
+
try {
|
|
1946
|
+
const first = (await this.ctx.api.pipelineAnalytics.getTrackMedia.query({
|
|
1947
|
+
trackId,
|
|
1948
|
+
kinds: [
|
|
1949
|
+
"thumbnailSmall",
|
|
1950
|
+
"thumbnail",
|
|
1951
|
+
"crop"
|
|
1952
|
+
]
|
|
1953
|
+
}))[0];
|
|
1954
|
+
if (first === void 0) {
|
|
1955
|
+
res.statusCode = 404;
|
|
1956
|
+
res.end();
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1959
|
+
const bytes = Buffer.from(first.base64, "base64");
|
|
1960
|
+
res.statusCode = 200;
|
|
1961
|
+
res.setHeader("content-type", "image/jpeg");
|
|
1962
|
+
res.setHeader("cache-control", "public, max-age=3600");
|
|
1963
|
+
res.end(bytes);
|
|
1964
|
+
} catch (err) {
|
|
1965
|
+
this.ctx.logger.warn("ha-export: media fetch failed", { meta: {
|
|
1966
|
+
trackId,
|
|
1967
|
+
error: errMsg(err)
|
|
1968
|
+
} });
|
|
1969
|
+
res.statusCode = 502;
|
|
1970
|
+
res.end();
|
|
1971
|
+
}
|
|
1972
|
+
}
|
|
1973
|
+
/**
|
|
1974
|
+
* Mint a link, or `null`.
|
|
1975
|
+
*
|
|
1976
|
+
* `null` when the hub has no reachable base URL: a URL Home Assistant
|
|
1977
|
+
* cannot open is worse than no image, because it renders as a broken
|
|
1978
|
+
* entity rather than an empty one.
|
|
1979
|
+
*/
|
|
1980
|
+
mintTrackMediaUrl(trackId) {
|
|
1981
|
+
if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
|
|
1982
|
+
return buildMediaUrl({
|
|
1983
|
+
baseUrl: this.mediaBaseUrl,
|
|
1984
|
+
routePrefix: MEDIA_ROUTE_PREFIX,
|
|
1985
|
+
id: trackId,
|
|
1986
|
+
secret: this.mediaSecret,
|
|
1987
|
+
expMs: Date.now() + MEDIA_URL_TTL_MS
|
|
1988
|
+
});
|
|
1989
|
+
}
|
|
1990
|
+
async refreshMediaBaseUrl() {
|
|
1991
|
+
if (this.config.publicBaseUrl.length > 0) {
|
|
1992
|
+
this.mediaBaseUrl = this.config.publicBaseUrl;
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
try {
|
|
1996
|
+
const { endpoints } = await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port: HUB_API_PORT });
|
|
1997
|
+
/**
|
|
1998
|
+
* Lowest priority wins, loopback never: a link Home Assistant
|
|
1999
|
+
* cannot open is worse than no image, because it renders as a
|
|
2000
|
+
* broken entity rather than an empty one.
|
|
2001
|
+
*/
|
|
2002
|
+
const usable = [...endpoints].filter((endpoint) => endpoint.kind !== "loopback" && endpoint.plausible).sort((a, b) => a.priority - b.priority)[0];
|
|
2003
|
+
this.mediaBaseUrl = usable?.baseUrl ?? null;
|
|
2004
|
+
} catch (err) {
|
|
2005
|
+
this.mediaBaseUrl = null;
|
|
2006
|
+
this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });
|
|
2007
|
+
}
|
|
2008
|
+
if (this.mediaBaseUrl === null) this.ctx.logger.warn("ha-export: no reachable hub URL — image entities will carry no value until one is set");
|
|
2009
|
+
}
|
|
2010
|
+
/**
|
|
2011
|
+
* The cap-level expose/unexpose applies to EVERY enabled broker.
|
|
2012
|
+
*
|
|
2013
|
+
* The `device-export` cap is the COMMON surface across export targets
|
|
2014
|
+
* and has no notion of a broker; per-broker granularity lives in the
|
|
2015
|
+
* derived Export panel, over the same store. There is exactly one
|
|
2016
|
+
* authority either way.
|
|
2017
|
+
*/
|
|
2018
|
+
async setMembershipEverywhere(deviceId, exposed) {
|
|
2019
|
+
let next = this.config.membership;
|
|
2020
|
+
for (const brokerId of this.enabledBrokerIds()) next = setExposed(next, brokerId, deviceId, exposed);
|
|
2021
|
+
if (next === this.config.membership) {
|
|
2022
|
+
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
2023
|
+
tags: { deviceId: Number(deviceId) },
|
|
2024
|
+
meta: { exposed }
|
|
2025
|
+
});
|
|
2026
|
+
return;
|
|
2027
|
+
}
|
|
2028
|
+
await this.updateGlobalSettings({ membership: next });
|
|
2029
|
+
this.ctx.logger.info("ha-export: export membership changed", {
|
|
2030
|
+
tags: { deviceId: Number(deviceId) },
|
|
2031
|
+
meta: {
|
|
2032
|
+
exposed,
|
|
2033
|
+
brokers: this.enabledBrokerIds()
|
|
2034
|
+
}
|
|
2035
|
+
});
|
|
2036
|
+
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
2037
|
+
}
|
|
2038
|
+
globalSettingsSchema() {
|
|
2039
|
+
const brokerFields = this.config.knownBrokers.map((broker) => ({
|
|
2040
|
+
type: "boolean",
|
|
2041
|
+
key: `exportTo:${broker.id}`,
|
|
2042
|
+
label: broker.name,
|
|
2043
|
+
description: `Push entities to this Home Assistant instance (${broker.id}).`,
|
|
2044
|
+
style: "switch",
|
|
2045
|
+
default: false
|
|
2046
|
+
}));
|
|
2047
|
+
return { sections: [{
|
|
2048
|
+
id: "ha-export-brokers",
|
|
2049
|
+
title: "Home Assistant instances",
|
|
2050
|
+
description: brokerFields.length > 0 ? "One switch per Home Assistant broker. An instance that is off receives nothing; the devices exported to it are remembered and resume when it is turned back on." : "No Home Assistant broker is registered yet. Add one under External systems → Brokers and it will appear here.",
|
|
2051
|
+
columns: 1,
|
|
2052
|
+
fields: brokerFields.length > 0 ? brokerFields : [{
|
|
2053
|
+
type: "info",
|
|
2054
|
+
key: "ha-export:__no-brokers",
|
|
2055
|
+
label: "No brokers",
|
|
2056
|
+
content: "Register a Home Assistant broker to enable the export.",
|
|
2057
|
+
variant: "warning"
|
|
2058
|
+
}]
|
|
2059
|
+
}, {
|
|
2060
|
+
id: "ha-export-transport",
|
|
2061
|
+
title: "Transport",
|
|
2062
|
+
columns: 1,
|
|
2063
|
+
fields: [{
|
|
2064
|
+
type: "text",
|
|
2065
|
+
key: "publicBaseUrl",
|
|
2066
|
+
label: "Hub URL Home Assistant can reach",
|
|
2067
|
+
description: "Used to build the signed image links Home Assistant fetches. Leave empty to derive one from the network capabilities; a link that cannot be built is never sent.",
|
|
2068
|
+
default: ""
|
|
2069
|
+
}, {
|
|
2070
|
+
type: "number",
|
|
2071
|
+
key: "reconcileIntervalSec",
|
|
2072
|
+
label: "Reconcile interval (s)",
|
|
2073
|
+
description: "Full re-announce and re-push cadence. Bus events are telemetry and may be dropped; this is the contract.",
|
|
2074
|
+
default: DEFAULT_CONFIG.reconcileIntervalSec
|
|
2075
|
+
}]
|
|
2076
|
+
}] };
|
|
2077
|
+
}
|
|
2078
|
+
/**
|
|
2079
|
+
* Per-device Export panel — one switch PER BROKER.
|
|
2080
|
+
*
|
|
2081
|
+
* Field keys are namespaced `haExport:<deviceId>:<brokerId>`: the
|
|
2082
|
+
* device-details form flattens fields by key across every contributing
|
|
2083
|
+
* section, so a bare `enabled` collides with the Alexa and HomeKit
|
|
2084
|
+
* exporters' identically-named fields and the operator sees another
|
|
2085
|
+
* exporter's value in this one's toggle.
|
|
2086
|
+
*/
|
|
2087
|
+
buildContribution(deviceId) {
|
|
2088
|
+
const idStr = String(deviceId);
|
|
2089
|
+
const enabled = this.enabledBrokerIds();
|
|
2090
|
+
const fields = enabled.map((brokerId) => ({
|
|
2091
|
+
type: "boolean",
|
|
2092
|
+
key: `haExport:${deviceId}:${brokerId}`,
|
|
2093
|
+
label: `Export to ${this.brokerName(brokerId)}`,
|
|
2094
|
+
description: "Entities for this device are pushed to that Home Assistant instance.",
|
|
2095
|
+
style: "switch",
|
|
2096
|
+
value: isExposed(this.config.membership, brokerId, idStr),
|
|
2097
|
+
immediate: true
|
|
2098
|
+
}));
|
|
2099
|
+
return {
|
|
2100
|
+
tabs: [{
|
|
2101
|
+
id: "export",
|
|
2102
|
+
label: "Export",
|
|
2103
|
+
icon: "share-2",
|
|
2104
|
+
order: 80
|
|
2105
|
+
}],
|
|
2106
|
+
sections: [{
|
|
2107
|
+
id: "ha-export",
|
|
2108
|
+
title: "Home Assistant",
|
|
2109
|
+
description: enabled.length > 0 ? "Each Home Assistant instance receives only the devices switched on for it." : "No Home Assistant instance is enabled for export. Turn one on in the Home Assistant Export addon settings.",
|
|
2110
|
+
tab: "export",
|
|
2111
|
+
columns: 1,
|
|
2112
|
+
order: 30,
|
|
2113
|
+
fields: fields.length > 0 ? fields : [{
|
|
2114
|
+
type: "info",
|
|
2115
|
+
key: `haExport:${deviceId}:__none`,
|
|
2116
|
+
label: "Not configured",
|
|
2117
|
+
content: "Enable a Home Assistant instance in the addon settings first.",
|
|
2118
|
+
variant: "info"
|
|
2119
|
+
}]
|
|
2120
|
+
}]
|
|
2121
|
+
};
|
|
2122
|
+
}
|
|
2123
|
+
async applyDeviceSettingsPatch(deviceId, patch) {
|
|
2124
|
+
const idStr = String(deviceId);
|
|
2125
|
+
let next = this.config.membership;
|
|
2126
|
+
let touched = false;
|
|
2127
|
+
for (const brokerId of this.enabledBrokerIds()) {
|
|
2128
|
+
const key = `haExport:${deviceId}:${brokerId}`;
|
|
2129
|
+
if (!(key in patch)) continue;
|
|
2130
|
+
next = setExposed(next, brokerId, idStr, patch[key] === true);
|
|
2131
|
+
touched = true;
|
|
2132
|
+
}
|
|
2133
|
+
if (!touched) return { success: true };
|
|
2134
|
+
await this.updateGlobalSettings({ membership: next });
|
|
2135
|
+
this.ctx.logger.info("ha-export: per-broker export membership changed", {
|
|
2136
|
+
tags: { deviceId },
|
|
2137
|
+
meta: { brokers: brokersExposing(next, idStr) }
|
|
2138
|
+
});
|
|
2139
|
+
await this.reconcile("device-panel");
|
|
2140
|
+
return { success: true };
|
|
2141
|
+
}
|
|
2142
|
+
buildStatus() {
|
|
2143
|
+
const enabled = this.enabledBrokerIds();
|
|
2144
|
+
const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
|
|
2145
|
+
return {
|
|
2146
|
+
linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
|
|
2147
|
+
exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
|
|
2148
|
+
...this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
2149
|
+
setup: {
|
|
2150
|
+
fields: [
|
|
2151
|
+
{
|
|
2152
|
+
label: "Home Assistant instances",
|
|
2153
|
+
value: `${healthy} / ${this.links.size} reachable`
|
|
2154
|
+
},
|
|
2155
|
+
{
|
|
2156
|
+
label: "Entities pushed",
|
|
2157
|
+
value: String(this.entityCount)
|
|
2158
|
+
},
|
|
2159
|
+
{
|
|
2160
|
+
label: "Push endpoint",
|
|
2161
|
+
value: PUSH_PATH
|
|
2162
|
+
},
|
|
2163
|
+
{
|
|
2164
|
+
label: "Capabilities unclassified",
|
|
2165
|
+
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
2166
|
+
},
|
|
2167
|
+
{
|
|
2168
|
+
label: "Snooze options",
|
|
2169
|
+
value: SNOOZE_OPTIONS.join(", ")
|
|
2170
|
+
}
|
|
2171
|
+
],
|
|
2172
|
+
note: "CamStack pushes to the CamStack custom component in Home Assistant. Home Assistant never polls the hub for a value; when the heartbeat stops, every entity is marked unavailable."
|
|
2173
|
+
}
|
|
2174
|
+
};
|
|
2175
|
+
}
|
|
2176
|
+
startTimer() {
|
|
2177
|
+
this.stopTimer();
|
|
2178
|
+
const ms = Math.max(30, this.config.reconcileIntervalSec) * 1e3;
|
|
2179
|
+
this.reconcileTimer = setInterval(() => void this.reconcile("periodic"), ms);
|
|
2180
|
+
}
|
|
2181
|
+
stopTimer() {
|
|
2182
|
+
if (this.reconcileTimer !== null) clearInterval(this.reconcileTimer);
|
|
2183
|
+
this.reconcileTimer = null;
|
|
2184
|
+
}
|
|
2185
|
+
};
|
|
2186
|
+
/**
|
|
2187
|
+
* Device kinds the picker offers.
|
|
2188
|
+
*
|
|
2189
|
+
* Every kind camstack models: coverage is decided by a device's
|
|
2190
|
+
* CAPABILITIES, not its type, and restricting the picker by type is what
|
|
2191
|
+
* confined the previous exporter to cameras.
|
|
2192
|
+
*/
|
|
2193
|
+
var SUPPORTED_DEVICE_KINDS = [
|
|
2194
|
+
"camera",
|
|
2195
|
+
"light",
|
|
2196
|
+
"siren",
|
|
2197
|
+
"switch",
|
|
2198
|
+
"sensor",
|
|
2199
|
+
"thermostat",
|
|
2200
|
+
"climate",
|
|
2201
|
+
"button",
|
|
2202
|
+
"event-emitter",
|
|
2203
|
+
"update",
|
|
2204
|
+
"generic",
|
|
2205
|
+
"lock",
|
|
2206
|
+
"cover",
|
|
2207
|
+
"valve",
|
|
2208
|
+
"humidifier",
|
|
2209
|
+
"water-heater",
|
|
2210
|
+
"fan",
|
|
2211
|
+
"media-player",
|
|
2212
|
+
"alarm-panel",
|
|
2213
|
+
"control",
|
|
2214
|
+
"presence",
|
|
2215
|
+
"weather",
|
|
2216
|
+
"vacuum",
|
|
2217
|
+
"lawn-mower",
|
|
2218
|
+
"container",
|
|
2219
|
+
"image",
|
|
2220
|
+
"pet-feeder",
|
|
2221
|
+
"hub"
|
|
2222
|
+
];
|
|
2223
|
+
/**
|
|
2224
|
+
* POST one message to Home Assistant's push view.
|
|
2225
|
+
*
|
|
2226
|
+
* Authenticated with the broker's OWN long-lived token: the component
|
|
2227
|
+
* registers its view behind HA auth, so there is no second shared secret
|
|
2228
|
+
* to configure or rotate. A non-2xx throws, which is how the push client
|
|
2229
|
+
* learns the instance is unreachable.
|
|
2230
|
+
*/
|
|
2231
|
+
async function postToHomeAssistant(baseUrl, token, message) {
|
|
2232
|
+
const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
|
|
2233
|
+
method: "POST",
|
|
2234
|
+
headers: {
|
|
2235
|
+
authorization: `Bearer ${token}`,
|
|
2236
|
+
"content-type": "application/json"
|
|
2237
|
+
},
|
|
2238
|
+
body: JSON.stringify(message),
|
|
2239
|
+
signal: AbortSignal.timeout(1e4)
|
|
2240
|
+
});
|
|
2241
|
+
if (!response.ok) throw new Error(`Home Assistant answered ${response.status}`);
|
|
2242
|
+
}
|
|
2243
|
+
function randomSecret() {
|
|
2244
|
+
const bytes = new Uint8Array(32);
|
|
2245
|
+
crypto.getRandomValues(bytes);
|
|
2246
|
+
return [...bytes].map((byte) => byte.toString(16).padStart(2, "0")).join("");
|
|
2247
|
+
}
|
|
2248
|
+
function sameBrokers(a, b) {
|
|
2249
|
+
if (a.length !== b.length) return false;
|
|
2250
|
+
return a.every((broker, index) => {
|
|
2251
|
+
const other = b[index];
|
|
2252
|
+
return other !== void 0 && other.id === broker.id && other.name === broker.name;
|
|
2253
|
+
});
|
|
2254
|
+
}
|
|
2255
|
+
function toSnapshot(raw) {
|
|
2256
|
+
if (raw === null || raw === void 0 || typeof raw !== "object") return {};
|
|
2257
|
+
const out = {};
|
|
2258
|
+
for (const [cap, slice] of Object.entries(raw)) if (slice !== null && typeof slice === "object") out[cap] = { ...slice };
|
|
2259
|
+
return out;
|
|
2260
|
+
}
|
|
2261
|
+
function toCountRecord(raw) {
|
|
2262
|
+
if (raw === null || typeof raw !== "object") return {};
|
|
2263
|
+
const out = {};
|
|
2264
|
+
for (const [key, value] of Object.entries(raw)) if (typeof value === "number") out[key] = value;
|
|
2265
|
+
return out;
|
|
2266
|
+
}
|
|
2267
|
+
function readRecordField(source, key) {
|
|
2268
|
+
if (source === null || typeof source !== "object") return void 0;
|
|
2269
|
+
return Reflect.get(source, key);
|
|
2270
|
+
}
|
|
2271
|
+
function readString(source, key) {
|
|
2272
|
+
const value = readRecordField(source, key);
|
|
2273
|
+
return typeof value === "string" && value.length > 0 ? value : null;
|
|
2274
|
+
}
|
|
2275
|
+
function readNumber(source, key) {
|
|
2276
|
+
const value = readRecordField(source, key);
|
|
2277
|
+
return typeof value === "number" && Number.isFinite(value) ? value : null;
|
|
2278
|
+
}
|
|
2279
|
+
function readRecord(source, key) {
|
|
2280
|
+
const value = readRecordField(source, key);
|
|
2281
|
+
if (value === null || typeof value !== "object") return null;
|
|
2282
|
+
return { ...value };
|
|
2283
|
+
}
|
|
2284
|
+
function readStringArray(source, key) {
|
|
2285
|
+
const value = readRecordField(source, key);
|
|
2286
|
+
if (!Array.isArray(value)) return [];
|
|
2287
|
+
return value.filter((entry) => typeof entry === "string");
|
|
2288
|
+
}
|
|
2289
|
+
function errMsg(err) {
|
|
2290
|
+
return err instanceof Error ? err.message : String(err);
|
|
2291
|
+
}
|
|
2292
|
+
//#endregion
|
|
2293
|
+
export { HaExportAddon, HaExportAddon as default };
|