@camstack/addon-provider-homeassistant 1.2.18 → 1.2.20
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 +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/{dist-UfT_fD3-.mjs → dist-BSQpoIVg.mjs} +73 -16
- package/dist/{dist-DuKJzmrJ.js → dist-Bz5ENMVc.js} +73 -16
- package/dist/ha-export/ha-export.addon.js +425 -25
- package/dist/ha-export/ha-export.addon.mjs +425 -25
- package/package.json +2 -3
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-
|
|
1
|
+
import { G as oauthIntegrationCapability, O as deviceExportCapability, bt as string, c as addonRoutesCapability, ct as BaseAddon, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, v as buildAddonRouteProvider, wt as EventCategory } from "../dist-BSQpoIVg.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
//#region src/ha-export/topics.ts
|
|
4
4
|
/**
|
|
@@ -143,6 +143,16 @@ var ZONE_MACROS = [
|
|
|
143
143
|
* So the flag is a required field on every spec. A new entity — or the
|
|
144
144
|
* synthetic devices the design addendum still owes — cannot inherit a
|
|
145
145
|
* camera's pressure valve by accident.
|
|
146
|
+
*
|
|
147
|
+
* **`last_image` is exempt, on every level of the fan-out** (operator,
|
|
148
|
+
* 2026-08-09). The valve exists to stop a fleet's worth of entities
|
|
149
|
+
* arriving switched on, but the picture IS the reason to look at a
|
|
150
|
+
* notification: an operator who wires `person_detected` into an
|
|
151
|
+
* automation wants the frame with it, and having to hunt through HA's
|
|
152
|
+
* disabled-entity list per camera per macro per zone is the opposite of
|
|
153
|
+
* what the export is for. The three `*_last_image` entities — camera,
|
|
154
|
+
* per macro, per zone × macro — therefore ship enabled. They cost one
|
|
155
|
+
* entity each and carry a signed URL, not bytes.
|
|
146
156
|
*/
|
|
147
157
|
/**
|
|
148
158
|
* The snooze surface, as a `select`.
|
|
@@ -213,7 +223,14 @@ function deviceBlock(device) {
|
|
|
213
223
|
*/
|
|
214
224
|
function zoneSpecs(zone) {
|
|
215
225
|
const slug = toSlug(zone.id);
|
|
216
|
-
const specs = [
|
|
226
|
+
const specs = [{
|
|
227
|
+
entity: `${slug}_objects`,
|
|
228
|
+
platform: "sensor",
|
|
229
|
+
label: `${zone.name} objects`,
|
|
230
|
+
uniqueSuffix: `${zone.id}_objects`,
|
|
231
|
+
icon: "mdi:counter",
|
|
232
|
+
enabledByDefault: true
|
|
233
|
+
}];
|
|
217
234
|
for (const macro of ZONE_MACROS) {
|
|
218
235
|
const label = `${zone.name} ${macro}`;
|
|
219
236
|
specs.push({
|
|
@@ -228,7 +245,7 @@ function zoneSpecs(zone) {
|
|
|
228
245
|
platform: "image",
|
|
229
246
|
label: `${label} last image`,
|
|
230
247
|
uniqueSuffix: `${zone.id}_${macro}_last_image`,
|
|
231
|
-
enabledByDefault:
|
|
248
|
+
enabledByDefault: true
|
|
232
249
|
}, {
|
|
233
250
|
entity: `${slug}_${macro}_last_detection`,
|
|
234
251
|
platform: "sensor",
|
|
@@ -270,6 +287,54 @@ function cameraSpecs(device) {
|
|
|
270
287
|
label: "Last image",
|
|
271
288
|
enabledByDefault: true
|
|
272
289
|
},
|
|
290
|
+
(
|
|
291
|
+
/**
|
|
292
|
+
* The SCENE — everything on camera, in a zone or not, and including
|
|
293
|
+
* confirmed stationary objects (they are still there). The frame-wide
|
|
294
|
+
* scope of the same occupancy snapshot the per-zone counters come from,
|
|
295
|
+
* which was computed and simply never projected.
|
|
296
|
+
*/
|
|
297
|
+
{
|
|
298
|
+
entity: "objects",
|
|
299
|
+
platform: "sensor",
|
|
300
|
+
label: "Objects",
|
|
301
|
+
icon: "mdi:counter",
|
|
302
|
+
enabledByDefault: true
|
|
303
|
+
}),
|
|
304
|
+
(
|
|
305
|
+
/**
|
|
306
|
+
* What was heard, and how loud. Fed by `pipeline.audio-inference-result`
|
|
307
|
+
* — the event this addon did not subscribe to at all, which is why
|
|
308
|
+
* `audio_detected` below has effectively never moved.
|
|
309
|
+
*
|
|
310
|
+
* `audio_last_sound`, NOT `audio_last_label`: `<macro>_last_label` is the
|
|
311
|
+
* TRACK path's naming, reserved for the macros that carry an
|
|
312
|
+
* identification (`LABELLED_MACROS`), and `audio` is deliberately not one
|
|
313
|
+
* of them because the track lifecycle has no label for it. Reusing that
|
|
314
|
+
* name would put two sources on one topic — the classifier here and the
|
|
315
|
+
* track projector there — which is the two-knobs failure this repo keeps
|
|
316
|
+
* paying for. Different source, different entity, different name.
|
|
317
|
+
*
|
|
318
|
+
* dBFS is negative-going (0 = full scale), so silence reads as a large
|
|
319
|
+
* negative number rather than as a missing value. There is no unit
|
|
320
|
+
* declared: HA has no dBFS device class, and mislabelling it `dB` would
|
|
321
|
+
* put a plausible wrong unit on a dashboard.
|
|
322
|
+
*/
|
|
323
|
+
{
|
|
324
|
+
entity: "audio_last_sound",
|
|
325
|
+
platform: "sensor",
|
|
326
|
+
label: "Audio last sound",
|
|
327
|
+
icon: "mdi:ear-hearing",
|
|
328
|
+
enabledByDefault: true
|
|
329
|
+
}),
|
|
330
|
+
{
|
|
331
|
+
entity: "audio_volume",
|
|
332
|
+
platform: "sensor",
|
|
333
|
+
label: "Audio level",
|
|
334
|
+
unit: "dBFS",
|
|
335
|
+
icon: "mdi:volume-high",
|
|
336
|
+
enabledByDefault: false
|
|
337
|
+
},
|
|
273
338
|
{
|
|
274
339
|
entity: "last_detection",
|
|
275
340
|
platform: "sensor",
|
|
@@ -345,6 +410,60 @@ function cameraSpecs(device) {
|
|
|
345
410
|
enabledByDefault: true
|
|
346
411
|
});
|
|
347
412
|
}
|
|
413
|
+
/**
|
|
414
|
+
* The doorbell, when the operator has bound the cap to this camera.
|
|
415
|
+
*
|
|
416
|
+
* A press is not a detection macro — it is not something the pipeline
|
|
417
|
+
* classifies — so it is NOT in `EXPORTED_MACROS`, and `MACROS_NOT_EXPORTED`
|
|
418
|
+
* says a doorbell is an actuator on its own device. Both stay true for the
|
|
419
|
+
* BUTTON accessory. What was missing is the camera case: a camera with the
|
|
420
|
+
* `doorbell` cap bound rings, and the operator automates on the ring the same
|
|
421
|
+
* way they automate on `person_detected`. Reolink's own doorbell is exactly
|
|
422
|
+
* this shape (`ReolinkSimpleEvent.type === 'doorbell'` at the camera level),
|
|
423
|
+
* and so is any camera the `virtual-doorbell` wrapper is pointed at.
|
|
424
|
+
*
|
|
425
|
+
* Every entity here is fed by the `doorbell` runtime-state slice
|
|
426
|
+
* (`lastPressedAt`, `pressCountSinceStart`) on `device.state-changed` —
|
|
427
|
+
* the same road the `battery` slice already travels.
|
|
428
|
+
*
|
|
429
|
+
* **`doorbell_last_image` is owed, and the frame for it already exists.**
|
|
430
|
+
* A press DOES have a picture: `sensor-marker-projector.ts` materialises a
|
|
431
|
+
* synthetic marker track carrying a `keyFrameSmall` of the camera, taken
|
|
432
|
+
* before the track is persisted, and the notification names that track as
|
|
433
|
+
* its media owner. What is missing is only the road to THIS addon:
|
|
434
|
+
* `persistSyntheticTrack` writes the row directly and emits no
|
|
435
|
+
* `pipeline-analytics.track-lifecycle`, which is the single event the export
|
|
436
|
+
* listens to for frames. So the export never hears about the marker while
|
|
437
|
+
* the notification centre shows its photo — measured 2026-08-09.
|
|
438
|
+
*
|
|
439
|
+
* It is NOT fixed by taking a second snapshot here at push time: the marker
|
|
440
|
+
* projector's own record argues that out (one snapshot, one owner, one
|
|
441
|
+
* persistence point — the producer's), and a second photograph of a doorway
|
|
442
|
+
* seconds after the ring is a different picture from the one in the
|
|
443
|
+
* notification. The fix belongs upstream, at the point that already owns the
|
|
444
|
+
* frame. Named here rather than shipped as an entity nothing feeds.
|
|
445
|
+
*/
|
|
446
|
+
if (device.boundCaps.includes("doorbell")) specs.push({
|
|
447
|
+
entity: "doorbell_pressed",
|
|
448
|
+
platform: "binary_sensor",
|
|
449
|
+
label: "Doorbell pressed",
|
|
450
|
+
deviceClass: "occupancy",
|
|
451
|
+
enabledByDefault: true
|
|
452
|
+
}, {
|
|
453
|
+
entity: "doorbell_last_pressed",
|
|
454
|
+
platform: "sensor",
|
|
455
|
+
label: "Doorbell last pressed",
|
|
456
|
+
deviceClass: "timestamp",
|
|
457
|
+
icon: "mdi:bell-ring",
|
|
458
|
+
enabledByDefault: true
|
|
459
|
+
}, {
|
|
460
|
+
entity: "doorbell_press_count",
|
|
461
|
+
platform: "sensor",
|
|
462
|
+
label: "Doorbell presses since restart",
|
|
463
|
+
icon: "mdi:counter",
|
|
464
|
+
entityCategory: "diagnostic",
|
|
465
|
+
enabledByDefault: false
|
|
466
|
+
});
|
|
348
467
|
specs.push({
|
|
349
468
|
entity: "snooze",
|
|
350
469
|
platform: "select",
|
|
@@ -365,13 +484,19 @@ function cameraSpecs(device) {
|
|
|
365
484
|
entity: `${macro}_last_image`,
|
|
366
485
|
platform: "image",
|
|
367
486
|
label: `${label} last image`,
|
|
368
|
-
enabledByDefault:
|
|
487
|
+
enabledByDefault: true
|
|
369
488
|
}, {
|
|
370
489
|
entity: `${macro}_last_detection`,
|
|
371
490
|
platform: "sensor",
|
|
372
491
|
label: `${label} last detection`,
|
|
373
492
|
deviceClass: "timestamp",
|
|
374
493
|
enabledByDefault: false
|
|
494
|
+
}, {
|
|
495
|
+
entity: `${macro}_objects`,
|
|
496
|
+
platform: "sensor",
|
|
497
|
+
label: `${label} objects`,
|
|
498
|
+
icon: "mdi:counter",
|
|
499
|
+
enabledByDefault: false
|
|
375
500
|
});
|
|
376
501
|
if (LABELLED_MACROS.includes(macro)) specs.push({
|
|
377
502
|
entity: `${macro}_last_label`,
|
|
@@ -867,9 +992,38 @@ function pruneBrokers(membership, knownBrokerIds) {
|
|
|
867
992
|
return next;
|
|
868
993
|
}
|
|
869
994
|
/**
|
|
870
|
-
*
|
|
871
|
-
*
|
|
872
|
-
*
|
|
995
|
+
* The brokers the operator has switched ON, read from the RAW addon store.
|
|
996
|
+
*
|
|
997
|
+
* `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
|
|
998
|
+
* hub happens to know about — so it can never appear in the addon's
|
|
999
|
+
* `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
|
|
1000
|
+
* present in the defaults ("the store can contain extra keys … without
|
|
1001
|
+
* polluting the typed config"), so every `exportTo:*` key is dropped on the
|
|
1002
|
+
* way into `this.config` and reading one there yields `undefined` for ever.
|
|
1003
|
+
*
|
|
1004
|
+
* That is not theoretical. On 2026-08-09 the switch was on
|
|
1005
|
+
* (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
|
|
1006
|
+
* settings FORM reads the store) and every reconcile still logged
|
|
1007
|
+
* `brokers=0`: nothing was ever exported, the per-device Export tab rendered
|
|
1008
|
+
* its "Not configured" card, and no operator action could have changed
|
|
1009
|
+
* either. The store is the single authority — read it, do not mirror it into
|
|
1010
|
+
* a typed field that would then be a second one (D62).
|
|
1011
|
+
*
|
|
1012
|
+
* A broker that is no longer known is not enabled, whatever the store says:
|
|
1013
|
+
* a stale `exportTo:` key for a deleted instance must not resurrect it.
|
|
1014
|
+
*/
|
|
1015
|
+
function enabledBrokerIds(store, knownBrokerIds) {
|
|
1016
|
+
return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* What a linked Home Assistant NEEDS to function — one entry per thing it
|
|
1020
|
+
* actually calls with this token, each named. Not a blast radius, not a
|
|
1021
|
+
* conservative under-declaration: `requestedScopes` has exactly one meaning
|
|
1022
|
+
* across every integration, and since D103 it is also the ENFORCEMENT input, so
|
|
1023
|
+
* an under-declaration is an integration that stops working.
|
|
1024
|
+
*
|
|
1025
|
+
* `category:system [create]` was rejected and stays rejected: it would hand
|
|
1026
|
+
* `addons.installPackage` to a home-automation bridge.
|
|
873
1027
|
*/
|
|
874
1028
|
var HOME_ASSISTANT_OAUTH_INTEGRATION = {
|
|
875
1029
|
integrationId: "homeassistant",
|
|
@@ -894,11 +1048,54 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
|
|
|
894
1048
|
type: "capability",
|
|
895
1049
|
target: "pipeline-orchestrator",
|
|
896
1050
|
access: ["view", "create"]
|
|
897
|
-
}
|
|
1051
|
+
},
|
|
1052
|
+
(
|
|
1053
|
+
/**
|
|
1054
|
+
* `deviceExport.listExposedDevices` — the membership read that decides WHICH
|
|
1055
|
+
* devices the component imports. It is pinned to this addon so the unpinned
|
|
1056
|
+
* aggregate (which merges Alexa's and HomeKit's exposed sets, D12) cannot
|
|
1057
|
+
* leak another exporter's devices into Home Assistant.
|
|
1058
|
+
*
|
|
1059
|
+
* Missing until 2026-08-09, which broke setup outright: the component's very
|
|
1060
|
+
* first authenticated call is this one, and the hub answered "rejected the
|
|
1061
|
+
* token" — reported by Home Assistant as `Configurazione non riuscita:
|
|
1062
|
+
* deviceExport.listExposedDevices: hub rejected the token`. The call was
|
|
1063
|
+
* introduced with the export filter and the descriptor was never widened for
|
|
1064
|
+
* it. `system` scope, so a capability grant is what it needs — the `addon:`
|
|
1065
|
+
* grant below covers the HTTP command route and does NOT cover this.
|
|
1066
|
+
*/
|
|
1067
|
+
{
|
|
1068
|
+
type: "capability",
|
|
1069
|
+
target: "device-export",
|
|
1070
|
+
access: ["view"]
|
|
1071
|
+
}),
|
|
1072
|
+
(
|
|
1073
|
+
/**
|
|
1074
|
+
* `POST /addon/homeassistant-export/command` — the ONLY path by which an
|
|
1075
|
+
* actuated Home Assistant entity (a camera switch, a PTZ button, reboot,
|
|
1076
|
+
* snooze) reaches CamStack. Added when D103 made the addon-route gate check
|
|
1077
|
+
* the grant instead of accepting any valid hub JWT; without it every HA
|
|
1078
|
+
* control entity answers `403 Token scope mismatch`. `view` is here for the
|
|
1079
|
+
* same addon's future GET routes, so a read does not force a re-link.
|
|
1080
|
+
*/
|
|
1081
|
+
{
|
|
1082
|
+
type: "addon",
|
|
1083
|
+
target: "homeassistant-export",
|
|
1084
|
+
access: ["view", "create"]
|
|
1085
|
+
})
|
|
898
1086
|
],
|
|
899
1087
|
allowedRedirectPrefixes: ["https://my.home-assistant.io/redirect/oauth"],
|
|
900
1088
|
allowedPrivateHostPaths: ["/auth/external/callback"],
|
|
901
|
-
requiresPkce: true
|
|
1089
|
+
requiresPkce: true,
|
|
1090
|
+
/**
|
|
1091
|
+
* One year. A Home Assistant config entry is meant to survive indefinitely
|
|
1092
|
+
* and re-linking is a manual trip through the UI, so the 30-day default
|
|
1093
|
+
* silently unlinked a working integration a month after setup. Finite rather
|
|
1094
|
+
* than `'never'` (which Alexa uses): an HA instance refreshes on a timer
|
|
1095
|
+
* while it is running, so a year is far beyond any realistic downtime, and a
|
|
1096
|
+
* bounded token is the better default where nothing is lost by it.
|
|
1097
|
+
*/
|
|
1098
|
+
refreshTokenTtlSec: 365 * 24 * 60 * 60
|
|
902
1099
|
};
|
|
903
1100
|
//#endregion
|
|
904
1101
|
//#region src/ha-export/push-client.ts
|
|
@@ -1248,6 +1445,10 @@ function projectZoneOccupancy(deviceKey, input) {
|
|
|
1248
1445
|
const values = [];
|
|
1249
1446
|
for (const zone of input.zones) {
|
|
1250
1447
|
const slug = toSlug(zone.zoneId);
|
|
1448
|
+
values.push({
|
|
1449
|
+
topic: stateTopic(deviceKey, `${slug}_objects`),
|
|
1450
|
+
value: String(zone.totalObjects)
|
|
1451
|
+
});
|
|
1251
1452
|
const totals = new Map(ZONE_MACROS.map((macro) => [macro, 0]));
|
|
1252
1453
|
for (const [className, count] of Object.entries(zone.byClass)) {
|
|
1253
1454
|
const macro = macroOf(className);
|
|
@@ -1259,6 +1460,22 @@ function projectZoneOccupancy(deviceKey, input) {
|
|
|
1259
1460
|
value: String(count)
|
|
1260
1461
|
});
|
|
1261
1462
|
}
|
|
1463
|
+
if (input.scene !== void 0) {
|
|
1464
|
+
values.push({
|
|
1465
|
+
topic: stateTopic(deviceKey, "objects"),
|
|
1466
|
+
value: String(input.scene.totalObjects)
|
|
1467
|
+
});
|
|
1468
|
+
const totals = new Map(EXPORTED_MACROS.map((macro) => [macro, 0]));
|
|
1469
|
+
for (const [className, count] of Object.entries(input.scene.byClass)) {
|
|
1470
|
+
const macro = macroOf(className);
|
|
1471
|
+
if (macro === null || !totals.has(macro)) continue;
|
|
1472
|
+
totals.set(macro, (totals.get(macro) ?? 0) + count);
|
|
1473
|
+
}
|
|
1474
|
+
for (const [macro, count] of totals) values.push({
|
|
1475
|
+
topic: stateTopic(deviceKey, `${macro}_objects`),
|
|
1476
|
+
value: String(count)
|
|
1477
|
+
});
|
|
1478
|
+
}
|
|
1262
1479
|
return values;
|
|
1263
1480
|
}
|
|
1264
1481
|
function projectBatterySlice(deviceKey, slice) {
|
|
@@ -1294,6 +1511,84 @@ function projectCameraSwitches(deviceKey, switches) {
|
|
|
1294
1511
|
value: bool(sw.enabled)
|
|
1295
1512
|
}));
|
|
1296
1513
|
}
|
|
1514
|
+
/**
|
|
1515
|
+
* The doorbell slice → its three entities.
|
|
1516
|
+
*
|
|
1517
|
+
* A press is a PULSE: the slice carries `lastPressedAt`, never a
|
|
1518
|
+
* `pressed: true/false`. `doorbell_pressed` is therefore driven by the
|
|
1519
|
+
* caller, which knows whether this projection is a fresh press or a
|
|
1520
|
+
* reconcile re-read — the same shape `projectMotion` already uses, and the
|
|
1521
|
+
* reason the flag is a parameter rather than derived from a timestamp
|
|
1522
|
+
* comparison here (a pure function that reads the clock is a pure function
|
|
1523
|
+
* that behaves differently in a test).
|
|
1524
|
+
*
|
|
1525
|
+
* `lastPressedAt: null` publishes NOTHING for the timestamp: HA renders an
|
|
1526
|
+
* empty timestamp sensor as `unknown`, which is honest, whereas `0` would
|
|
1527
|
+
* render as 1 January 1970 on the operator's dashboard.
|
|
1528
|
+
*/
|
|
1529
|
+
function projectDoorbellSlice(deviceKey, slice, pressed) {
|
|
1530
|
+
const values = [{
|
|
1531
|
+
topic: stateTopic(deviceKey, "doorbell_pressed"),
|
|
1532
|
+
value: bool(pressed)
|
|
1533
|
+
}, {
|
|
1534
|
+
topic: stateTopic(deviceKey, "doorbell_press_count"),
|
|
1535
|
+
value: String(slice.pressCountSinceStart)
|
|
1536
|
+
}];
|
|
1537
|
+
if (slice.lastPressedAt !== null) values.push({
|
|
1538
|
+
topic: stateTopic(deviceKey, "doorbell_last_pressed"),
|
|
1539
|
+
value: new Date(slice.lastPressedAt).toISOString()
|
|
1540
|
+
});
|
|
1541
|
+
return values;
|
|
1542
|
+
}
|
|
1543
|
+
/**
|
|
1544
|
+
* One audio window → the audio entities.
|
|
1545
|
+
*
|
|
1546
|
+
* This is the source the catalog's `audio_detected` was always missing.
|
|
1547
|
+
* It had only the track lifecycle, which rarely carries an audio macro, so
|
|
1548
|
+
* the entity existed and effectively never moved — the note in this
|
|
1549
|
+
* package's CLAUDE.md said so without naming the event. The event is
|
|
1550
|
+
* `pipeline.audio-inference-result`, and it carries both halves an
|
|
1551
|
+
* operator asks for: WHAT was heard and HOW LOUD.
|
|
1552
|
+
*
|
|
1553
|
+
* The label is the HIGHEST-CONFIDENCE detection of the window, not the
|
|
1554
|
+
* first: the analyzer's array order is its own business and depending on
|
|
1555
|
+
* it would make the exported label change with an unrelated refactor.
|
|
1556
|
+
*
|
|
1557
|
+
* A window with no detections publishes NO label. It does not publish an
|
|
1558
|
+
* empty string: HA renders that as a valid state, so a quiet minute would
|
|
1559
|
+
* blank the last thing heard instead of leaving it — and "what was that
|
|
1560
|
+
* noise" is asked after the noise has stopped.
|
|
1561
|
+
*
|
|
1562
|
+
* The level is published on EVERY window, silence included. That is the
|
|
1563
|
+
* point of a volume entity: a flat line is a reading, and dBFS is
|
|
1564
|
+
* negative-going (0 = full scale), so no value can be mistaken for one.
|
|
1565
|
+
*/
|
|
1566
|
+
function projectAudioWindow(deviceKey, input) {
|
|
1567
|
+
const values = [];
|
|
1568
|
+
if (input.dbfs !== void 0 && Number.isFinite(input.dbfs)) values.push({
|
|
1569
|
+
topic: stateTopic(deviceKey, "audio_volume"),
|
|
1570
|
+
value: String(Math.round(input.dbfs * 10) / 10)
|
|
1571
|
+
});
|
|
1572
|
+
const best = input.detections.reduce((top, d) => top === null || d.confidence > top.confidence ? d : top, null);
|
|
1573
|
+
if (best !== null) values.push({
|
|
1574
|
+
topic: stateTopic(deviceKey, "audio_last_sound"),
|
|
1575
|
+
value: best.className
|
|
1576
|
+
});
|
|
1577
|
+
return values;
|
|
1578
|
+
}
|
|
1579
|
+
/** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
|
|
1580
|
+
var HA_BROKER_KIND = "home-assistant";
|
|
1581
|
+
/**
|
|
1582
|
+
* Every Home Assistant broker currently registered, cluster-wide.
|
|
1583
|
+
*
|
|
1584
|
+
* Unpinned by design (see the module docblock) and filtered by `kind`, which is
|
|
1585
|
+
* the contract check that matters: whatever mix of providers answered the
|
|
1586
|
+
* union, only `home-assistant` brokers are ever exported to over Home
|
|
1587
|
+
* Assistant's transport.
|
|
1588
|
+
*/
|
|
1589
|
+
async function listHomeAssistantBrokers(query) {
|
|
1590
|
+
return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
|
|
1591
|
+
}
|
|
1297
1592
|
//#endregion
|
|
1298
1593
|
//#region src/ha-export/ha-export.addon.ts
|
|
1299
1594
|
/**
|
|
@@ -1331,12 +1626,12 @@ function projectCameraSwitches(deviceKey, switches) {
|
|
|
1331
1626
|
* different sets of cameras, and the per-device Export panel shows
|
|
1332
1627
|
* one switch per broker over the single membership store.
|
|
1333
1628
|
*/
|
|
1334
|
-
/** The addon that owns the import direction — never export back to it. */
|
|
1335
|
-
var HA_PROVIDER_ADDON_ID = "provider-homeassistant";
|
|
1336
1629
|
var ADDON_ID = "homeassistant-export";
|
|
1337
1630
|
/** Where the custom component registers its push view inside HA. */
|
|
1338
1631
|
var PUSH_PATH = "/api/camstack/push";
|
|
1339
1632
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
1633
|
+
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
1634
|
+
var DOORBELL_PULSE_MS = 5e3;
|
|
1340
1635
|
/** The hub's API port — where the data plane is reverse-proxied. */
|
|
1341
1636
|
var HUB_API_PORT = 4443;
|
|
1342
1637
|
/** One PTZ button press, as a relative move. */
|
|
@@ -1368,6 +1663,10 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1368
1663
|
reconcilePending = null;
|
|
1369
1664
|
lastError;
|
|
1370
1665
|
entityCount = 0;
|
|
1666
|
+
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
1667
|
+
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
1668
|
+
/** deviceId → the pending OFF edge. HA releases nothing on its own. */
|
|
1669
|
+
doorbellReleaseTimers = /* @__PURE__ */ new Map();
|
|
1371
1670
|
unclassified = [];
|
|
1372
1671
|
/**
|
|
1373
1672
|
* A link that returned repairs NOW, not at the next periodic pass.
|
|
@@ -1410,6 +1709,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1410
1709
|
this.startTimer();
|
|
1411
1710
|
this.ctx.addDisposer(async () => {
|
|
1412
1711
|
this.stopTimer();
|
|
1712
|
+
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
1713
|
+
this.doorbellReleaseTimers.clear();
|
|
1413
1714
|
this.reachability.dispose();
|
|
1414
1715
|
for (const link of this.links.values()) await link.client.dispose();
|
|
1415
1716
|
this.links.clear();
|
|
@@ -1466,6 +1767,15 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1466
1767
|
this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
|
|
1467
1768
|
this.onMotion(event.data);
|
|
1468
1769
|
});
|
|
1770
|
+
/**
|
|
1771
|
+
* The audio window. This is the source `audio_detected` never had: it was
|
|
1772
|
+
* fed only by the track lifecycle, which rarely carries an audio macro, so
|
|
1773
|
+
* the entity existed and effectively never moved. It also carries the
|
|
1774
|
+
* window LEVEL, which nothing else on the export path does.
|
|
1775
|
+
*/
|
|
1776
|
+
this.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
1777
|
+
this.onAudioWindow(event.data);
|
|
1778
|
+
});
|
|
1469
1779
|
this.subscribe({ category: EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
1470
1780
|
this.onTrackLifecycle(event.data);
|
|
1471
1781
|
});
|
|
@@ -1513,6 +1823,40 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1513
1823
|
}));
|
|
1514
1824
|
return;
|
|
1515
1825
|
}
|
|
1826
|
+
if (capName === "doorbell") {
|
|
1827
|
+
/**
|
|
1828
|
+
* A press is a PULSE — the slice carries `lastPressedAt`, never a
|
|
1829
|
+
* boolean. `doorbell_pressed` is therefore ON only when THIS delivery
|
|
1830
|
+
* advanced the timestamp, and the previous value is remembered per
|
|
1831
|
+
* device rather than compared against the clock: a reconcile re-read of
|
|
1832
|
+
* an unchanged slice must not ring the doorbell again in Home Assistant,
|
|
1833
|
+
* and a press that arrives while the export was restarting must still
|
|
1834
|
+
* ring once. HA auto-releases nothing, so the OFF edge is published on a
|
|
1835
|
+
* short timer — without it the binary sensor latches on for ever and the
|
|
1836
|
+
* next press produces no edge to automate on.
|
|
1837
|
+
*/
|
|
1838
|
+
const lastPressedAt = readNumber(slice, "lastPressedAt");
|
|
1839
|
+
const pressCountSinceStart = readNumber(slice, "pressCountSinceStart") ?? 0;
|
|
1840
|
+
const previous = this.lastDoorbellPressAt.get(deviceId) ?? null;
|
|
1841
|
+
const pressed = lastPressedAt !== null && lastPressedAt !== previous;
|
|
1842
|
+
if (pressed) this.lastDoorbellPressAt.set(deviceId, lastPressedAt);
|
|
1843
|
+
this.push(deviceId, projectDoorbellSlice(deviceKey, {
|
|
1844
|
+
lastPressedAt,
|
|
1845
|
+
pressCountSinceStart
|
|
1846
|
+
}, pressed));
|
|
1847
|
+
if (pressed) {
|
|
1848
|
+
const existing = this.doorbellReleaseTimers.get(deviceId);
|
|
1849
|
+
if (existing !== void 0) clearTimeout(existing);
|
|
1850
|
+
this.doorbellReleaseTimers.set(deviceId, setTimeout(() => {
|
|
1851
|
+
this.doorbellReleaseTimers.delete(deviceId);
|
|
1852
|
+
this.push(deviceId, projectDoorbellSlice(deviceKey, {
|
|
1853
|
+
lastPressedAt,
|
|
1854
|
+
pressCountSinceStart
|
|
1855
|
+
}, false));
|
|
1856
|
+
}, DOORBELL_PULSE_MS));
|
|
1857
|
+
}
|
|
1858
|
+
return;
|
|
1859
|
+
}
|
|
1516
1860
|
if (capName === "motion") {
|
|
1517
1861
|
const detected = slice["detected"];
|
|
1518
1862
|
const lastDetectedAt = readNumber(slice, "lastDetectedAt");
|
|
@@ -1585,6 +1929,28 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1585
1929
|
}
|
|
1586
1930
|
this.push(deviceId, values);
|
|
1587
1931
|
}
|
|
1932
|
+
onAudioWindow(data) {
|
|
1933
|
+
const deviceId = readNumber(data, "deviceId");
|
|
1934
|
+
if (deviceId === null) return;
|
|
1935
|
+
const deviceKey = this.keyByDeviceId.get(deviceId);
|
|
1936
|
+
if (deviceKey === void 0) return;
|
|
1937
|
+
const frame = readRecordField(data, "frame");
|
|
1938
|
+
if (frame === null || typeof frame !== "object" || Array.isArray(frame)) return;
|
|
1939
|
+
const dbfs = readNumber(readRecord(frame, "level"), "dbfs");
|
|
1940
|
+
const raw = readRecordField(frame, "detections");
|
|
1941
|
+
const detections = Array.isArray(raw) ? raw.flatMap((entry) => {
|
|
1942
|
+
const className = readString(entry, "className");
|
|
1943
|
+
const confidence = readNumber(entry, "confidence");
|
|
1944
|
+
return className === null || confidence === null ? [] : [{
|
|
1945
|
+
className,
|
|
1946
|
+
confidence
|
|
1947
|
+
}];
|
|
1948
|
+
}) : [];
|
|
1949
|
+
this.push(deviceId, projectAudioWindow(deviceKey, {
|
|
1950
|
+
detections,
|
|
1951
|
+
...dbfs !== null ? { dbfs } : {}
|
|
1952
|
+
}));
|
|
1953
|
+
}
|
|
1588
1954
|
onZoneOccupancy(data) {
|
|
1589
1955
|
const deviceId = readNumber(data, "deviceId");
|
|
1590
1956
|
if (deviceId === null) return;
|
|
@@ -1598,11 +1964,20 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1598
1964
|
if (zoneId === null) return [];
|
|
1599
1965
|
return [{
|
|
1600
1966
|
zoneId,
|
|
1967
|
+
totalObjects: readNumber(entry, "totalObjects") ?? 0,
|
|
1601
1968
|
byClass: toCountRecord(byClass)
|
|
1602
1969
|
}];
|
|
1603
1970
|
});
|
|
1604
|
-
|
|
1605
|
-
|
|
1971
|
+
const frame = readRecordField(data, "frame");
|
|
1972
|
+
const scene = frame !== null && typeof frame === "object" && !Array.isArray(frame) ? {
|
|
1973
|
+
totalObjects: readNumber(frame, "totalObjects") ?? 0,
|
|
1974
|
+
byClass: toCountRecord(readRecord(frame, "byClass"))
|
|
1975
|
+
} : void 0;
|
|
1976
|
+
if (zones.length === 0 && scene === void 0) return;
|
|
1977
|
+
this.push(deviceId, projectZoneOccupancy(deviceKey, {
|
|
1978
|
+
zones,
|
|
1979
|
+
...scene !== void 0 ? { scene } : {}
|
|
1980
|
+
}));
|
|
1606
1981
|
}
|
|
1607
1982
|
/**
|
|
1608
1983
|
* Send a device's values to every broker it is exported to.
|
|
@@ -1694,7 +2069,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1694
2069
|
* exported back to it: HA would mirror its own entity across the
|
|
1695
2070
|
* bridge, and any automation touching it would round-trip.
|
|
1696
2071
|
*/
|
|
1697
|
-
if (device.addonId ===
|
|
2072
|
+
if (device.addonId === "provider-homeassistant") {
|
|
1698
2073
|
this.ctx.logger.warn("ha-export: refusing to export a device imported from Home Assistant", { tags: { deviceId: numericId } });
|
|
1699
2074
|
continue;
|
|
1700
2075
|
}
|
|
@@ -1819,10 +2194,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1819
2194
|
enabled: sw.enabled
|
|
1820
2195
|
}))));
|
|
1821
2196
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
1822
|
-
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
2197
|
+
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
2198
|
+
zones: occupancy.zones.map((zone) => ({
|
|
2199
|
+
zoneId: zone.zoneId,
|
|
2200
|
+
totalObjects: zone.totalObjects,
|
|
2201
|
+
byClass: toCountRecord(zone.byClass)
|
|
2202
|
+
})),
|
|
2203
|
+
scene: {
|
|
2204
|
+
totalObjects: occupancy.frame.totalObjects,
|
|
2205
|
+
byClass: toCountRecord(occupancy.frame.byClass)
|
|
2206
|
+
}
|
|
2207
|
+
}));
|
|
1826
2208
|
}
|
|
1827
2209
|
return values;
|
|
1828
2210
|
}
|
|
@@ -1914,13 +2296,14 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1914
2296
|
* ones the operator ticked.
|
|
1915
2297
|
*
|
|
1916
2298
|
* Credentials come through the `broker` cap — the sanctioned no-import
|
|
1917
|
-
* path — and the owning `addonId
|
|
1918
|
-
* COLLECTION cap, so an unpinned
|
|
1919
|
-
* first-registered provider,
|
|
1920
|
-
*
|
|
2299
|
+
* path — and EVERY call carries the owning `addonId`: `broker` is a
|
|
2300
|
+
* COLLECTION cap, so an unpinned call is answered by the
|
|
2301
|
+
* first-registered provider (Homematic, on the operator's hub), which
|
|
2302
|
+
* knows nothing about `ha_*`. `broker-source.ts` holds the pin and the
|
|
2303
|
+
* reasoning.
|
|
1921
2304
|
*/
|
|
1922
2305
|
async refreshBrokers() {
|
|
1923
|
-
const haBrokers =
|
|
2306
|
+
const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
|
|
1924
2307
|
const known = haBrokers.map((broker) => ({
|
|
1925
2308
|
id: broker.id,
|
|
1926
2309
|
name: broker.name
|
|
@@ -1930,6 +2313,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1930
2313
|
knownBrokers: known,
|
|
1931
2314
|
membership: pruned
|
|
1932
2315
|
});
|
|
2316
|
+
await this.refreshEnabledBrokers(known);
|
|
1933
2317
|
const wanted = new Set(this.enabledBrokerIds());
|
|
1934
2318
|
for (const [brokerId, link] of this.links) {
|
|
1935
2319
|
if (wanted.has(brokerId)) continue;
|
|
@@ -2001,8 +2385,23 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
2001
2385
|
return null;
|
|
2002
2386
|
}
|
|
2003
2387
|
}
|
|
2388
|
+
/**
|
|
2389
|
+
* The brokers switched on, as of the last {@link refreshEnabledBrokers}.
|
|
2390
|
+
*
|
|
2391
|
+
* NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
|
|
2392
|
+
* `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
|
|
2393
|
+
* it is `undefined` there for ever — see `enabledBrokerIds` in
|
|
2394
|
+
* `membership.ts` for what that cost. The store is the authority; this is a
|
|
2395
|
+
* read-through cache of it, refreshed at the top of every reconcile pass and
|
|
2396
|
+
* before the per-device panel renders, never written to.
|
|
2397
|
+
*/
|
|
2398
|
+
enabled = [];
|
|
2399
|
+
async refreshEnabledBrokers(known) {
|
|
2400
|
+
const store = await this.resolveGlobalStore();
|
|
2401
|
+
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
|
|
2402
|
+
}
|
|
2004
2403
|
enabledBrokerIds() {
|
|
2005
|
-
return this.
|
|
2404
|
+
return this.enabled;
|
|
2006
2405
|
}
|
|
2007
2406
|
brokerName(brokerId) {
|
|
2008
2407
|
return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
|
|
@@ -2341,7 +2740,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
2341
2740
|
* exporters' identically-named fields and the operator sees another
|
|
2342
2741
|
* exporter's value in this one's toggle.
|
|
2343
2742
|
*/
|
|
2344
|
-
buildContribution(deviceId) {
|
|
2743
|
+
async buildContribution(deviceId) {
|
|
2744
|
+
await this.refreshEnabledBrokers(this.config.knownBrokers);
|
|
2345
2745
|
const idStr = String(deviceId);
|
|
2346
2746
|
const enabled = this.enabledBrokerIds();
|
|
2347
2747
|
const fields = enabled.map((brokerId) => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@camstack/addon-provider-homeassistant",
|
|
3
|
-
"version": "1.2.
|
|
3
|
+
"version": "1.2.20",
|
|
4
4
|
"description": "Home Assistant device provider addon for CamStack",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"camstack",
|
|
@@ -46,8 +46,7 @@
|
|
|
46
46
|
"brokerKind": "home-assistant",
|
|
47
47
|
"supportsLocationImport": true,
|
|
48
48
|
"execution": {
|
|
49
|
-
"placement": "hub-only"
|
|
50
|
-
"group": "notifiers"
|
|
49
|
+
"placement": "hub-only"
|
|
51
50
|
},
|
|
52
51
|
"capabilities": [
|
|
53
52
|
{
|