@camstack/addon-provider-homeassistant 1.2.19 → 1.2.21
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/ha-export/ha-export.addon.js +823 -15
- package/dist/ha-export/ha-export.addon.mjs +823 -15
- package/package.json +1 -1
|
@@ -148,6 +148,16 @@ var ZONE_MACROS = [
|
|
|
148
148
|
* So the flag is a required field on every spec. A new entity — or the
|
|
149
149
|
* synthetic devices the design addendum still owes — cannot inherit a
|
|
150
150
|
* camera's pressure valve by accident.
|
|
151
|
+
*
|
|
152
|
+
* **`last_image` is exempt, on every level of the fan-out** (operator,
|
|
153
|
+
* 2026-08-09). The valve exists to stop a fleet's worth of entities
|
|
154
|
+
* arriving switched on, but the picture IS the reason to look at a
|
|
155
|
+
* notification: an operator who wires `person_detected` into an
|
|
156
|
+
* automation wants the frame with it, and having to hunt through HA's
|
|
157
|
+
* disabled-entity list per camera per macro per zone is the opposite of
|
|
158
|
+
* what the export is for. The three `*_last_image` entities — camera,
|
|
159
|
+
* per macro, per zone × macro — therefore ship enabled. They cost one
|
|
160
|
+
* entity each and carry a signed URL, not bytes.
|
|
151
161
|
*/
|
|
152
162
|
/**
|
|
153
163
|
* The snooze surface, as a `select`.
|
|
@@ -218,7 +228,14 @@ function deviceBlock(device) {
|
|
|
218
228
|
*/
|
|
219
229
|
function zoneSpecs(zone) {
|
|
220
230
|
const slug = toSlug(zone.id);
|
|
221
|
-
const specs = [
|
|
231
|
+
const specs = [{
|
|
232
|
+
entity: `${slug}_objects`,
|
|
233
|
+
platform: "sensor",
|
|
234
|
+
label: `${zone.name} objects`,
|
|
235
|
+
uniqueSuffix: `${zone.id}_objects`,
|
|
236
|
+
icon: "mdi:counter",
|
|
237
|
+
enabledByDefault: true
|
|
238
|
+
}];
|
|
222
239
|
for (const macro of ZONE_MACROS) {
|
|
223
240
|
const label = `${zone.name} ${macro}`;
|
|
224
241
|
specs.push({
|
|
@@ -233,7 +250,7 @@ function zoneSpecs(zone) {
|
|
|
233
250
|
platform: "image",
|
|
234
251
|
label: `${label} last image`,
|
|
235
252
|
uniqueSuffix: `${zone.id}_${macro}_last_image`,
|
|
236
|
-
enabledByDefault:
|
|
253
|
+
enabledByDefault: true
|
|
237
254
|
}, {
|
|
238
255
|
entity: `${slug}_${macro}_last_detection`,
|
|
239
256
|
platform: "sensor",
|
|
@@ -275,6 +292,71 @@ function cameraSpecs(device) {
|
|
|
275
292
|
label: "Last image",
|
|
276
293
|
enabledByDefault: true
|
|
277
294
|
},
|
|
295
|
+
(
|
|
296
|
+
/**
|
|
297
|
+
* The SCENE — everything on camera, in a zone or not, and including
|
|
298
|
+
* confirmed stationary objects (they are still there). The frame-wide
|
|
299
|
+
* scope of the same occupancy snapshot the per-zone counters come from,
|
|
300
|
+
* which was computed and simply never projected.
|
|
301
|
+
*/
|
|
302
|
+
{
|
|
303
|
+
entity: "objects",
|
|
304
|
+
platform: "sensor",
|
|
305
|
+
label: "Objects",
|
|
306
|
+
icon: "mdi:counter",
|
|
307
|
+
enabledByDefault: true
|
|
308
|
+
}),
|
|
309
|
+
(
|
|
310
|
+
/**
|
|
311
|
+
* What was heard, and how loud. Fed by `pipeline.audio-inference-result`
|
|
312
|
+
* — the event this addon did not subscribe to at all, which is why
|
|
313
|
+
* `audio_detected` below has effectively never moved.
|
|
314
|
+
*
|
|
315
|
+
* `audio_last_sound`, NOT `audio_last_label`: `<macro>_last_label` is the
|
|
316
|
+
* TRACK path's naming, reserved for the macros that carry an
|
|
317
|
+
* identification (`LABELLED_MACROS`), and `audio` is deliberately not one
|
|
318
|
+
* of them because the track lifecycle has no label for it. Reusing that
|
|
319
|
+
* name would put two sources on one topic — the classifier here and the
|
|
320
|
+
* track projector there — which is the two-knobs failure this repo keeps
|
|
321
|
+
* paying for. Different source, different entity, different name.
|
|
322
|
+
*
|
|
323
|
+
* dBFS is negative-going (0 = full scale), so silence reads as a large
|
|
324
|
+
* negative number rather than as a missing value. There is no unit
|
|
325
|
+
* declared: HA has no dBFS device class, and mislabelling it `dB` would
|
|
326
|
+
* put a plausible wrong unit on a dashboard.
|
|
327
|
+
*/
|
|
328
|
+
{
|
|
329
|
+
entity: "audio_last_sound",
|
|
330
|
+
platform: "sensor",
|
|
331
|
+
label: "Audio last sound",
|
|
332
|
+
icon: "mdi:ear-hearing",
|
|
333
|
+
enabledByDefault: true
|
|
334
|
+
}),
|
|
335
|
+
{
|
|
336
|
+
entity: "audio_volume",
|
|
337
|
+
platform: "sensor",
|
|
338
|
+
label: "Audio level",
|
|
339
|
+
unit: "dBFS",
|
|
340
|
+
icon: "mdi:volume-high",
|
|
341
|
+
enabledByDefault: true
|
|
342
|
+
},
|
|
343
|
+
(
|
|
344
|
+
/**
|
|
345
|
+
* BOTH levels, because they answer different questions and neither
|
|
346
|
+
* derives from the other in a way an operator should have to do in a
|
|
347
|
+
* template. dBFS is logarithmic and negative-going — the one to threshold
|
|
348
|
+
* on ("louder than -30") — while RMS is the linear amplitude, which is
|
|
349
|
+
* what a graph of relative loudness wants. The analyzer computes both in
|
|
350
|
+
* the same window; publishing one and making the other a `log10` template
|
|
351
|
+
* in Home Assistant is work we already did.
|
|
352
|
+
*/
|
|
353
|
+
{
|
|
354
|
+
entity: "audio_level_rms",
|
|
355
|
+
platform: "sensor",
|
|
356
|
+
label: "Audio level (RMS)",
|
|
357
|
+
icon: "mdi:waveform",
|
|
358
|
+
enabledByDefault: true
|
|
359
|
+
}),
|
|
278
360
|
{
|
|
279
361
|
entity: "last_detection",
|
|
280
362
|
platform: "sensor",
|
|
@@ -350,6 +432,60 @@ function cameraSpecs(device) {
|
|
|
350
432
|
enabledByDefault: true
|
|
351
433
|
});
|
|
352
434
|
}
|
|
435
|
+
/**
|
|
436
|
+
* The doorbell, when the operator has bound the cap to this camera.
|
|
437
|
+
*
|
|
438
|
+
* A press is not a detection macro — it is not something the pipeline
|
|
439
|
+
* classifies — so it is NOT in `EXPORTED_MACROS`, and `MACROS_NOT_EXPORTED`
|
|
440
|
+
* says a doorbell is an actuator on its own device. Both stay true for the
|
|
441
|
+
* BUTTON accessory. What was missing is the camera case: a camera with the
|
|
442
|
+
* `doorbell` cap bound rings, and the operator automates on the ring the same
|
|
443
|
+
* way they automate on `person_detected`. Reolink's own doorbell is exactly
|
|
444
|
+
* this shape (`ReolinkSimpleEvent.type === 'doorbell'` at the camera level),
|
|
445
|
+
* and so is any camera the `virtual-doorbell` wrapper is pointed at.
|
|
446
|
+
*
|
|
447
|
+
* Every entity here is fed by the `doorbell` runtime-state slice
|
|
448
|
+
* (`lastPressedAt`, `pressCountSinceStart`) on `device.state-changed` —
|
|
449
|
+
* the same road the `battery` slice already travels.
|
|
450
|
+
*
|
|
451
|
+
* **`doorbell_last_image` is owed, and the frame for it already exists.**
|
|
452
|
+
* A press DOES have a picture: `sensor-marker-projector.ts` materialises a
|
|
453
|
+
* synthetic marker track carrying a `keyFrameSmall` of the camera, taken
|
|
454
|
+
* before the track is persisted, and the notification names that track as
|
|
455
|
+
* its media owner. What is missing is only the road to THIS addon:
|
|
456
|
+
* `persistSyntheticTrack` writes the row directly and emits no
|
|
457
|
+
* `pipeline-analytics.track-lifecycle`, which is the single event the export
|
|
458
|
+
* listens to for frames. So the export never hears about the marker while
|
|
459
|
+
* the notification centre shows its photo — measured 2026-08-09.
|
|
460
|
+
*
|
|
461
|
+
* It is NOT fixed by taking a second snapshot here at push time: the marker
|
|
462
|
+
* projector's own record argues that out (one snapshot, one owner, one
|
|
463
|
+
* persistence point — the producer's), and a second photograph of a doorway
|
|
464
|
+
* seconds after the ring is a different picture from the one in the
|
|
465
|
+
* notification. The fix belongs upstream, at the point that already owns the
|
|
466
|
+
* frame. Named here rather than shipped as an entity nothing feeds.
|
|
467
|
+
*/
|
|
468
|
+
if (device.boundCaps.includes("doorbell")) specs.push({
|
|
469
|
+
entity: "doorbell_pressed",
|
|
470
|
+
platform: "binary_sensor",
|
|
471
|
+
label: "Doorbell pressed",
|
|
472
|
+
deviceClass: "occupancy",
|
|
473
|
+
enabledByDefault: true
|
|
474
|
+
}, {
|
|
475
|
+
entity: "doorbell_last_pressed",
|
|
476
|
+
platform: "sensor",
|
|
477
|
+
label: "Doorbell last pressed",
|
|
478
|
+
deviceClass: "timestamp",
|
|
479
|
+
icon: "mdi:bell-ring",
|
|
480
|
+
enabledByDefault: true
|
|
481
|
+
}, {
|
|
482
|
+
entity: "doorbell_press_count",
|
|
483
|
+
platform: "sensor",
|
|
484
|
+
label: "Doorbell presses since restart",
|
|
485
|
+
icon: "mdi:counter",
|
|
486
|
+
entityCategory: "diagnostic",
|
|
487
|
+
enabledByDefault: false
|
|
488
|
+
});
|
|
353
489
|
specs.push({
|
|
354
490
|
entity: "snooze",
|
|
355
491
|
platform: "select",
|
|
@@ -370,13 +506,19 @@ function cameraSpecs(device) {
|
|
|
370
506
|
entity: `${macro}_last_image`,
|
|
371
507
|
platform: "image",
|
|
372
508
|
label: `${label} last image`,
|
|
373
|
-
enabledByDefault:
|
|
509
|
+
enabledByDefault: true
|
|
374
510
|
}, {
|
|
375
511
|
entity: `${macro}_last_detection`,
|
|
376
512
|
platform: "sensor",
|
|
377
513
|
label: `${label} last detection`,
|
|
378
514
|
deviceClass: "timestamp",
|
|
379
515
|
enabledByDefault: false
|
|
516
|
+
}, {
|
|
517
|
+
entity: `${macro}_objects`,
|
|
518
|
+
platform: "sensor",
|
|
519
|
+
label: `${label} objects`,
|
|
520
|
+
icon: "mdi:counter",
|
|
521
|
+
enabledByDefault: false
|
|
380
522
|
});
|
|
381
523
|
if (LABELLED_MACROS.includes(macro)) specs.push({
|
|
382
524
|
entity: `${macro}_last_label`,
|
|
@@ -1325,6 +1467,10 @@ function projectZoneOccupancy(deviceKey, input) {
|
|
|
1325
1467
|
const values = [];
|
|
1326
1468
|
for (const zone of input.zones) {
|
|
1327
1469
|
const slug = toSlug(zone.zoneId);
|
|
1470
|
+
values.push({
|
|
1471
|
+
topic: stateTopic(deviceKey, `${slug}_objects`),
|
|
1472
|
+
value: String(zone.totalObjects)
|
|
1473
|
+
});
|
|
1328
1474
|
const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
|
|
1329
1475
|
for (const [className, count] of Object.entries(zone.byClass)) {
|
|
1330
1476
|
const macro = macroOf(className);
|
|
@@ -1336,6 +1482,22 @@ function projectZoneOccupancy(deviceKey, input) {
|
|
|
1336
1482
|
value: String(count)
|
|
1337
1483
|
});
|
|
1338
1484
|
}
|
|
1485
|
+
if (input.scene !== void 0) {
|
|
1486
|
+
values.push({
|
|
1487
|
+
topic: stateTopic(deviceKey, "objects"),
|
|
1488
|
+
value: String(input.scene.totalObjects)
|
|
1489
|
+
});
|
|
1490
|
+
const totals = new Map(EXPORTED_MACROS.map((macro) => [macro, 0]));
|
|
1491
|
+
for (const [className, count] of Object.entries(input.scene.byClass)) {
|
|
1492
|
+
const macro = macroOf(className);
|
|
1493
|
+
if (macro === null || !totals.has(macro)) continue;
|
|
1494
|
+
totals.set(macro, (totals.get(macro) ?? 0) + count);
|
|
1495
|
+
}
|
|
1496
|
+
for (const [macro, count] of totals) values.push({
|
|
1497
|
+
topic: stateTopic(deviceKey, `${macro}_objects`),
|
|
1498
|
+
value: String(count)
|
|
1499
|
+
});
|
|
1500
|
+
}
|
|
1339
1501
|
return values;
|
|
1340
1502
|
}
|
|
1341
1503
|
function projectBatterySlice(deviceKey, slice) {
|
|
@@ -1371,6 +1533,362 @@ function projectCameraSwitches(deviceKey, switches) {
|
|
|
1371
1533
|
value: bool(sw.enabled)
|
|
1372
1534
|
}));
|
|
1373
1535
|
}
|
|
1536
|
+
/**
|
|
1537
|
+
* The doorbell slice → its three entities.
|
|
1538
|
+
*
|
|
1539
|
+
* A press is a PULSE: the slice carries `lastPressedAt`, never a
|
|
1540
|
+
* `pressed: true/false`. `doorbell_pressed` is therefore driven by the
|
|
1541
|
+
* caller, which knows whether this projection is a fresh press or a
|
|
1542
|
+
* reconcile re-read — the same shape `projectMotion` already uses, and the
|
|
1543
|
+
* reason the flag is a parameter rather than derived from a timestamp
|
|
1544
|
+
* comparison here (a pure function that reads the clock is a pure function
|
|
1545
|
+
* that behaves differently in a test).
|
|
1546
|
+
*
|
|
1547
|
+
* `lastPressedAt: null` publishes NOTHING for the timestamp: HA renders an
|
|
1548
|
+
* empty timestamp sensor as `unknown`, which is honest, whereas `0` would
|
|
1549
|
+
* render as 1 January 1970 on the operator's dashboard.
|
|
1550
|
+
*/
|
|
1551
|
+
function projectDoorbellSlice(deviceKey, slice, pressed) {
|
|
1552
|
+
const values = [{
|
|
1553
|
+
topic: stateTopic(deviceKey, "doorbell_pressed"),
|
|
1554
|
+
value: bool(pressed)
|
|
1555
|
+
}, {
|
|
1556
|
+
topic: stateTopic(deviceKey, "doorbell_press_count"),
|
|
1557
|
+
value: String(slice.pressCountSinceStart)
|
|
1558
|
+
}];
|
|
1559
|
+
if (slice.lastPressedAt !== null) values.push({
|
|
1560
|
+
topic: stateTopic(deviceKey, "doorbell_last_pressed"),
|
|
1561
|
+
value: new Date(slice.lastPressedAt).toISOString()
|
|
1562
|
+
});
|
|
1563
|
+
return values;
|
|
1564
|
+
}
|
|
1565
|
+
/**
|
|
1566
|
+
* One audio window → the audio entities.
|
|
1567
|
+
*
|
|
1568
|
+
* This is the source the catalog's `audio_detected` was always missing.
|
|
1569
|
+
* It had only the track lifecycle, which rarely carries an audio macro, so
|
|
1570
|
+
* the entity existed and effectively never moved — the note in this
|
|
1571
|
+
* package's CLAUDE.md said so without naming the event. The event is
|
|
1572
|
+
* `pipeline.audio-inference-result`, and it carries both halves an
|
|
1573
|
+
* operator asks for: WHAT was heard and HOW LOUD.
|
|
1574
|
+
*
|
|
1575
|
+
* The label is the HIGHEST-CONFIDENCE detection of the window, not the
|
|
1576
|
+
* first: the analyzer's array order is its own business and depending on
|
|
1577
|
+
* it would make the exported label change with an unrelated refactor.
|
|
1578
|
+
*
|
|
1579
|
+
* A window with no detections publishes NO label. It does not publish an
|
|
1580
|
+
* empty string: HA renders that as a valid state, so a quiet minute would
|
|
1581
|
+
* blank the last thing heard instead of leaving it — and "what was that
|
|
1582
|
+
* noise" is asked after the noise has stopped.
|
|
1583
|
+
*
|
|
1584
|
+
* The level is published on EVERY window, silence included. That is the
|
|
1585
|
+
* point of a volume entity: a flat line is a reading, and dBFS is
|
|
1586
|
+
* negative-going (0 = full scale), so no value can be mistaken for one.
|
|
1587
|
+
*/
|
|
1588
|
+
function projectAudioWindow(deviceKey, input) {
|
|
1589
|
+
const values = [];
|
|
1590
|
+
if (input.dbfs !== void 0 && Number.isFinite(input.dbfs)) values.push({
|
|
1591
|
+
topic: stateTopic(deviceKey, "audio_volume"),
|
|
1592
|
+
value: String(Math.round(input.dbfs * 10) / 10)
|
|
1593
|
+
});
|
|
1594
|
+
if (input.rms !== void 0 && Number.isFinite(input.rms)) values.push({
|
|
1595
|
+
topic: stateTopic(deviceKey, "audio_level_rms"),
|
|
1596
|
+
value: String(Math.round(input.rms * 1e4) / 1e4)
|
|
1597
|
+
});
|
|
1598
|
+
const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
|
|
1599
|
+
if (best !== null) values.push({
|
|
1600
|
+
topic: stateTopic(deviceKey, "audio_last_sound"),
|
|
1601
|
+
value: best.className
|
|
1602
|
+
});
|
|
1603
|
+
return values;
|
|
1604
|
+
}
|
|
1605
|
+
//#endregion
|
|
1606
|
+
//#region src/ha-export/synthetic-devices.ts
|
|
1607
|
+
/**
|
|
1608
|
+
* Synthetic devices — what belongs to the SERVER, exported as devices that
|
|
1609
|
+
* exist inside Home Assistant and nowhere else.
|
|
1610
|
+
*
|
|
1611
|
+
* They are **not camstack devices**, deliberately (operator, 2026-08-04 and
|
|
1612
|
+
* again 2026-08-09). They carry rule switches, cluster monitoring and addon
|
|
1613
|
+
* health, none of which is a device in camstack's model; putting them in the
|
|
1614
|
+
* device list would fill it with things that are not devices. So they are built
|
|
1615
|
+
* HERE, by the exporter, and they have no `deviceId`.
|
|
1616
|
+
*
|
|
1617
|
+
* A LIST, not two special cases. The spec is explicit that the concept is
|
|
1618
|
+
* repeatable and more will be added when something needs a home — a one-off
|
|
1619
|
+
* would have to be refactored the first time a third is wanted. Adding one is
|
|
1620
|
+
* adding an entry to {@link SYNTHETIC_DEVICES} plus its projection.
|
|
1621
|
+
*
|
|
1622
|
+
* **Their values are RECONCILE-ONLY, on purpose.** Cluster CPU, memory, addon
|
|
1623
|
+
* counts and rule state are polled statistics, not events: there is no
|
|
1624
|
+
* `nodes.cpu-changed` to subscribe to, and inventing one to make this look
|
|
1625
|
+
* event-driven would be a second authority for numbers the topology already
|
|
1626
|
+
* owns. They refresh on the reconcile cadence (`reconcileIntervalSec`, 300s by
|
|
1627
|
+
* default), which is also what makes them self-healing after a Home Assistant
|
|
1628
|
+
* restart.
|
|
1629
|
+
*
|
|
1630
|
+
* The identity rules are the same wire format as every other device
|
|
1631
|
+
* (`../CLAUDE.md`): `device_id` is `camstack-<stableId>` and `unique_id` is
|
|
1632
|
+
* `<stableId>_<entity>`. The stable ids here are prefixed `synthetic-` so they
|
|
1633
|
+
* can never collide with a real device's, whatever a provider mints.
|
|
1634
|
+
*/
|
|
1635
|
+
/** The stable id of the notification-centre device. A WIRE FORMAT. */
|
|
1636
|
+
var NOTIFICATION_CENTER_STABLE_ID = "synthetic-notification-center";
|
|
1637
|
+
/** The stable id of the server device. A WIRE FORMAT. */
|
|
1638
|
+
var SERVER_STABLE_ID = "synthetic-server";
|
|
1639
|
+
var SYNTHETIC_STABLE_IDS = [NOTIFICATION_CENTER_STABLE_ID, SERVER_STABLE_ID];
|
|
1640
|
+
/** Is this device key one of ours? Used by the command route. */
|
|
1641
|
+
function isSyntheticDeviceKey(deviceKey) {
|
|
1642
|
+
return SYNTHETIC_STABLE_IDS.some((id) => deviceKeyFor(id) === deviceKey);
|
|
1643
|
+
}
|
|
1644
|
+
/**
|
|
1645
|
+
* Everything a synthetic device exports arrives ENABLED.
|
|
1646
|
+
*
|
|
1647
|
+
* The pressure valve exists for the camera fan-out — three zones is ~73
|
|
1648
|
+
* entities and the fleet is ~880. There is no fan-out here: the largest of
|
|
1649
|
+
* these is one switch per notification rule, and a rule the operator wrote is
|
|
1650
|
+
* a rule they want to reach. Shipping them disabled would repeat the bug that
|
|
1651
|
+
* exported a thermometer's temperature switched off.
|
|
1652
|
+
*/
|
|
1653
|
+
function buildComponents(stableId, specs) {
|
|
1654
|
+
const deviceKey = deviceKeyFor(stableId);
|
|
1655
|
+
const cmps = {};
|
|
1656
|
+
for (const spec of specs) cmps[toComponentKey(spec.platform, spec.entity)] = {
|
|
1657
|
+
platform: spec.platform,
|
|
1658
|
+
unique_id: `${stableId}_${spec.entity}`,
|
|
1659
|
+
name: spec.label,
|
|
1660
|
+
...spec.platform === "button" ? {} : { state_topic: stateTopic(deviceKey, spec.entity) },
|
|
1661
|
+
...spec.writable === true ? { command_topic: commandTopic(deviceKey, spec.entity) } : {},
|
|
1662
|
+
...spec.deviceClass !== void 0 ? { device_class: spec.deviceClass } : {},
|
|
1663
|
+
...spec.unit !== void 0 ? { unit_of_measurement: spec.unit } : {},
|
|
1664
|
+
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
1665
|
+
...spec.options !== void 0 ? { options: spec.options } : {},
|
|
1666
|
+
...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
|
|
1667
|
+
...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
|
|
1668
|
+
payload_on: "true",
|
|
1669
|
+
payload_off: "false"
|
|
1670
|
+
} : {}
|
|
1671
|
+
};
|
|
1672
|
+
return cmps;
|
|
1673
|
+
}
|
|
1674
|
+
function plan(stableId, name, specs) {
|
|
1675
|
+
return {
|
|
1676
|
+
deviceKey: deviceKeyFor(stableId),
|
|
1677
|
+
deviceId: null,
|
|
1678
|
+
dev: {
|
|
1679
|
+
ids: [deviceKeyFor(stableId)],
|
|
1680
|
+
name,
|
|
1681
|
+
mf: "CamStack",
|
|
1682
|
+
mdl: "Synthetic"
|
|
1683
|
+
},
|
|
1684
|
+
cmps: buildComponents(stableId, specs)
|
|
1685
|
+
};
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* The rule switch entity for a rule id.
|
|
1689
|
+
*
|
|
1690
|
+
* Slugged, because a rule id is free text and an entity segment is not. The
|
|
1691
|
+
* `unique_id` is derived from the rule ID and never its NAME, so a renamed rule
|
|
1692
|
+
* keeps every automation pointed at it — the friendly name changes in Home
|
|
1693
|
+
* Assistant and nothing else does.
|
|
1694
|
+
*/
|
|
1695
|
+
function ruleEntity(ruleId) {
|
|
1696
|
+
return `rule_${toSlug(ruleId)}`;
|
|
1697
|
+
}
|
|
1698
|
+
/** `rule_<slug>` → the rule id it came from, or null. */
|
|
1699
|
+
function ruleIdFromEntity(entity, rules) {
|
|
1700
|
+
return rules.find((rule) => ruleEntity(rule.id) === entity)?.id ?? null;
|
|
1701
|
+
}
|
|
1702
|
+
function notificationCenterSpecs(input) {
|
|
1703
|
+
return [
|
|
1704
|
+
{
|
|
1705
|
+
entity: "snoozed",
|
|
1706
|
+
platform: "binary_sensor",
|
|
1707
|
+
label: "Notifications snoozed",
|
|
1708
|
+
icon: "mdi:bell-sleep"
|
|
1709
|
+
},
|
|
1710
|
+
{
|
|
1711
|
+
entity: "rules_enabled",
|
|
1712
|
+
platform: "sensor",
|
|
1713
|
+
label: "Rules enabled",
|
|
1714
|
+
icon: "mdi:counter",
|
|
1715
|
+
entityCategory: "diagnostic"
|
|
1716
|
+
},
|
|
1717
|
+
...input.rules.map((rule) => ({
|
|
1718
|
+
entity: ruleEntity(rule.id),
|
|
1719
|
+
platform: "switch",
|
|
1720
|
+
label: rule.name,
|
|
1721
|
+
writable: true,
|
|
1722
|
+
icon: "mdi:bell-cog"
|
|
1723
|
+
}))
|
|
1724
|
+
];
|
|
1725
|
+
}
|
|
1726
|
+
function serverSpecs(input) {
|
|
1727
|
+
return [
|
|
1728
|
+
{
|
|
1729
|
+
entity: "cluster_healthy",
|
|
1730
|
+
platform: "binary_sensor",
|
|
1731
|
+
label: "Cluster healthy",
|
|
1732
|
+
icon: "mdi:server-network"
|
|
1733
|
+
},
|
|
1734
|
+
{
|
|
1735
|
+
entity: "nodes_online",
|
|
1736
|
+
platform: "sensor",
|
|
1737
|
+
label: "Nodes online",
|
|
1738
|
+
icon: "mdi:server"
|
|
1739
|
+
},
|
|
1740
|
+
{
|
|
1741
|
+
entity: "nodes_total",
|
|
1742
|
+
platform: "sensor",
|
|
1743
|
+
label: "Nodes total",
|
|
1744
|
+
icon: "mdi:server"
|
|
1745
|
+
},
|
|
1746
|
+
{
|
|
1747
|
+
entity: "addons_healthy",
|
|
1748
|
+
platform: "binary_sensor",
|
|
1749
|
+
label: "Addons healthy",
|
|
1750
|
+
icon: "mdi:puzzle-check"
|
|
1751
|
+
},
|
|
1752
|
+
{
|
|
1753
|
+
entity: "addons_running",
|
|
1754
|
+
platform: "sensor",
|
|
1755
|
+
label: "Addons running",
|
|
1756
|
+
icon: "mdi:puzzle"
|
|
1757
|
+
},
|
|
1758
|
+
{
|
|
1759
|
+
entity: "addons_failed",
|
|
1760
|
+
platform: "sensor",
|
|
1761
|
+
label: "Addons failed",
|
|
1762
|
+
icon: "mdi:puzzle-remove"
|
|
1763
|
+
},
|
|
1764
|
+
{
|
|
1765
|
+
entity: "server_version",
|
|
1766
|
+
platform: "sensor",
|
|
1767
|
+
label: "Server version",
|
|
1768
|
+
icon: "mdi:tag",
|
|
1769
|
+
entityCategory: "diagnostic"
|
|
1770
|
+
},
|
|
1771
|
+
{
|
|
1772
|
+
entity: "update_available",
|
|
1773
|
+
platform: "binary_sensor",
|
|
1774
|
+
label: "Update available",
|
|
1775
|
+
deviceClass: "update",
|
|
1776
|
+
entityCategory: "diagnostic"
|
|
1777
|
+
},
|
|
1778
|
+
...input.nodes.flatMap((node) => {
|
|
1779
|
+
const slug = toSlug(node.id);
|
|
1780
|
+
return [
|
|
1781
|
+
{
|
|
1782
|
+
entity: `${slug}_online`,
|
|
1783
|
+
platform: "binary_sensor",
|
|
1784
|
+
label: `${node.name} online`,
|
|
1785
|
+
deviceClass: "connectivity"
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
entity: `${slug}_cpu`,
|
|
1789
|
+
platform: "sensor",
|
|
1790
|
+
label: `${node.name} CPU`,
|
|
1791
|
+
unit: "%",
|
|
1792
|
+
icon: "mdi:cpu-64-bit"
|
|
1793
|
+
},
|
|
1794
|
+
{
|
|
1795
|
+
entity: `${slug}_memory`,
|
|
1796
|
+
platform: "sensor",
|
|
1797
|
+
label: `${node.name} memory`,
|
|
1798
|
+
unit: "%",
|
|
1799
|
+
icon: "mdi:memory"
|
|
1800
|
+
},
|
|
1801
|
+
{
|
|
1802
|
+
entity: `${slug}_uptime`,
|
|
1803
|
+
platform: "sensor",
|
|
1804
|
+
label: `${node.name} uptime`,
|
|
1805
|
+
deviceClass: "timestamp"
|
|
1806
|
+
}
|
|
1807
|
+
];
|
|
1808
|
+
})
|
|
1809
|
+
];
|
|
1810
|
+
}
|
|
1811
|
+
/** Every synthetic device, in declaration order. */
|
|
1812
|
+
function syntheticPlans(input) {
|
|
1813
|
+
return [plan(NOTIFICATION_CENTER_STABLE_ID, "Notification Center", notificationCenterSpecs(input)), plan(SERVER_STABLE_ID, "CamStack Server", serverSpecs(input))];
|
|
1814
|
+
}
|
|
1815
|
+
/**
|
|
1816
|
+
* Every synthetic value, for the reconcile to push.
|
|
1817
|
+
*
|
|
1818
|
+
* Uptime is published as an ISO timestamp of the BOOT INSTANT, not as a
|
|
1819
|
+
* seconds counter: HA's `timestamp` device class renders "3 days ago" and keeps
|
|
1820
|
+
* doing so between reconciles, whereas a seconds number would sit frozen at
|
|
1821
|
+
* whatever it was 300 seconds ago and read as a stalled machine.
|
|
1822
|
+
*/
|
|
1823
|
+
function projectSynthetic(input, nowMs) {
|
|
1824
|
+
const nc = deviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
1825
|
+
const srv = deviceKeyFor(SERVER_STABLE_ID);
|
|
1826
|
+
const online = input.nodes.filter((node) => node.online);
|
|
1827
|
+
const values = [
|
|
1828
|
+
{
|
|
1829
|
+
topic: stateTopic(nc, "snoozed"),
|
|
1830
|
+
value: String(input.snoozed)
|
|
1831
|
+
},
|
|
1832
|
+
{
|
|
1833
|
+
topic: stateTopic(nc, "rules_enabled"),
|
|
1834
|
+
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
1835
|
+
},
|
|
1836
|
+
...input.rules.map((rule) => ({
|
|
1837
|
+
topic: stateTopic(nc, ruleEntity(rule.id)),
|
|
1838
|
+
value: String(rule.enabled)
|
|
1839
|
+
})),
|
|
1840
|
+
{
|
|
1841
|
+
topic: stateTopic(srv, "cluster_healthy"),
|
|
1842
|
+
value: String(online.length === input.nodes.length)
|
|
1843
|
+
},
|
|
1844
|
+
{
|
|
1845
|
+
topic: stateTopic(srv, "nodes_online"),
|
|
1846
|
+
value: String(online.length)
|
|
1847
|
+
},
|
|
1848
|
+
{
|
|
1849
|
+
topic: stateTopic(srv, "nodes_total"),
|
|
1850
|
+
value: String(input.nodes.length)
|
|
1851
|
+
},
|
|
1852
|
+
{
|
|
1853
|
+
topic: stateTopic(srv, "addons_healthy"),
|
|
1854
|
+
value: String(input.addonsFailed === 0)
|
|
1855
|
+
},
|
|
1856
|
+
{
|
|
1857
|
+
topic: stateTopic(srv, "addons_running"),
|
|
1858
|
+
value: String(input.addonsRunning)
|
|
1859
|
+
},
|
|
1860
|
+
{
|
|
1861
|
+
topic: stateTopic(srv, "addons_failed"),
|
|
1862
|
+
value: String(input.addonsFailed)
|
|
1863
|
+
},
|
|
1864
|
+
{
|
|
1865
|
+
topic: stateTopic(srv, "server_version"),
|
|
1866
|
+
value: input.serverVersion
|
|
1867
|
+
},
|
|
1868
|
+
{
|
|
1869
|
+
topic: stateTopic(srv, "update_available"),
|
|
1870
|
+
value: String(input.updateAvailable)
|
|
1871
|
+
}
|
|
1872
|
+
];
|
|
1873
|
+
for (const node of input.nodes) {
|
|
1874
|
+
const slug = toSlug(node.id);
|
|
1875
|
+
values.push({
|
|
1876
|
+
topic: stateTopic(srv, `${slug}_online`),
|
|
1877
|
+
value: String(node.online)
|
|
1878
|
+
}, {
|
|
1879
|
+
topic: stateTopic(srv, `${slug}_cpu`),
|
|
1880
|
+
value: String(Math.round(node.cpuPercent))
|
|
1881
|
+
}, {
|
|
1882
|
+
topic: stateTopic(srv, `${slug}_memory`),
|
|
1883
|
+
value: String(Math.round(node.memoryPercent))
|
|
1884
|
+
});
|
|
1885
|
+
if (node.online && node.uptime > 0) values.push({
|
|
1886
|
+
topic: stateTopic(srv, `${slug}_uptime`),
|
|
1887
|
+
value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
|
|
1888
|
+
});
|
|
1889
|
+
}
|
|
1890
|
+
return values;
|
|
1891
|
+
}
|
|
1374
1892
|
/** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
|
|
1375
1893
|
var HA_BROKER_KIND = "home-assistant";
|
|
1376
1894
|
/**
|
|
@@ -1425,6 +1943,8 @@ var ADDON_ID = "homeassistant-export";
|
|
|
1425
1943
|
/** Where the custom component registers its push view inside HA. */
|
|
1426
1944
|
var PUSH_PATH = "/api/camstack/push";
|
|
1427
1945
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
1946
|
+
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
1947
|
+
var DOORBELL_PULSE_MS = 5e3;
|
|
1428
1948
|
/** The hub's API port — where the data plane is reverse-proxied. */
|
|
1429
1949
|
var HUB_API_PORT = 4443;
|
|
1430
1950
|
/** One PTZ button press, as a relative move. */
|
|
@@ -1456,8 +1976,18 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1456
1976
|
reconcilePending = null;
|
|
1457
1977
|
lastError;
|
|
1458
1978
|
entityCount = 0;
|
|
1979
|
+
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
1980
|
+
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
1981
|
+
/** deviceId → the pending OFF edge. HA releases nothing on its own. */
|
|
1982
|
+
doorbellReleaseTimers = /* @__PURE__ */ new Map();
|
|
1459
1983
|
unclassified = [];
|
|
1460
1984
|
/**
|
|
1985
|
+
* The rules as of the last reconcile, so a command arriving on
|
|
1986
|
+
* `rule_<slug>` can be turned back into the rule id it came from. Refreshed
|
|
1987
|
+
* whenever the synthetic devices are, never written to.
|
|
1988
|
+
*/
|
|
1989
|
+
syntheticRules = [];
|
|
1990
|
+
/**
|
|
1461
1991
|
* A link that returned repairs NOW, not at the next periodic pass.
|
|
1462
1992
|
*
|
|
1463
1993
|
* `PushClient` drops its dedup cache the moment Home Assistant answers
|
|
@@ -1498,6 +2028,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1498
2028
|
this.startTimer();
|
|
1499
2029
|
this.ctx.addDisposer(async () => {
|
|
1500
2030
|
this.stopTimer();
|
|
2031
|
+
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
2032
|
+
this.doorbellReleaseTimers.clear();
|
|
1501
2033
|
this.reachability.dispose();
|
|
1502
2034
|
for (const link of this.links.values()) await link.client.dispose();
|
|
1503
2035
|
this.links.clear();
|
|
@@ -1554,6 +2086,15 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1554
2086
|
this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
1555
2087
|
this.onMotion(event.data);
|
|
1556
2088
|
});
|
|
2089
|
+
/**
|
|
2090
|
+
* The audio window. This is the source `audio_detected` never had: it was
|
|
2091
|
+
* fed only by the track lifecycle, which rarely carries an audio macro, so
|
|
2092
|
+
* the entity existed and effectively never moved. It also carries the
|
|
2093
|
+
* window LEVEL, which nothing else on the export path does.
|
|
2094
|
+
*/
|
|
2095
|
+
this.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
2096
|
+
this.onAudioWindow(event.data);
|
|
2097
|
+
});
|
|
1557
2098
|
this.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
1558
2099
|
this.onTrackLifecycle(event.data);
|
|
1559
2100
|
});
|
|
@@ -1601,6 +2142,40 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1601
2142
|
}));
|
|
1602
2143
|
return;
|
|
1603
2144
|
}
|
|
2145
|
+
if (capName === "doorbell") {
|
|
2146
|
+
/**
|
|
2147
|
+
* A press is a PULSE — the slice carries `lastPressedAt`, never a
|
|
2148
|
+
* boolean. `doorbell_pressed` is therefore ON only when THIS delivery
|
|
2149
|
+
* advanced the timestamp, and the previous value is remembered per
|
|
2150
|
+
* device rather than compared against the clock: a reconcile re-read of
|
|
2151
|
+
* an unchanged slice must not ring the doorbell again in Home Assistant,
|
|
2152
|
+
* and a press that arrives while the export was restarting must still
|
|
2153
|
+
* ring once. HA auto-releases nothing, so the OFF edge is published on a
|
|
2154
|
+
* short timer — without it the binary sensor latches on for ever and the
|
|
2155
|
+
* next press produces no edge to automate on.
|
|
2156
|
+
*/
|
|
2157
|
+
const lastPressedAt = readNumber(slice, "lastPressedAt");
|
|
2158
|
+
const pressCountSinceStart = readNumber(slice, "pressCountSinceStart") ?? 0;
|
|
2159
|
+
const previous = this.lastDoorbellPressAt.get(deviceId) ?? null;
|
|
2160
|
+
const pressed = lastPressedAt !== null && lastPressedAt !== previous;
|
|
2161
|
+
if (pressed) this.lastDoorbellPressAt.set(deviceId, lastPressedAt);
|
|
2162
|
+
this.push(deviceId, projectDoorbellSlice(deviceKey, {
|
|
2163
|
+
lastPressedAt,
|
|
2164
|
+
pressCountSinceStart
|
|
2165
|
+
}, pressed));
|
|
2166
|
+
if (pressed) {
|
|
2167
|
+
const existing = this.doorbellReleaseTimers.get(deviceId);
|
|
2168
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
2169
|
+
this.doorbellReleaseTimers.set(deviceId, setTimeout(() => {
|
|
2170
|
+
this.doorbellReleaseTimers.delete(deviceId);
|
|
2171
|
+
this.push(deviceId, projectDoorbellSlice(deviceKey, {
|
|
2172
|
+
lastPressedAt,
|
|
2173
|
+
pressCountSinceStart
|
|
2174
|
+
}, false));
|
|
2175
|
+
}, DOORBELL_PULSE_MS));
|
|
2176
|
+
}
|
|
2177
|
+
return;
|
|
2178
|
+
}
|
|
1604
2179
|
if (capName === "motion") {
|
|
1605
2180
|
const detected = slice["detected"];
|
|
1606
2181
|
const lastDetectedAt = readNumber(slice, "lastDetectedAt");
|
|
@@ -1673,6 +2248,54 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1673
2248
|
}
|
|
1674
2249
|
this.push(deviceId, values);
|
|
1675
2250
|
}
|
|
2251
|
+
onAudioWindow(data) {
|
|
2252
|
+
const deviceId = readNumber(data, "deviceId");
|
|
2253
|
+
if (deviceId === null) return;
|
|
2254
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
2255
|
+
if (deviceKey === void 0) return;
|
|
2256
|
+
const frame = readRecordField(data, "frame");
|
|
2257
|
+
if (frame === null || typeof frame !== "object" || Array.isArray(frame)) return;
|
|
2258
|
+
const level = readRecord(frame, "level");
|
|
2259
|
+
const dbfs = readNumber(level, "dbfs");
|
|
2260
|
+
const rms = readNumber(level, "rms");
|
|
2261
|
+
const raw = readRecordField(frame, "detections");
|
|
2262
|
+
/**
|
|
2263
|
+
* An `AudioDetection` carries `macroClass` + `score` and its real sound
|
|
2264
|
+
* class in `labels: ScoredLabel[]` — the enrichment chain. It has NO
|
|
2265
|
+
* `className` and no `confidence`; reading those invented names mapped
|
|
2266
|
+
* every window to `[]`, so `audio_last_sound` sat at `unknown` for ever
|
|
2267
|
+
* while the analyzer was classifying happily (found 2026-08-09, symptom:
|
|
2268
|
+
* "le audio labels sembrano non arrivare").
|
|
2269
|
+
*
|
|
2270
|
+
* The best LABEL wins over the macro: `audio` as a value tells an operator
|
|
2271
|
+
* nothing, `dog` or `crying` is the whole point. Falls back to the macro
|
|
2272
|
+
* class only when a detection carries no label at all.
|
|
2273
|
+
*/
|
|
2274
|
+
const detections = Array.isArray(raw) ? raw.flatMap((entry) => {
|
|
2275
|
+
const labels = readRecordField(entry, "labels");
|
|
2276
|
+
const best = Array.isArray(labels) ? labels.reduce((top, l) => {
|
|
2277
|
+
const label = readString(l, "label");
|
|
2278
|
+
const score = readNumber(l, "score") ?? 0;
|
|
2279
|
+
if (label === null) return top;
|
|
2280
|
+
return top === null || score > top.confidence ? {
|
|
2281
|
+
className: label,
|
|
2282
|
+
confidence: score
|
|
2283
|
+
} : top;
|
|
2284
|
+
}, null) : null;
|
|
2285
|
+
if (best !== null) return [best];
|
|
2286
|
+
const macro = readString(entry, "macroClass");
|
|
2287
|
+
const score = readNumber(entry, "score");
|
|
2288
|
+
return macro === null || score === null ? [] : [{
|
|
2289
|
+
className: macro,
|
|
2290
|
+
confidence: score
|
|
2291
|
+
}];
|
|
2292
|
+
}) : [];
|
|
2293
|
+
this.push(deviceId, projectAudioWindow(deviceKey, {
|
|
2294
|
+
detections,
|
|
2295
|
+
...dbfs !== null ? { dbfs } : {},
|
|
2296
|
+
...rms !== null ? { rms } : {}
|
|
2297
|
+
}));
|
|
2298
|
+
}
|
|
1676
2299
|
onZoneOccupancy(data) {
|
|
1677
2300
|
const deviceId = readNumber(data, "deviceId");
|
|
1678
2301
|
if (deviceId === null) return;
|
|
@@ -1686,11 +2309,20 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1686
2309
|
if (zoneId === null) return [];
|
|
1687
2310
|
return [{
|
|
1688
2311
|
zoneId,
|
|
2312
|
+
totalObjects: readNumber(entry, "totalObjects") ?? 0,
|
|
1689
2313
|
byClass: toCountRecord(byClass)
|
|
1690
2314
|
}];
|
|
1691
2315
|
});
|
|
1692
|
-
|
|
1693
|
-
|
|
2316
|
+
const frame = readRecordField(data, "frame");
|
|
2317
|
+
const scene = frame !== null && typeof frame === "object" && !Array.isArray(frame) ? {
|
|
2318
|
+
totalObjects: readNumber(frame, "totalObjects") ?? 0,
|
|
2319
|
+
byClass: toCountRecord(readRecord(frame, "byClass"))
|
|
2320
|
+
} : void 0;
|
|
2321
|
+
if (zones.length === 0 && scene === void 0) return;
|
|
2322
|
+
this.push(deviceId, projectZoneOccupancy(deviceKey, {
|
|
2323
|
+
zones,
|
|
2324
|
+
...scene !== void 0 ? { scene } : {}
|
|
2325
|
+
}));
|
|
1694
2326
|
}
|
|
1695
2327
|
/**
|
|
1696
2328
|
* Send a device's values to every broker it is exported to.
|
|
@@ -1819,12 +2451,35 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1819
2451
|
}
|
|
1820
2452
|
});
|
|
1821
2453
|
}
|
|
2454
|
+
/**
|
|
2455
|
+
* The synthetic devices — server-owned surfaces that are not camstack
|
|
2456
|
+
* devices. They are appended to the SAME `exported` map so they travel
|
|
2457
|
+
* the ordinary announce + push path: one code path for identity,
|
|
2458
|
+
* batching and dedup, rather than a parallel one that would drift.
|
|
2459
|
+
* `brokerIds` is every enabled broker, because they belong to the server
|
|
2460
|
+
* and not to a membership the operator picks per camera.
|
|
2461
|
+
*/
|
|
2462
|
+
const synthetic = await this.buildSynthetic();
|
|
2463
|
+
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
2464
|
+
exported.set(plan.deviceKey, {
|
|
2465
|
+
plan,
|
|
2466
|
+
deviceId: -1,
|
|
2467
|
+
target: {
|
|
2468
|
+
deviceId: -1,
|
|
2469
|
+
type: "synthetic",
|
|
2470
|
+
boundCaps: []
|
|
2471
|
+
},
|
|
2472
|
+
brokerIds: [...enabled]
|
|
2473
|
+
});
|
|
2474
|
+
entityCount += Object.keys(plan.cmps).length;
|
|
2475
|
+
}
|
|
1822
2476
|
this.exported = exported;
|
|
1823
2477
|
this.keyByDeviceId = keyByDeviceId;
|
|
1824
2478
|
this.entityCount = entityCount;
|
|
1825
2479
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
1826
2480
|
await this.announce(exported);
|
|
1827
2481
|
await this.pushFullState(exported, snapshots);
|
|
2482
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
1828
2483
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
1829
2484
|
reason,
|
|
1830
2485
|
brokers: enabled.length,
|
|
@@ -1847,6 +2502,69 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1847
2502
|
* that device only and can never take the loop — or the runner — down
|
|
1848
2503
|
* with it.
|
|
1849
2504
|
*/
|
|
2505
|
+
/**
|
|
2506
|
+
* Gather the synthetic devices' statistics.
|
|
2507
|
+
*
|
|
2508
|
+
* Best-effort per source: one API that does not answer costs its own
|
|
2509
|
+
* entities for this pass, never the whole set — a hub whose
|
|
2510
|
+
* `notificationRules` provider is restarting must still export its cluster
|
|
2511
|
+
* health, which is exactly when an operator wants it.
|
|
2512
|
+
*
|
|
2513
|
+
* Returns `null` only when NOTHING answered, so the devices are not
|
|
2514
|
+
* announced empty on a hub that is still booting.
|
|
2515
|
+
*/
|
|
2516
|
+
async buildSynthetic() {
|
|
2517
|
+
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
2518
|
+
id: rule.id,
|
|
2519
|
+
name: rule.name,
|
|
2520
|
+
enabled: rule.enabled
|
|
2521
|
+
}))).catch((err) => {
|
|
2522
|
+
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
2523
|
+
return null;
|
|
2524
|
+
});
|
|
2525
|
+
const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes.length > 0).catch(() => null);
|
|
2526
|
+
const nodes = await this.ctx.api.nodes.topology.query().then((list) => list.map((node) => ({
|
|
2527
|
+
id: node.id,
|
|
2528
|
+
name: node.name,
|
|
2529
|
+
online: node.isOnline,
|
|
2530
|
+
cpuPercent: node.cpuPercent,
|
|
2531
|
+
memoryPercent: node.memoryPercent,
|
|
2532
|
+
uptime: node.uptime
|
|
2533
|
+
}))).catch((err) => {
|
|
2534
|
+
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
2535
|
+
return null;
|
|
2536
|
+
});
|
|
2537
|
+
const addons = await this.ctx.api.addons.list.query().then((list) => {
|
|
2538
|
+
let running = 0;
|
|
2539
|
+
let failed = 0;
|
|
2540
|
+
for (const addon of list) if (addon.health?.phase === "failed") failed += 1;
|
|
2541
|
+
else running += 1;
|
|
2542
|
+
return {
|
|
2543
|
+
running,
|
|
2544
|
+
failed
|
|
2545
|
+
};
|
|
2546
|
+
}).catch(() => null);
|
|
2547
|
+
const server = await this.ctx.api.serverManagement.getServerPackageStatus.query().catch(() => null);
|
|
2548
|
+
if (rules === null && nodes === null && addons === null && server === null) return null;
|
|
2549
|
+
this.syntheticRules = rules ?? this.syntheticRules;
|
|
2550
|
+
return {
|
|
2551
|
+
rules: rules ?? this.syntheticRules,
|
|
2552
|
+
snoozed: snoozes ?? false,
|
|
2553
|
+
nodes: nodes ?? [],
|
|
2554
|
+
addonsRunning: addons?.running ?? 0,
|
|
2555
|
+
addonsFailed: addons?.failed ?? 0,
|
|
2556
|
+
serverVersion: server?.runningVersion ?? server?.activeVersion ?? "unknown",
|
|
2557
|
+
updateAvailable: server?.updateAvailable ?? false
|
|
2558
|
+
};
|
|
2559
|
+
}
|
|
2560
|
+
/** Push the synthetic values to every enabled broker. */
|
|
2561
|
+
async pushSynthetic(values, brokerIds) {
|
|
2562
|
+
for (const brokerId of brokerIds) {
|
|
2563
|
+
const link = this.links.get(brokerId);
|
|
2564
|
+
if (link === void 0) continue;
|
|
2565
|
+
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
2566
|
+
}
|
|
2567
|
+
}
|
|
1850
2568
|
async announce(exported) {
|
|
1851
2569
|
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
1852
2570
|
const link = this.links.get(brokerId);
|
|
@@ -1907,10 +2625,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1907
2625
|
enabled: sw.enabled
|
|
1908
2626
|
}))));
|
|
1909
2627
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
1910
|
-
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
1911
|
-
|
|
1912
|
-
|
|
1913
|
-
|
|
2628
|
+
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
2629
|
+
zones: occupancy.zones.map((zone) => ({
|
|
2630
|
+
zoneId: zone.zoneId,
|
|
2631
|
+
totalObjects: zone.totalObjects,
|
|
2632
|
+
byClass: toCountRecord(zone.byClass)
|
|
2633
|
+
})),
|
|
2634
|
+
scene: {
|
|
2635
|
+
totalObjects: occupancy.frame.totalObjects,
|
|
2636
|
+
byClass: toCountRecord(occupancy.frame.byClass)
|
|
2637
|
+
}
|
|
2638
|
+
}));
|
|
1914
2639
|
}
|
|
1915
2640
|
return values;
|
|
1916
2641
|
}
|
|
@@ -2014,7 +2739,26 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2014
2739
|
id: broker.id,
|
|
2015
2740
|
name: broker.name
|
|
2016
2741
|
}));
|
|
2017
|
-
|
|
2742
|
+
/**
|
|
2743
|
+
* NEVER prune on an empty enumeration.
|
|
2744
|
+
*
|
|
2745
|
+
* `broker.list` is a fan-out union, so it answers `[]` for the whole window
|
|
2746
|
+
* in which `provider-homeassistant` has not finished registering — and it
|
|
2747
|
+
* registers its caps only at the END of an `initialize()` that restores 239
|
|
2748
|
+
* devices. An empty list there means "I do not know yet", never "the
|
|
2749
|
+
* operator deleted their instance", and the two were indistinguishable to
|
|
2750
|
+
* this code: every boot pruned the membership to nothing and PERSISTED it,
|
|
2751
|
+
* so the operator's exported cameras silently switched themselves off. Seen
|
|
2752
|
+
* live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
|
|
2753
|
+
* `devices=2` and both switches back to `false` with no line anywhere.
|
|
2754
|
+
*
|
|
2755
|
+
* This is D49 exactly: a destructive step gated on a fallible read must not
|
|
2756
|
+
* fire on the reading that destroys work. A broker that really was deleted
|
|
2757
|
+
* is pruned on the next pass that returns a NON-empty list without it,
|
|
2758
|
+
* which costs one interval and cannot cost the operator their config.
|
|
2759
|
+
*/
|
|
2760
|
+
const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
|
|
2761
|
+
if (haBrokers.length === 0 && Object.keys(this.config.membership).length > 0) this.ctx.logger.warn("ha-export: no Home Assistant broker answered — keeping the export membership rather than pruning it", { meta: { brokers: Object.keys(this.config.membership) } });
|
|
2018
2762
|
if (pruned !== this.config.membership || !sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({
|
|
2019
2763
|
knownBrokers: known,
|
|
2020
2764
|
membership: pruned
|
|
@@ -2137,6 +2881,49 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2137
2881
|
reply.send({ error: "device not exported" });
|
|
2138
2882
|
return;
|
|
2139
2883
|
}
|
|
2884
|
+
/**
|
|
2885
|
+
* A synthetic device has no camstack device behind it, so
|
|
2886
|
+
* `resolveCommand` — which routes by device type and bound caps — cannot
|
|
2887
|
+
* speak for it. Its commands go straight to the authority that already
|
|
2888
|
+
* owns the function, exactly as a camera switch does: the export writes
|
|
2889
|
+
* `setRuleEnabled`, never a store of its own. A second knob that can
|
|
2890
|
+
* disagree with the admin UI is worse than no knob (D62).
|
|
2891
|
+
*/
|
|
2892
|
+
if (isSyntheticDeviceKey(parsed.deviceKey)) {
|
|
2893
|
+
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
2894
|
+
if (ruleId === null) {
|
|
2895
|
+
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
2896
|
+
topic,
|
|
2897
|
+
entity: parsed.entity
|
|
2898
|
+
} });
|
|
2899
|
+
reply.status(422);
|
|
2900
|
+
reply.send({ error: "unroutable command" });
|
|
2901
|
+
return;
|
|
2902
|
+
}
|
|
2903
|
+
const enabled = value.toLowerCase() === "true";
|
|
2904
|
+
try {
|
|
2905
|
+
await this.ctx.api.notificationRules.setRuleEnabled.mutate({
|
|
2906
|
+
ruleId,
|
|
2907
|
+
enabled
|
|
2908
|
+
});
|
|
2909
|
+
this.ctx.logger.info("ha-export: notification rule toggled from Home Assistant", { meta: {
|
|
2910
|
+
ruleId,
|
|
2911
|
+
enabled
|
|
2912
|
+
} });
|
|
2913
|
+
await this.reconcile("synthetic-command");
|
|
2914
|
+
reply.status(200);
|
|
2915
|
+
reply.send({});
|
|
2916
|
+
} catch (err) {
|
|
2917
|
+
this.ctx.logger.warn("ha-export: could not apply a notification-rule toggle", { meta: {
|
|
2918
|
+
ruleId,
|
|
2919
|
+
enabled,
|
|
2920
|
+
error: errMsg(err)
|
|
2921
|
+
} });
|
|
2922
|
+
reply.status(422);
|
|
2923
|
+
reply.send({ error: "command not applied" });
|
|
2924
|
+
}
|
|
2925
|
+
return;
|
|
2926
|
+
}
|
|
2140
2927
|
const command = resolveCommand(exported.target, parsed.entity, value);
|
|
2141
2928
|
if (command === null) {
|
|
2142
2929
|
this.ctx.logger.warn("ha-export: dropping an unroutable command", {
|
|
@@ -2487,13 +3274,34 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
2487
3274
|
const idStr = String(deviceId);
|
|
2488
3275
|
let next = this.config.membership;
|
|
2489
3276
|
let touched = false;
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2493
|
-
|
|
3277
|
+
/**
|
|
3278
|
+
* Driven by the PATCH KEYS, not by the enabled-broker list.
|
|
3279
|
+
*
|
|
3280
|
+
* The key already carries the broker (`haExport:<deviceId>:<brokerId>`) —
|
|
3281
|
+
* the operator could not have been shown the switch otherwise — so reading
|
|
3282
|
+
* the broker from it needs no other state to be warm. Iterating
|
|
3283
|
+
* `enabledBrokerIds()` instead made this depend on a read-through cache,
|
|
3284
|
+
* and when that cache was cold the loop matched nothing, `touched` stayed
|
|
3285
|
+
* false, and the method returned `{success: true}` HAVING DONE NOTHING.
|
|
3286
|
+
* The admin UI showed the switch flip and it silently did not persist;
|
|
3287
|
+
* measured live on 2026-08-09 with two cameras that would not stay
|
|
3288
|
+
* exported. A no-op that reports success is worse than an error.
|
|
3289
|
+
*/
|
|
3290
|
+
const prefix = `haExport:${deviceId}:`;
|
|
3291
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
3292
|
+
if (!key.startsWith(prefix)) continue;
|
|
3293
|
+
const brokerId = key.slice(prefix.length);
|
|
3294
|
+
if (brokerId.length === 0) continue;
|
|
3295
|
+
next = setExposed(next, brokerId, idStr, value === true);
|
|
2494
3296
|
touched = true;
|
|
2495
3297
|
}
|
|
2496
|
-
if (!touched)
|
|
3298
|
+
if (!touched) {
|
|
3299
|
+
this.ctx.logger.warn("ha-export: a device patch carried no export key — nothing applied", {
|
|
3300
|
+
tags: { deviceId },
|
|
3301
|
+
meta: { keys: Object.keys(patch) }
|
|
3302
|
+
});
|
|
3303
|
+
return { success: true };
|
|
3304
|
+
}
|
|
2497
3305
|
await this.updateGlobalSettings({ membership: next });
|
|
2498
3306
|
this.ctx.logger.info("ha-export: per-broker export membership changed", {
|
|
2499
3307
|
tags: { deviceId },
|