@camstack/addon-provider-homeassistant 1.2.15 → 1.2.17
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 +52 -1
- package/dist/addon.mjs +52 -1
- package/dist/{dist-DJqSvADu.js → dist-DIa87XAf.js} +248 -1
- package/dist/{dist-DdyXnBOa.mjs → dist-reK3lvnr.mjs} +248 -1
- package/dist/ha-export/ha-export.addon.js +276 -57
- package/dist/ha-export/ha-export.addon.mjs +275 -57
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
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-
|
|
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-reK3lvnr.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
//#region src/ha-export/topics.ts
|
|
4
4
|
/**
|
|
@@ -125,21 +125,25 @@ var ZONE_MACROS = [
|
|
|
125
125
|
"animal"
|
|
126
126
|
];
|
|
127
127
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
128
|
+
* `enabled_by_default: false` is a DECISION, taken per entity, never
|
|
129
|
+
* inferred from its name or its platform.
|
|
130
|
+
*
|
|
131
|
+
* It used to be inferred — a name-and-platform heuristic written for the
|
|
132
|
+
* camera catalog (`endsWith('_detected')`, `platform === 'switch'`) and
|
|
133
|
+
* then applied to every device the catalog builds. On a camera it is
|
|
134
|
+
* right: three zones is ~73 entities and the fleet is ~880, so only what
|
|
135
|
+
* an operator automates on arrives enabled. On a DERIVED device it was
|
|
136
|
+
* simply wrong — every sensor, every number and the alarm panel matched
|
|
137
|
+
* nothing and shipped switched off, so a temperature sensor exported its
|
|
138
|
+
* temperature disabled and the operator found the thing they installed
|
|
139
|
+
* the integration for turned off. Measured on the live hub, the whole
|
|
140
|
+
* derived half is **228 entities across 115 devices — 2 on average, 3 at
|
|
141
|
+
* most**: there is no fan-out there to protect against.
|
|
142
|
+
*
|
|
143
|
+
* So the flag is a required field on every spec. A new entity — or the
|
|
144
|
+
* synthetic devices the design addendum still owes — cannot inherit a
|
|
145
|
+
* camera's pressure valve by accident.
|
|
131
146
|
*/
|
|
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
147
|
/**
|
|
144
148
|
* The snooze surface, as a `select`.
|
|
145
149
|
*
|
|
@@ -175,7 +179,6 @@ var PTZ_BUTTONS = [
|
|
|
175
179
|
];
|
|
176
180
|
function buildComponent(device, spec) {
|
|
177
181
|
const deviceKey = deviceKeyFor(device.stableId);
|
|
178
|
-
const enabled = isEnabledByDefault(spec.entity, spec.platform);
|
|
179
182
|
return {
|
|
180
183
|
platform: spec.platform,
|
|
181
184
|
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
@@ -187,7 +190,7 @@ function buildComponent(device, spec) {
|
|
|
187
190
|
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
188
191
|
...spec.options !== void 0 ? { options: spec.options } : {},
|
|
189
192
|
...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
|
|
190
|
-
...
|
|
193
|
+
...spec.enabledByDefault ? {} : { enabled_by_default: false },
|
|
191
194
|
...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
|
|
192
195
|
payload_on: "true",
|
|
193
196
|
payload_off: "false"
|
|
@@ -203,6 +206,11 @@ function deviceBlock(device) {
|
|
|
203
206
|
mdl: device.model ?? device.type
|
|
204
207
|
};
|
|
205
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* The fan-out, and the reason the valve exists: zones × zone-macros × 4.
|
|
211
|
+
* The detector is what an operator automates on; the other three are
|
|
212
|
+
* detail they can switch on per entity.
|
|
213
|
+
*/
|
|
206
214
|
function zoneSpecs(zone) {
|
|
207
215
|
const slug = toSlug(zone.id);
|
|
208
216
|
const specs = [];
|
|
@@ -213,24 +221,28 @@ function zoneSpecs(zone) {
|
|
|
213
221
|
platform: "binary_sensor",
|
|
214
222
|
label: `${label} detected`,
|
|
215
223
|
uniqueSuffix: `${zone.id}_${macro}_detected`,
|
|
216
|
-
deviceClass: "motion"
|
|
224
|
+
deviceClass: "motion",
|
|
225
|
+
enabledByDefault: true
|
|
217
226
|
}, {
|
|
218
227
|
entity: `${slug}_${macro}_last_image`,
|
|
219
228
|
platform: "image",
|
|
220
229
|
label: `${label} last image`,
|
|
221
|
-
uniqueSuffix: `${zone.id}_${macro}_last_image
|
|
230
|
+
uniqueSuffix: `${zone.id}_${macro}_last_image`,
|
|
231
|
+
enabledByDefault: false
|
|
222
232
|
}, {
|
|
223
233
|
entity: `${slug}_${macro}_last_detection`,
|
|
224
234
|
platform: "sensor",
|
|
225
235
|
label: `${label} last detection`,
|
|
226
236
|
uniqueSuffix: `${zone.id}_${macro}_last_detection`,
|
|
227
|
-
deviceClass: "timestamp"
|
|
237
|
+
deviceClass: "timestamp",
|
|
238
|
+
enabledByDefault: false
|
|
228
239
|
}, {
|
|
229
240
|
entity: `${slug}_${macro}_objects`,
|
|
230
241
|
platform: "sensor",
|
|
231
242
|
label: `${label} objects`,
|
|
232
243
|
uniqueSuffix: `${zone.id}_${macro}_objects`,
|
|
233
|
-
icon: "mdi:counter"
|
|
244
|
+
icon: "mdi:counter",
|
|
245
|
+
enabledByDefault: false
|
|
234
246
|
});
|
|
235
247
|
}
|
|
236
248
|
return specs;
|
|
@@ -241,26 +253,30 @@ function cameraSpecs(device) {
|
|
|
241
253
|
entity: "triggered",
|
|
242
254
|
platform: "binary_sensor",
|
|
243
255
|
label: "Triggered",
|
|
244
|
-
deviceClass: "motion"
|
|
256
|
+
deviceClass: "motion",
|
|
257
|
+
enabledByDefault: true
|
|
245
258
|
},
|
|
246
259
|
{
|
|
247
260
|
entity: "online",
|
|
248
261
|
platform: "binary_sensor",
|
|
249
262
|
label: "Online",
|
|
250
263
|
deviceClass: "connectivity",
|
|
251
|
-
entityCategory: "diagnostic"
|
|
264
|
+
entityCategory: "diagnostic",
|
|
265
|
+
enabledByDefault: true
|
|
252
266
|
},
|
|
253
267
|
{
|
|
254
268
|
entity: "last_image",
|
|
255
269
|
platform: "image",
|
|
256
|
-
label: "Last image"
|
|
270
|
+
label: "Last image",
|
|
271
|
+
enabledByDefault: true
|
|
257
272
|
},
|
|
258
273
|
{
|
|
259
274
|
entity: "last_detection",
|
|
260
275
|
platform: "sensor",
|
|
261
276
|
label: "Last detection",
|
|
262
277
|
deviceClass: "timestamp",
|
|
263
|
-
icon: "mdi:clock"
|
|
278
|
+
icon: "mdi:clock",
|
|
279
|
+
enabledByDefault: false
|
|
264
280
|
}
|
|
265
281
|
];
|
|
266
282
|
if (device.slices.includes("battery")) specs.push({
|
|
@@ -269,18 +285,21 @@ function cameraSpecs(device) {
|
|
|
269
285
|
label: "Battery",
|
|
270
286
|
deviceClass: "battery",
|
|
271
287
|
unit: "%",
|
|
272
|
-
entityCategory: "diagnostic"
|
|
288
|
+
entityCategory: "diagnostic",
|
|
289
|
+
enabledByDefault: false
|
|
273
290
|
}, {
|
|
274
291
|
entity: "charger",
|
|
275
292
|
platform: "binary_sensor",
|
|
276
293
|
label: "Charging",
|
|
277
294
|
deviceClass: "battery_charging",
|
|
278
|
-
entityCategory: "diagnostic"
|
|
295
|
+
entityCategory: "diagnostic",
|
|
296
|
+
enabledByDefault: false
|
|
279
297
|
}, {
|
|
280
298
|
entity: "sleeping",
|
|
281
299
|
platform: "binary_sensor",
|
|
282
300
|
label: "Sleeping",
|
|
283
|
-
entityCategory: "diagnostic"
|
|
301
|
+
entityCategory: "diagnostic",
|
|
302
|
+
enabledByDefault: false
|
|
284
303
|
});
|
|
285
304
|
/**
|
|
286
305
|
* One HA switch per AVAILABLE camera switch, rendered from
|
|
@@ -298,28 +317,32 @@ function cameraSpecs(device) {
|
|
|
298
317
|
entity: toSlug(sw.id),
|
|
299
318
|
platform: "switch",
|
|
300
319
|
label: sw.label,
|
|
301
|
-
writable: true
|
|
320
|
+
writable: true,
|
|
321
|
+
enabledByDefault: true
|
|
302
322
|
});
|
|
303
323
|
}
|
|
304
324
|
if (device.features.includes("rebootable")) specs.push({
|
|
305
325
|
entity: "reboot",
|
|
306
326
|
platform: "button",
|
|
307
327
|
label: "Reboot",
|
|
308
|
-
writable: true
|
|
328
|
+
writable: true,
|
|
329
|
+
enabledByDefault: true
|
|
309
330
|
});
|
|
310
331
|
if (device.boundCaps.includes("ptz")) {
|
|
311
332
|
for (const entity of PTZ_BUTTONS) specs.push({
|
|
312
333
|
entity,
|
|
313
334
|
platform: "button",
|
|
314
335
|
label: humanise(entity),
|
|
315
|
-
writable: true
|
|
336
|
+
writable: true,
|
|
337
|
+
enabledByDefault: true
|
|
316
338
|
});
|
|
317
339
|
specs.push({
|
|
318
340
|
entity: "ptz_preset",
|
|
319
341
|
platform: "select",
|
|
320
342
|
label: "PTZ preset",
|
|
321
343
|
writable: true,
|
|
322
|
-
options: device.ptzPresets ?? []
|
|
344
|
+
options: device.ptzPresets ?? [],
|
|
345
|
+
enabledByDefault: true
|
|
323
346
|
});
|
|
324
347
|
}
|
|
325
348
|
specs.push({
|
|
@@ -327,7 +350,8 @@ function cameraSpecs(device) {
|
|
|
327
350
|
platform: "select",
|
|
328
351
|
label: "Snooze notifications",
|
|
329
352
|
writable: true,
|
|
330
|
-
options: SNOOZE_OPTIONS
|
|
353
|
+
options: SNOOZE_OPTIONS,
|
|
354
|
+
enabledByDefault: true
|
|
331
355
|
});
|
|
332
356
|
for (const macro of EXPORTED_MACROS) {
|
|
333
357
|
const label = humanise(macro);
|
|
@@ -335,22 +359,26 @@ function cameraSpecs(device) {
|
|
|
335
359
|
entity: `${macro}_detected`,
|
|
336
360
|
platform: "binary_sensor",
|
|
337
361
|
label: `${label} detected`,
|
|
338
|
-
deviceClass: "motion"
|
|
362
|
+
deviceClass: "motion",
|
|
363
|
+
enabledByDefault: true
|
|
339
364
|
}, {
|
|
340
365
|
entity: `${macro}_last_image`,
|
|
341
366
|
platform: "image",
|
|
342
|
-
label: `${label} last image
|
|
367
|
+
label: `${label} last image`,
|
|
368
|
+
enabledByDefault: false
|
|
343
369
|
}, {
|
|
344
370
|
entity: `${macro}_last_detection`,
|
|
345
371
|
platform: "sensor",
|
|
346
372
|
label: `${label} last detection`,
|
|
347
|
-
deviceClass: "timestamp"
|
|
373
|
+
deviceClass: "timestamp",
|
|
374
|
+
enabledByDefault: false
|
|
348
375
|
});
|
|
349
376
|
if (LABELLED_MACROS.includes(macro)) specs.push({
|
|
350
377
|
entity: `${macro}_last_label`,
|
|
351
378
|
platform: "sensor",
|
|
352
379
|
label: `${label} last label`,
|
|
353
|
-
icon: "mdi:tag"
|
|
380
|
+
icon: "mdi:tag",
|
|
381
|
+
enabledByDefault: false
|
|
354
382
|
});
|
|
355
383
|
}
|
|
356
384
|
for (const zone of device.zones) specs.push(...zoneSpecs(zone));
|
|
@@ -535,6 +563,22 @@ var CAPS_NOT_EXPORTED = [
|
|
|
535
563
|
{
|
|
536
564
|
cap: "reboot",
|
|
537
565
|
reason: "Rendered as a button, gated on the `rebootable` feature rather than the binding."
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
cap: "device-status",
|
|
569
|
+
reason: "Auto-registered by BaseDevice on EVERY device, so it would add an entity to all of them; availability is already the heartbeat, per device."
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
cap: "feature-probe",
|
|
573
|
+
reason: "The runtime truth about what a device CAN do — it decides which entities exist, so exporting it as one would be circular."
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
cap: "device-ops",
|
|
577
|
+
reason: "The per-device operations envelope; its members surface individually (e.g. reboot)."
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
cap: "device-discovery",
|
|
581
|
+
reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
|
|
538
582
|
}
|
|
539
583
|
];
|
|
540
584
|
/** A capability in neither list — the thing the guard exists to surface. */
|
|
@@ -542,14 +586,28 @@ function unclassifiedCaps(caps) {
|
|
|
542
586
|
const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
|
|
543
587
|
return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
|
|
544
588
|
}
|
|
545
|
-
/**
|
|
589
|
+
/**
|
|
590
|
+
* The derived half: every non-camera kind, from the caps it declares.
|
|
591
|
+
*
|
|
592
|
+
* **Everything here arrives ENABLED**, and that is the rule rather than
|
|
593
|
+
* an oversight in the other direction. A cap-derived entity IS the reason
|
|
594
|
+
* the device exists — a temperature sensor's temperature, a light's
|
|
595
|
+
* brightness, an alarm panel's state — and there is no fan-out to
|
|
596
|
+
* protect against: measured on the live hub, 115 non-camera devices
|
|
597
|
+
* produce 228 entities, 2 per device on average and 3 at most, because a
|
|
598
|
+
* device declares one or two mapped caps and the rest of its bindings
|
|
599
|
+
* (`device-status`, `feature-probe`, `device-ops`) carry no entity at
|
|
600
|
+
* all. The camera valve is for 73-per-device; this is not that problem,
|
|
601
|
+
* and exporting the operator's temperature switched off was the bug.
|
|
602
|
+
*/
|
|
546
603
|
function buildDerivedPlan(device) {
|
|
547
604
|
const specs = [{
|
|
548
605
|
entity: "online",
|
|
549
606
|
platform: "binary_sensor",
|
|
550
607
|
label: "Online",
|
|
551
608
|
deviceClass: "connectivity",
|
|
552
|
-
entityCategory: "diagnostic"
|
|
609
|
+
entityCategory: "diagnostic",
|
|
610
|
+
enabledByDefault: true
|
|
553
611
|
}];
|
|
554
612
|
for (const cap of device.boundCaps) {
|
|
555
613
|
const mapping = CAP_ENTITY_MAP[cap];
|
|
@@ -558,6 +616,7 @@ function buildDerivedPlan(device) {
|
|
|
558
616
|
entity: toSlug(cap),
|
|
559
617
|
platform: mapping.platform,
|
|
560
618
|
label: humanise(cap),
|
|
619
|
+
enabledByDefault: true,
|
|
561
620
|
...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
|
|
562
621
|
...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
|
|
563
622
|
...mapping.writable === true ? { writable: true } : {}
|
|
@@ -674,6 +733,59 @@ function resolveCommand(target, entity, value) {
|
|
|
674
733
|
}
|
|
675
734
|
return null;
|
|
676
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* The ONE derivation of "a signed, expiring URL".
|
|
738
|
+
*
|
|
739
|
+
* This repo mints unguessable, self-expiring links in three places now — the
|
|
740
|
+
* notification artifact plane, the Home Assistant media plane, and the snapshot
|
|
741
|
+
* link plane. The first two grew independently and are byte-identical logic
|
|
742
|
+
* (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
|
|
743
|
+
* each restating the crypto locally because **addons never import each other**.
|
|
744
|
+
*
|
|
745
|
+
* That reason is real, and the conclusion drawn from it was wrong. Two copies of
|
|
746
|
+
* a signing scheme is how one of them quietly ends up with a different TTL, a
|
|
747
|
+
* different compare, or a missing expiry check, and nothing fails until a link
|
|
748
|
+
* that should have died keeps working. The fix is the same one D52 applies to
|
|
749
|
+
* crop geometry: one derivation, in a place every addon may depend on. A
|
|
750
|
+
* framework package is exactly that place — `@camstack/types/node`, off the root
|
|
751
|
+
* entry because `node:crypto` must never be traversed by a browser bundler.
|
|
752
|
+
*
|
|
753
|
+
* What this module deliberately does NOT decide: the TTL, the base URL, the
|
|
754
|
+
* shape of `id`, and the access level of the route. Those are per-plane policy
|
|
755
|
+
* and each caller states them where a reader can see them.
|
|
756
|
+
*/
|
|
757
|
+
/**
|
|
758
|
+
* The signature over `(id, expMs)`.
|
|
759
|
+
*
|
|
760
|
+
* `id` is whatever the plane uses to name the thing being served — an artifact
|
|
761
|
+
* id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
|
|
762
|
+
* must not put a `:` inside a field whose boundary matters; where a plane has
|
|
763
|
+
* more than one field, it composes them itself and owns that ambiguity.
|
|
764
|
+
*/
|
|
765
|
+
function signExpiringUrl(secret, id, expMs) {
|
|
766
|
+
return createHmac("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Verify a request's `(id, exp, sig)`.
|
|
770
|
+
*
|
|
771
|
+
* **Expiry is checked BEFORE the compare**, so an expired link is refused
|
|
772
|
+
* whether or not its signature is valid — a leaked URL stops working on its own
|
|
773
|
+
* and cannot be kept alive by holding a correct signature. The compare itself is
|
|
774
|
+
* constant-time so a public route cannot be probed for the signature byte by
|
|
775
|
+
* byte.
|
|
776
|
+
*/
|
|
777
|
+
function verifyExpiringUrl(input) {
|
|
778
|
+
const { secret, id, exp, sig, nowMs } = input;
|
|
779
|
+
if (exp === void 0 || sig === void 0) return false;
|
|
780
|
+
const expMs = typeof exp === "number" ? exp : Number(exp);
|
|
781
|
+
if (!Number.isFinite(expMs)) return false;
|
|
782
|
+
if (expMs <= nowMs) return false;
|
|
783
|
+
const expected = signExpiringUrl(secret, id, expMs);
|
|
784
|
+
const a = Buffer.from(expected, "utf8");
|
|
785
|
+
const b = Buffer.from(sig, "utf8");
|
|
786
|
+
if (a.length !== b.length) return false;
|
|
787
|
+
return timingSafeEqual(a, b);
|
|
788
|
+
}
|
|
677
789
|
//#endregion
|
|
678
790
|
//#region src/ha-export/media-url.ts
|
|
679
791
|
/**
|
|
@@ -686,18 +798,23 @@ function resolveCommand(target, entity, value) {
|
|
|
686
798
|
*
|
|
687
799
|
* The link is signed rather than protected by a session, because the
|
|
688
800
|
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
689
|
-
* camstack token.
|
|
690
|
-
* (that module lives in another addon and addons never import each other,
|
|
691
|
-
* so the crypto is restated here rather than reached for).
|
|
801
|
+
* camstack token.
|
|
692
802
|
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
803
|
+
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
804
|
+
* notification artifact plane each carried their own copy, because addons never
|
|
805
|
+
* import each other and neither could reach the other's. That reasoning was
|
|
806
|
+
* right and the conclusion was wrong: both now delegate to the single
|
|
807
|
+
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
808
|
+
* stays local is policy — the TTL below, and the id this plane signs.
|
|
809
|
+
*
|
|
810
|
+
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
811
|
+
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
812
|
+
* URL stops working on its own.
|
|
696
813
|
*/
|
|
697
814
|
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
698
815
|
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
699
816
|
function signMedia(secret, id, expMs) {
|
|
700
|
-
return
|
|
817
|
+
return signExpiringUrl(secret, id, expMs);
|
|
701
818
|
}
|
|
702
819
|
function buildMediaUrl(input) {
|
|
703
820
|
const base = input.baseUrl.replace(/\/+$/, "");
|
|
@@ -705,13 +822,7 @@ function buildMediaUrl(input) {
|
|
|
705
822
|
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
706
823
|
}
|
|
707
824
|
function verifyMediaSignature(input) {
|
|
708
|
-
|
|
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));
|
|
825
|
+
return verifyExpiringUrl(input);
|
|
715
826
|
}
|
|
716
827
|
//#endregion
|
|
717
828
|
//#region src/ha-export/membership.ts
|
|
@@ -768,6 +879,7 @@ var PushClient = class {
|
|
|
768
879
|
transport;
|
|
769
880
|
logger;
|
|
770
881
|
brokerId;
|
|
882
|
+
onLinkRestored;
|
|
771
883
|
/** topic → last value SENT. Dropped whenever the link is lost. */
|
|
772
884
|
stateCache = /* @__PURE__ */ new Map();
|
|
773
885
|
buffer = [];
|
|
@@ -781,6 +893,7 @@ var PushClient = class {
|
|
|
781
893
|
this.transport = options.transport;
|
|
782
894
|
this.logger = options.logger;
|
|
783
895
|
this.brokerId = options.brokerId;
|
|
896
|
+
this.onLinkRestored = options.onLinkRestored;
|
|
784
897
|
}
|
|
785
898
|
/** `false` once a POST has failed, `true` again on the next success. */
|
|
786
899
|
get healthy() {
|
|
@@ -882,6 +995,21 @@ var PushClient = class {
|
|
|
882
995
|
} });
|
|
883
996
|
this.dropped = 0;
|
|
884
997
|
this.droppedLoggedAt = 0;
|
|
998
|
+
/**
|
|
999
|
+
* Signalled AFTER the cache is gone and BEFORE anything else is sent:
|
|
1000
|
+
* the handler re-announces from inside this call, and a cache that
|
|
1001
|
+
* still held the old values would dedup away exactly those messages.
|
|
1002
|
+
* The handler's own failure is its business — it can never take the
|
|
1003
|
+
* transport, or the runner, down with it (D29).
|
|
1004
|
+
*/
|
|
1005
|
+
try {
|
|
1006
|
+
this.onLinkRestored?.(this.brokerId);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
this.logger.warn("ha-export: the re-announce handler threw, link is up but not repaired", { meta: {
|
|
1009
|
+
brokerId: this.brokerId,
|
|
1010
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1011
|
+
} });
|
|
1012
|
+
}
|
|
885
1013
|
}
|
|
886
1014
|
/**
|
|
887
1015
|
* A failed POST discards work, so it may not be silent — an operator
|
|
@@ -903,6 +1031,48 @@ var PushClient = class {
|
|
|
903
1031
|
} });
|
|
904
1032
|
}
|
|
905
1033
|
};
|
|
1034
|
+
var ReachabilityReconciler = class {
|
|
1035
|
+
options;
|
|
1036
|
+
minIntervalMs;
|
|
1037
|
+
timer = null;
|
|
1038
|
+
lastRunAt = null;
|
|
1039
|
+
pending = /* @__PURE__ */ new Set();
|
|
1040
|
+
disposed = false;
|
|
1041
|
+
constructor(options) {
|
|
1042
|
+
this.options = options;
|
|
1043
|
+
this.minIntervalMs = options.minIntervalMs ?? 3e4;
|
|
1044
|
+
}
|
|
1045
|
+
/** One link came back. Ask for a full re-announce. */
|
|
1046
|
+
request(brokerId) {
|
|
1047
|
+
if (this.disposed) return;
|
|
1048
|
+
this.pending.add(brokerId);
|
|
1049
|
+
if (this.timer !== null) return;
|
|
1050
|
+
const sinceLast = this.lastRunAt === null ? Number.POSITIVE_INFINITY : Date.now() - this.lastRunAt;
|
|
1051
|
+
const delayMs = Math.max(0, this.minIntervalMs - sinceLast);
|
|
1052
|
+
this.options.onScheduled?.({
|
|
1053
|
+
brokerId,
|
|
1054
|
+
delayMs
|
|
1055
|
+
});
|
|
1056
|
+
this.timer = setTimeout(() => {
|
|
1057
|
+
this.timer = null;
|
|
1058
|
+
this.fire();
|
|
1059
|
+
}, delayMs);
|
|
1060
|
+
}
|
|
1061
|
+
dispose() {
|
|
1062
|
+
this.disposed = true;
|
|
1063
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
1064
|
+
this.timer = null;
|
|
1065
|
+
this.pending.clear();
|
|
1066
|
+
}
|
|
1067
|
+
fire() {
|
|
1068
|
+
if (this.disposed) return;
|
|
1069
|
+
const brokerIds = [...this.pending].sort();
|
|
1070
|
+
this.pending.clear();
|
|
1071
|
+
if (brokerIds.length === 0) return;
|
|
1072
|
+
this.lastRunAt = Date.now();
|
|
1073
|
+
this.options.run({ brokerIds });
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
906
1076
|
//#endregion
|
|
907
1077
|
//#region src/ha-export/state-projector.ts
|
|
908
1078
|
/**
|
|
@@ -1160,9 +1330,32 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1160
1330
|
mediaBaseUrl = null;
|
|
1161
1331
|
reconcileTimer = null;
|
|
1162
1332
|
reconcileInFlight = false;
|
|
1333
|
+
/** A reason asked for while one was running. Re-run, never dropped. */
|
|
1334
|
+
reconcilePending = null;
|
|
1163
1335
|
lastError;
|
|
1164
1336
|
entityCount = 0;
|
|
1165
1337
|
unclassified = [];
|
|
1338
|
+
/**
|
|
1339
|
+
* A link that returned repairs NOW, not at the next periodic pass.
|
|
1340
|
+
*
|
|
1341
|
+
* `PushClient` drops its dedup cache the moment Home Assistant answers
|
|
1342
|
+
* again; without this, nothing re-sent anything and every entity sat
|
|
1343
|
+
* blank for up to `reconcileIntervalSec` (300s). Bounded and coalesced,
|
|
1344
|
+
* because the answer is the COMPLETE `cmps` set plus every value for
|
|
1345
|
+
* every exported device — see `reachability-reconcile.ts`.
|
|
1346
|
+
*/
|
|
1347
|
+
reachability = new ReachabilityReconciler({
|
|
1348
|
+
onScheduled: ({ brokerId, delayMs }) => {
|
|
1349
|
+
this.ctx.logger.info("ha-export: Home Assistant link returned, re-announcing everything", { meta: {
|
|
1350
|
+
brokerId,
|
|
1351
|
+
inMs: delayMs
|
|
1352
|
+
} });
|
|
1353
|
+
},
|
|
1354
|
+
run: ({ brokerIds }) => {
|
|
1355
|
+
this.reconcile("link-restored");
|
|
1356
|
+
this.ctx.logger.debug("ha-export: re-announce triggered by a returning link", { meta: { brokers: [...brokerIds] } });
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1166
1359
|
constructor() {
|
|
1167
1360
|
super({ ...DEFAULT_CONFIG });
|
|
1168
1361
|
}
|
|
@@ -1185,6 +1378,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1185
1378
|
this.startTimer();
|
|
1186
1379
|
this.ctx.addDisposer(async () => {
|
|
1187
1380
|
this.stopTimer();
|
|
1381
|
+
this.reachability.dispose();
|
|
1188
1382
|
for (const link of this.links.values()) await link.client.dispose();
|
|
1189
1383
|
this.links.clear();
|
|
1190
1384
|
});
|
|
@@ -1404,10 +1598,35 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1404
1598
|
* Re-resolves the brokers, rebuilds every plan, re-sends the COMPLETE
|
|
1405
1599
|
* `cmps` set for every exported device, and re-pushes every value.
|
|
1406
1600
|
* Events are an optimisation over this, never a substitute (D8/D11).
|
|
1601
|
+
*
|
|
1602
|
+
* A reconcile asked for while one is running is RE-RUN after it, never
|
|
1603
|
+
* dropped. It used to be dropped, and silently: a link that returned
|
|
1604
|
+
* mid-pass cleared its dedup cache and then lost the only re-announce
|
|
1605
|
+
* that would have refilled it, which is the exact defect this addon
|
|
1606
|
+
* just fixed, arriving through a different door.
|
|
1407
1607
|
*/
|
|
1408
1608
|
async reconcile(reason) {
|
|
1409
|
-
if (this.reconcileInFlight)
|
|
1609
|
+
if (this.reconcileInFlight) {
|
|
1610
|
+
this.reconcilePending = reason;
|
|
1611
|
+
this.ctx.logger.debug("ha-export: a reconcile is already running, re-running after it", { meta: { reason } });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1410
1614
|
this.reconcileInFlight = true;
|
|
1615
|
+
try {
|
|
1616
|
+
let next = reason;
|
|
1617
|
+
while (next !== null) {
|
|
1618
|
+
const current = next;
|
|
1619
|
+
this.reconcilePending = null;
|
|
1620
|
+
await this.runReconcile(current);
|
|
1621
|
+
next = this.reconcilePending;
|
|
1622
|
+
}
|
|
1623
|
+
} finally {
|
|
1624
|
+
this.reconcileInFlight = false;
|
|
1625
|
+
this.reconcilePending = null;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/** One pass. Never throws: a failure degrades this pass and no more. */
|
|
1629
|
+
async runReconcile(reason) {
|
|
1411
1630
|
const startedAt = Date.now();
|
|
1412
1631
|
try {
|
|
1413
1632
|
await this.refreshBrokers();
|
|
@@ -1492,8 +1711,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1492
1711
|
} catch (err) {
|
|
1493
1712
|
this.lastError = errMsg(err);
|
|
1494
1713
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
1495
|
-
} finally {
|
|
1496
|
-
this.reconcileInFlight = false;
|
|
1497
1714
|
}
|
|
1498
1715
|
}
|
|
1499
1716
|
/**
|
|
@@ -1689,7 +1906,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1689
1906
|
const client = new PushClient({
|
|
1690
1907
|
transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
|
|
1691
1908
|
logger: this.ctx.logger,
|
|
1692
|
-
brokerId: broker.id
|
|
1909
|
+
brokerId: broker.id,
|
|
1910
|
+
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
1693
1911
|
});
|
|
1694
1912
|
client.start();
|
|
1695
1913
|
this.links.set(broker.id, {
|