@camstack/addon-provider-homeassistant 1.2.15 → 1.2.16

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 CHANGED
@@ -1,5 +1,5 @@
1
1
  Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
2
- const require_dist = require("./dist-DJqSvADu.js");
2
+ const require_dist = require("./dist-DQdO_V3C.js");
3
3
  let node_crypto = require("node:crypto");
4
4
  let node_zlib = require("node:zlib");
5
5
  //#region src/ha-device-source.ts
package/dist/addon.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { $ as temperatureSensorCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as EventCategory, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, G as powerMeterCapability, H as notificationOutputCapability, I as imageCapability, J as pressureSensorCapability, K as prepareNotification, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as tamperCapability, R as lockControlCapability, S as colorCapability, St as url, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as smokeCapability, Y as scriptRunnerCapability, Z as switchCapability, _ as brokerCapability, _t as object, a as EnumSensorDateTimeFormatSchema, at as weatherCapability, b as carbonMonoxideCapability, bt as union, c as addonRoutesCapability, ct as DeviceFeature, d as ambientLightSensorCapability, dt as createEvent, et as updateCapability, f as automationControlCapability, ft as _enum, g as brightnessCapability, gt as number, h as binaryCapability, ht as literal, it as waterHeaterCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceRole, m as bestLocationMatch, mt as discriminatedUnion, n as BaseDeviceProvider, nt as valveCapability, o as TargetSchema, ot as errMsg, p as batteryCapability, pt as array, q as presenceCapability, rt as vibrationCapability, s as accessoriesCapability, t as BaseDevice, tt as vacuumControlCapability, u as alarmPanelCapability, ut as DeviceType, v as buildAddonRouteProvider, vt as record, w as contactCapability, x as climateControlCapability, xt as unknown, y as buttonCapability, yt as string, z as mediaPlayerCapability } from "./dist-DdyXnBOa.mjs";
1
+ import { $ as temperatureSensorCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as EventCategory, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, G as powerMeterCapability, H as notificationOutputCapability, I as imageCapability, J as pressureSensorCapability, K as prepareNotification, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as tamperCapability, R as lockControlCapability, S as colorCapability, St as url, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as smokeCapability, Y as scriptRunnerCapability, Z as switchCapability, _ as brokerCapability, _t as object, a as EnumSensorDateTimeFormatSchema, at as weatherCapability, b as carbonMonoxideCapability, bt as union, c as addonRoutesCapability, ct as DeviceFeature, d as ambientLightSensorCapability, dt as createEvent, et as updateCapability, f as automationControlCapability, ft as _enum, g as brightnessCapability, gt as number, h as binaryCapability, ht as literal, it as waterHeaterCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceRole, m as bestLocationMatch, mt as discriminatedUnion, n as BaseDeviceProvider, nt as valveCapability, o as TargetSchema, ot as errMsg, p as batteryCapability, pt as array, q as presenceCapability, rt as vibrationCapability, s as accessoriesCapability, t as BaseDevice, tt as vacuumControlCapability, u as alarmPanelCapability, ut as DeviceType, v as buildAddonRouteProvider, vt as record, w as contactCapability, x as climateControlCapability, xt as unknown, y as buttonCapability, yt as string, z as mediaPlayerCapability } from "./dist-B04tcTnS.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
4
4
  //#region src/ha-device-source.ts
@@ -18301,6 +18301,37 @@ DeviceType.Camera, method(object({
18301
18301
  lastCapturedAt: number().nullable(),
18302
18302
  cacheAgeMs: number().nullable(),
18303
18303
  etag: string().nullable()
18304
+ }))), systemMethod(object({
18305
+ /** The tiles a surface is actually rendering. One entry per (device,
18306
+ * width) the caller will paint — the width is snapped to the server's
18307
+ * ladder and becomes part of the link's SIGNED identity. */
18308
+ targets: array(object({
18309
+ deviceId: number(),
18310
+ /** Target width in px. Omit for the frame as captured — correct
18311
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18312
+ width: number().int().positive().optional()
18313
+ })).min(1).max(200) }), array(object({
18314
+ deviceId: number(),
18315
+ /** Root-relative signed path, or null when the link plane is not
18316
+ * served (no data-plane facility). Present even for a device that has
18317
+ * never captured — the request is what triggers the first one (D94). */
18318
+ url: string().nullable(),
18319
+ /** Epoch ms of the frame this link serves. Null = never captured.
18320
+ * THE honest age: the tRPC path carried none before this. */
18321
+ capturedAt: number().nullable(),
18322
+ /** Age of that frame at the moment the answer was built. */
18323
+ ageMs: number().nullable(),
18324
+ /** Epoch ms after which `url` stops verifying. */
18325
+ expiresAt: number().nullable(),
18326
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18327
+ width: number().nullable(),
18328
+ /** The device has never produced a frame. An empty state, not a
18329
+ * failure — and never a reason to withhold the link (D94). */
18330
+ neverCaptured: boolean(),
18331
+ /** A sleeping battery camera: the frame is deliberately stale and will
18332
+ * NOT refresh in the background. A surface should say so rather than
18333
+ * present it as current. */
18334
+ sleeping: boolean()
18304
18335
  })));
18305
18336
  /**
18306
18337
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -31570,6 +31601,12 @@ Object.freeze({
31570
31601
  addonId: null,
31571
31602
  access: "view"
31572
31603
  },
31604
+ "snapshot.getSnapshotLinks": {
31605
+ capName: "snapshot",
31606
+ capScope: "device",
31607
+ addonId: null,
31608
+ access: "view"
31609
+ },
31573
31610
  "snapshot.getSnapshotOverview": {
31574
31611
  capName: "snapshot",
31575
31612
  capScope: "device",
@@ -18301,6 +18301,37 @@ DeviceType.Camera, method(object({
18301
18301
  lastCapturedAt: number().nullable(),
18302
18302
  cacheAgeMs: number().nullable(),
18303
18303
  etag: string().nullable()
18304
+ }))), systemMethod(object({
18305
+ /** The tiles a surface is actually rendering. One entry per (device,
18306
+ * width) the caller will paint — the width is snapped to the server's
18307
+ * ladder and becomes part of the link's SIGNED identity. */
18308
+ targets: array(object({
18309
+ deviceId: number(),
18310
+ /** Target width in px. Omit for the frame as captured — correct
18311
+ * for a full-bleed surface, wrong (and expensive) for a grid. */
18312
+ width: number().int().positive().optional()
18313
+ })).min(1).max(200) }), array(object({
18314
+ deviceId: number(),
18315
+ /** Root-relative signed path, or null when the link plane is not
18316
+ * served (no data-plane facility). Present even for a device that has
18317
+ * never captured — the request is what triggers the first one (D94). */
18318
+ url: string().nullable(),
18319
+ /** Epoch ms of the frame this link serves. Null = never captured.
18320
+ * THE honest age: the tRPC path carried none before this. */
18321
+ capturedAt: number().nullable(),
18322
+ /** Age of that frame at the moment the answer was built. */
18323
+ ageMs: number().nullable(),
18324
+ /** Epoch ms after which `url` stops verifying. */
18325
+ expiresAt: number().nullable(),
18326
+ /** Ladder rung the bytes are at; null = the frame as captured. */
18327
+ width: number().nullable(),
18328
+ /** The device has never produced a frame. An empty state, not a
18329
+ * failure — and never a reason to withhold the link (D94). */
18330
+ neverCaptured: boolean(),
18331
+ /** A sleeping battery camera: the frame is deliberately stale and will
18332
+ * NOT refresh in the background. A surface should say so rather than
18333
+ * present it as current. */
18334
+ sleeping: boolean()
18304
18335
  })));
18305
18336
  /**
18306
18337
  * `sso-bridge` — internal hub-only cap that lets SSO-style auth
@@ -31570,6 +31601,12 @@ Object.freeze({
31570
31601
  addonId: null,
31571
31602
  access: "view"
31572
31603
  },
31604
+ "snapshot.getSnapshotLinks": {
31605
+ capName: "snapshot",
31606
+ capScope: "device",
31607
+ addonId: null,
31608
+ access: "view"
31609
+ },
31573
31610
  "snapshot.getSnapshotOverview": {
31574
31611
  capName: "snapshot",
31575
31612
  capScope: "device",
@@ -2,7 +2,8 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DJqSvADu.js");
5
+ //#endregion
6
+ const require_dist = require("../dist-DQdO_V3C.js");
6
7
  let node_crypto = require("node:crypto");
7
8
  //#region src/ha-export/topics.ts
8
9
  /**
@@ -129,21 +130,25 @@ var ZONE_MACROS = [
129
130
  "animal"
130
131
  ];
131
132
  /**
132
- * ~73 entities per camera with 3 zones. Only the ones people automate on
133
- * arrive enabled; everything else is registered and switched off, for the
134
- * operator to enable when they want it.
133
+ * `enabled_by_default: false` is a DECISION, taken per entity, never
134
+ * inferred from its name or its platform.
135
+ *
136
+ * It used to be inferred — a name-and-platform heuristic written for the
137
+ * camera catalog (`endsWith('_detected')`, `platform === 'switch'`) and
138
+ * then applied to every device the catalog builds. On a camera it is
139
+ * right: three zones is ~73 entities and the fleet is ~880, so only what
140
+ * an operator automates on arrives enabled. On a DERIVED device it was
141
+ * simply wrong — every sensor, every number and the alarm panel matched
142
+ * nothing and shipped switched off, so a temperature sensor exported its
143
+ * temperature disabled and the operator found the thing they installed
144
+ * the integration for turned off. Measured on the live hub, the whole
145
+ * derived half is **228 entities across 115 devices — 2 on average, 3 at
146
+ * most**: there is no fan-out there to protect against.
147
+ *
148
+ * So the flag is a required field on every spec. A new entity — or the
149
+ * synthetic devices the design addendum still owes — cannot inherit a
150
+ * camera's pressure valve by accident.
135
151
  */
136
- var ENABLED_BY_DEFAULT_SUFFIXES = ["detected"];
137
- var ENABLED_BY_DEFAULT_ENTITIES = [
138
- "triggered",
139
- "online",
140
- "last_image"
141
- ];
142
- function isEnabledByDefault(entity, platform) {
143
- if (platform === "switch" || platform === "button" || platform === "select") return true;
144
- if (ENABLED_BY_DEFAULT_ENTITIES.includes(entity)) return true;
145
- return ENABLED_BY_DEFAULT_SUFFIXES.some((suffix) => entity.endsWith(`_${suffix}`));
146
- }
147
152
  /**
148
153
  * The snooze surface, as a `select`.
149
154
  *
@@ -179,7 +184,6 @@ var PTZ_BUTTONS = [
179
184
  ];
180
185
  function buildComponent(device, spec) {
181
186
  const deviceKey = deviceKeyFor(device.stableId);
182
- const enabled = isEnabledByDefault(spec.entity, spec.platform);
183
187
  return {
184
188
  platform: spec.platform,
185
189
  unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
@@ -191,7 +195,7 @@ function buildComponent(device, spec) {
191
195
  ...spec.icon !== void 0 ? { icon: spec.icon } : {},
192
196
  ...spec.options !== void 0 ? { options: spec.options } : {},
193
197
  ...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
194
- ...enabled ? {} : { enabled_by_default: false },
198
+ ...spec.enabledByDefault ? {} : { enabled_by_default: false },
195
199
  ...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
196
200
  payload_on: "true",
197
201
  payload_off: "false"
@@ -207,6 +211,11 @@ function deviceBlock(device) {
207
211
  mdl: device.model ?? device.type
208
212
  };
209
213
  }
214
+ /**
215
+ * The fan-out, and the reason the valve exists: zones × zone-macros × 4.
216
+ * The detector is what an operator automates on; the other three are
217
+ * detail they can switch on per entity.
218
+ */
210
219
  function zoneSpecs(zone) {
211
220
  const slug = toSlug(zone.id);
212
221
  const specs = [];
@@ -217,24 +226,28 @@ function zoneSpecs(zone) {
217
226
  platform: "binary_sensor",
218
227
  label: `${label} detected`,
219
228
  uniqueSuffix: `${zone.id}_${macro}_detected`,
220
- deviceClass: "motion"
229
+ deviceClass: "motion",
230
+ enabledByDefault: true
221
231
  }, {
222
232
  entity: `${slug}_${macro}_last_image`,
223
233
  platform: "image",
224
234
  label: `${label} last image`,
225
- uniqueSuffix: `${zone.id}_${macro}_last_image`
235
+ uniqueSuffix: `${zone.id}_${macro}_last_image`,
236
+ enabledByDefault: false
226
237
  }, {
227
238
  entity: `${slug}_${macro}_last_detection`,
228
239
  platform: "sensor",
229
240
  label: `${label} last detection`,
230
241
  uniqueSuffix: `${zone.id}_${macro}_last_detection`,
231
- deviceClass: "timestamp"
242
+ deviceClass: "timestamp",
243
+ enabledByDefault: false
232
244
  }, {
233
245
  entity: `${slug}_${macro}_objects`,
234
246
  platform: "sensor",
235
247
  label: `${label} objects`,
236
248
  uniqueSuffix: `${zone.id}_${macro}_objects`,
237
- icon: "mdi:counter"
249
+ icon: "mdi:counter",
250
+ enabledByDefault: false
238
251
  });
239
252
  }
240
253
  return specs;
@@ -245,26 +258,30 @@ function cameraSpecs(device) {
245
258
  entity: "triggered",
246
259
  platform: "binary_sensor",
247
260
  label: "Triggered",
248
- deviceClass: "motion"
261
+ deviceClass: "motion",
262
+ enabledByDefault: true
249
263
  },
250
264
  {
251
265
  entity: "online",
252
266
  platform: "binary_sensor",
253
267
  label: "Online",
254
268
  deviceClass: "connectivity",
255
- entityCategory: "diagnostic"
269
+ entityCategory: "diagnostic",
270
+ enabledByDefault: true
256
271
  },
257
272
  {
258
273
  entity: "last_image",
259
274
  platform: "image",
260
- label: "Last image"
275
+ label: "Last image",
276
+ enabledByDefault: true
261
277
  },
262
278
  {
263
279
  entity: "last_detection",
264
280
  platform: "sensor",
265
281
  label: "Last detection",
266
282
  deviceClass: "timestamp",
267
- icon: "mdi:clock"
283
+ icon: "mdi:clock",
284
+ enabledByDefault: false
268
285
  }
269
286
  ];
270
287
  if (device.slices.includes("battery")) specs.push({
@@ -273,18 +290,21 @@ function cameraSpecs(device) {
273
290
  label: "Battery",
274
291
  deviceClass: "battery",
275
292
  unit: "%",
276
- entityCategory: "diagnostic"
293
+ entityCategory: "diagnostic",
294
+ enabledByDefault: false
277
295
  }, {
278
296
  entity: "charger",
279
297
  platform: "binary_sensor",
280
298
  label: "Charging",
281
299
  deviceClass: "battery_charging",
282
- entityCategory: "diagnostic"
300
+ entityCategory: "diagnostic",
301
+ enabledByDefault: false
283
302
  }, {
284
303
  entity: "sleeping",
285
304
  platform: "binary_sensor",
286
305
  label: "Sleeping",
287
- entityCategory: "diagnostic"
306
+ entityCategory: "diagnostic",
307
+ enabledByDefault: false
288
308
  });
289
309
  /**
290
310
  * One HA switch per AVAILABLE camera switch, rendered from
@@ -302,28 +322,32 @@ function cameraSpecs(device) {
302
322
  entity: toSlug(sw.id),
303
323
  platform: "switch",
304
324
  label: sw.label,
305
- writable: true
325
+ writable: true,
326
+ enabledByDefault: true
306
327
  });
307
328
  }
308
329
  if (device.features.includes("rebootable")) specs.push({
309
330
  entity: "reboot",
310
331
  platform: "button",
311
332
  label: "Reboot",
312
- writable: true
333
+ writable: true,
334
+ enabledByDefault: true
313
335
  });
314
336
  if (device.boundCaps.includes("ptz")) {
315
337
  for (const entity of PTZ_BUTTONS) specs.push({
316
338
  entity,
317
339
  platform: "button",
318
340
  label: humanise(entity),
319
- writable: true
341
+ writable: true,
342
+ enabledByDefault: true
320
343
  });
321
344
  specs.push({
322
345
  entity: "ptz_preset",
323
346
  platform: "select",
324
347
  label: "PTZ preset",
325
348
  writable: true,
326
- options: device.ptzPresets ?? []
349
+ options: device.ptzPresets ?? [],
350
+ enabledByDefault: true
327
351
  });
328
352
  }
329
353
  specs.push({
@@ -331,7 +355,8 @@ function cameraSpecs(device) {
331
355
  platform: "select",
332
356
  label: "Snooze notifications",
333
357
  writable: true,
334
- options: SNOOZE_OPTIONS
358
+ options: SNOOZE_OPTIONS,
359
+ enabledByDefault: true
335
360
  });
336
361
  for (const macro of EXPORTED_MACROS) {
337
362
  const label = humanise(macro);
@@ -339,22 +364,26 @@ function cameraSpecs(device) {
339
364
  entity: `${macro}_detected`,
340
365
  platform: "binary_sensor",
341
366
  label: `${label} detected`,
342
- deviceClass: "motion"
367
+ deviceClass: "motion",
368
+ enabledByDefault: true
343
369
  }, {
344
370
  entity: `${macro}_last_image`,
345
371
  platform: "image",
346
- label: `${label} last image`
372
+ label: `${label} last image`,
373
+ enabledByDefault: false
347
374
  }, {
348
375
  entity: `${macro}_last_detection`,
349
376
  platform: "sensor",
350
377
  label: `${label} last detection`,
351
- deviceClass: "timestamp"
378
+ deviceClass: "timestamp",
379
+ enabledByDefault: false
352
380
  });
353
381
  if (LABELLED_MACROS.includes(macro)) specs.push({
354
382
  entity: `${macro}_last_label`,
355
383
  platform: "sensor",
356
384
  label: `${label} last label`,
357
- icon: "mdi:tag"
385
+ icon: "mdi:tag",
386
+ enabledByDefault: false
358
387
  });
359
388
  }
360
389
  for (const zone of device.zones) specs.push(...zoneSpecs(zone));
@@ -539,6 +568,22 @@ var CAPS_NOT_EXPORTED = [
539
568
  {
540
569
  cap: "reboot",
541
570
  reason: "Rendered as a button, gated on the `rebootable` feature rather than the binding."
571
+ },
572
+ {
573
+ cap: "device-status",
574
+ 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."
575
+ },
576
+ {
577
+ cap: "feature-probe",
578
+ reason: "The runtime truth about what a device CAN do — it decides which entities exist, so exporting it as one would be circular."
579
+ },
580
+ {
581
+ cap: "device-ops",
582
+ reason: "The per-device operations envelope; its members surface individually (e.g. reboot)."
583
+ },
584
+ {
585
+ cap: "device-discovery",
586
+ reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
542
587
  }
543
588
  ];
544
589
  /** A capability in neither list — the thing the guard exists to surface. */
@@ -546,14 +591,28 @@ function unclassifiedCaps(caps) {
546
591
  const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
547
592
  return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
548
593
  }
549
- /** The derived half: every non-camera kind, from the caps it declares. */
594
+ /**
595
+ * The derived half: every non-camera kind, from the caps it declares.
596
+ *
597
+ * **Everything here arrives ENABLED**, and that is the rule rather than
598
+ * an oversight in the other direction. A cap-derived entity IS the reason
599
+ * the device exists — a temperature sensor's temperature, a light's
600
+ * brightness, an alarm panel's state — and there is no fan-out to
601
+ * protect against: measured on the live hub, 115 non-camera devices
602
+ * produce 228 entities, 2 per device on average and 3 at most, because a
603
+ * device declares one or two mapped caps and the rest of its bindings
604
+ * (`device-status`, `feature-probe`, `device-ops`) carry no entity at
605
+ * all. The camera valve is for 73-per-device; this is not that problem,
606
+ * and exporting the operator's temperature switched off was the bug.
607
+ */
550
608
  function buildDerivedPlan(device) {
551
609
  const specs = [{
552
610
  entity: "online",
553
611
  platform: "binary_sensor",
554
612
  label: "Online",
555
613
  deviceClass: "connectivity",
556
- entityCategory: "diagnostic"
614
+ entityCategory: "diagnostic",
615
+ enabledByDefault: true
557
616
  }];
558
617
  for (const cap of device.boundCaps) {
559
618
  const mapping = CAP_ENTITY_MAP[cap];
@@ -562,6 +621,7 @@ function buildDerivedPlan(device) {
562
621
  entity: toSlug(cap),
563
622
  platform: mapping.platform,
564
623
  label: humanise(cap),
624
+ enabledByDefault: true,
565
625
  ...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
566
626
  ...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
567
627
  ...mapping.writable === true ? { writable: true } : {}
@@ -678,6 +738,59 @@ function resolveCommand(target, entity, value) {
678
738
  }
679
739
  return null;
680
740
  }
741
+ /**
742
+ * The ONE derivation of "a signed, expiring URL".
743
+ *
744
+ * This repo mints unguessable, self-expiring links in three places now — the
745
+ * notification artifact plane, the Home Assistant media plane, and the snapshot
746
+ * link plane. The first two grew independently and are byte-identical logic
747
+ * (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
748
+ * each restating the crypto locally because **addons never import each other**.
749
+ *
750
+ * That reason is real, and the conclusion drawn from it was wrong. Two copies of
751
+ * a signing scheme is how one of them quietly ends up with a different TTL, a
752
+ * different compare, or a missing expiry check, and nothing fails until a link
753
+ * that should have died keeps working. The fix is the same one D52 applies to
754
+ * crop geometry: one derivation, in a place every addon may depend on. A
755
+ * framework package is exactly that place — `@camstack/types/node`, off the root
756
+ * entry because `node:crypto` must never be traversed by a browser bundler.
757
+ *
758
+ * What this module deliberately does NOT decide: the TTL, the base URL, the
759
+ * shape of `id`, and the access level of the route. Those are per-plane policy
760
+ * and each caller states them where a reader can see them.
761
+ */
762
+ /**
763
+ * The signature over `(id, expMs)`.
764
+ *
765
+ * `id` is whatever the plane uses to name the thing being served — an artifact
766
+ * id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
767
+ * must not put a `:` inside a field whose boundary matters; where a plane has
768
+ * more than one field, it composes them itself and owns that ambiguity.
769
+ */
770
+ function signExpiringUrl(secret, id, expMs) {
771
+ return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
772
+ }
773
+ /**
774
+ * Verify a request's `(id, exp, sig)`.
775
+ *
776
+ * **Expiry is checked BEFORE the compare**, so an expired link is refused
777
+ * whether or not its signature is valid — a leaked URL stops working on its own
778
+ * and cannot be kept alive by holding a correct signature. The compare itself is
779
+ * constant-time so a public route cannot be probed for the signature byte by
780
+ * byte.
781
+ */
782
+ function verifyExpiringUrl(input) {
783
+ const { secret, id, exp, sig, nowMs } = input;
784
+ if (exp === void 0 || sig === void 0) return false;
785
+ const expMs = typeof exp === "number" ? exp : Number(exp);
786
+ if (!Number.isFinite(expMs)) return false;
787
+ if (expMs <= nowMs) return false;
788
+ const expected = signExpiringUrl(secret, id, expMs);
789
+ const a = Buffer.from(expected, "utf8");
790
+ const b = Buffer.from(sig, "utf8");
791
+ if (a.length !== b.length) return false;
792
+ return (0, node_crypto.timingSafeEqual)(a, b);
793
+ }
681
794
  //#endregion
682
795
  //#region src/ha-export/media-url.ts
683
796
  /**
@@ -690,18 +803,23 @@ function resolveCommand(target, entity, value) {
690
803
  *
691
804
  * The link is signed rather than protected by a session, because the
692
805
  * fetcher is HA's own frontend or a phone — neither of which holds a
693
- * camstack token. This mirrors the notification artifact plane's scheme
694
- * (that module lives in another addon and addons never import each other,
695
- * so the crypto is restated here rather than reached for).
806
+ * camstack token.
696
807
  *
697
- * Expiry is checked BEFORE the constant-time compare: an expired link is
698
- * refused whether or not its signature is valid, so a leaked URL stops
699
- * working on its own.
808
+ * The HMAC itself is NOT restated here. It used to be this module and the
809
+ * notification artifact plane each carried their own copy, because addons never
810
+ * import each other and neither could reach the other's. That reasoning was
811
+ * right and the conclusion was wrong: both now delegate to the single
812
+ * derivation in `@camstack/types/node`, which every addon may depend on. What
813
+ * stays local is policy — the TTL below, and the id this plane signs.
814
+ *
815
+ * Expiry is checked BEFORE the constant-time compare (in the shared verifier):
816
+ * an expired link is refused whether or not its signature is valid, so a leaked
817
+ * URL stops working on its own.
700
818
  */
701
819
  /** How long a minted link stays valid. Long enough for HA to render it. */
702
820
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
703
821
  function signMedia(secret, id, expMs) {
704
- return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${expMs}`).digest("hex");
822
+ return signExpiringUrl(secret, id, expMs);
705
823
  }
706
824
  function buildMediaUrl(input) {
707
825
  const base = input.baseUrl.replace(/\/+$/, "");
@@ -709,13 +827,7 @@ function buildMediaUrl(input) {
709
827
  return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
710
828
  }
711
829
  function verifyMediaSignature(input) {
712
- if (input.exp === void 0 || input.sig === void 0) return false;
713
- const expMs = Number(input.exp);
714
- if (!Number.isFinite(expMs)) return false;
715
- if (expMs < input.nowMs) return false;
716
- const expected = signMedia(input.secret, input.id, expMs);
717
- if (expected.length !== input.sig.length) return false;
718
- return (0, node_crypto.timingSafeEqual)(Buffer.from(expected), Buffer.from(input.sig));
830
+ return verifyExpiringUrl(input);
719
831
  }
720
832
  //#endregion
721
833
  //#region src/ha-export/membership.ts
@@ -772,6 +884,7 @@ var PushClient = class {
772
884
  transport;
773
885
  logger;
774
886
  brokerId;
887
+ onLinkRestored;
775
888
  /** topic → last value SENT. Dropped whenever the link is lost. */
776
889
  stateCache = /* @__PURE__ */ new Map();
777
890
  buffer = [];
@@ -785,6 +898,7 @@ var PushClient = class {
785
898
  this.transport = options.transport;
786
899
  this.logger = options.logger;
787
900
  this.brokerId = options.brokerId;
901
+ this.onLinkRestored = options.onLinkRestored;
788
902
  }
789
903
  /** `false` once a POST has failed, `true` again on the next success. */
790
904
  get healthy() {
@@ -886,6 +1000,21 @@ var PushClient = class {
886
1000
  } });
887
1001
  this.dropped = 0;
888
1002
  this.droppedLoggedAt = 0;
1003
+ /**
1004
+ * Signalled AFTER the cache is gone and BEFORE anything else is sent:
1005
+ * the handler re-announces from inside this call, and a cache that
1006
+ * still held the old values would dedup away exactly those messages.
1007
+ * The handler's own failure is its business — it can never take the
1008
+ * transport, or the runner, down with it (D29).
1009
+ */
1010
+ try {
1011
+ this.onLinkRestored?.(this.brokerId);
1012
+ } catch (err) {
1013
+ this.logger.warn("ha-export: the re-announce handler threw, link is up but not repaired", { meta: {
1014
+ brokerId: this.brokerId,
1015
+ error: err instanceof Error ? err.message : String(err)
1016
+ } });
1017
+ }
889
1018
  }
890
1019
  /**
891
1020
  * A failed POST discards work, so it may not be silent — an operator
@@ -907,6 +1036,48 @@ var PushClient = class {
907
1036
  } });
908
1037
  }
909
1038
  };
1039
+ var ReachabilityReconciler = class {
1040
+ options;
1041
+ minIntervalMs;
1042
+ timer = null;
1043
+ lastRunAt = null;
1044
+ pending = /* @__PURE__ */ new Set();
1045
+ disposed = false;
1046
+ constructor(options) {
1047
+ this.options = options;
1048
+ this.minIntervalMs = options.minIntervalMs ?? 3e4;
1049
+ }
1050
+ /** One link came back. Ask for a full re-announce. */
1051
+ request(brokerId) {
1052
+ if (this.disposed) return;
1053
+ this.pending.add(brokerId);
1054
+ if (this.timer !== null) return;
1055
+ const sinceLast = this.lastRunAt === null ? Number.POSITIVE_INFINITY : Date.now() - this.lastRunAt;
1056
+ const delayMs = Math.max(0, this.minIntervalMs - sinceLast);
1057
+ this.options.onScheduled?.({
1058
+ brokerId,
1059
+ delayMs
1060
+ });
1061
+ this.timer = setTimeout(() => {
1062
+ this.timer = null;
1063
+ this.fire();
1064
+ }, delayMs);
1065
+ }
1066
+ dispose() {
1067
+ this.disposed = true;
1068
+ if (this.timer !== null) clearTimeout(this.timer);
1069
+ this.timer = null;
1070
+ this.pending.clear();
1071
+ }
1072
+ fire() {
1073
+ if (this.disposed) return;
1074
+ const brokerIds = [...this.pending].sort();
1075
+ this.pending.clear();
1076
+ if (brokerIds.length === 0) return;
1077
+ this.lastRunAt = Date.now();
1078
+ this.options.run({ brokerIds });
1079
+ }
1080
+ };
910
1081
  //#endregion
911
1082
  //#region src/ha-export/state-projector.ts
912
1083
  /**
@@ -1164,9 +1335,32 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1164
1335
  mediaBaseUrl = null;
1165
1336
  reconcileTimer = null;
1166
1337
  reconcileInFlight = false;
1338
+ /** A reason asked for while one was running. Re-run, never dropped. */
1339
+ reconcilePending = null;
1167
1340
  lastError;
1168
1341
  entityCount = 0;
1169
1342
  unclassified = [];
1343
+ /**
1344
+ * A link that returned repairs NOW, not at the next periodic pass.
1345
+ *
1346
+ * `PushClient` drops its dedup cache the moment Home Assistant answers
1347
+ * again; without this, nothing re-sent anything and every entity sat
1348
+ * blank for up to `reconcileIntervalSec` (300s). Bounded and coalesced,
1349
+ * because the answer is the COMPLETE `cmps` set plus every value for
1350
+ * every exported device — see `reachability-reconcile.ts`.
1351
+ */
1352
+ reachability = new ReachabilityReconciler({
1353
+ onScheduled: ({ brokerId, delayMs }) => {
1354
+ this.ctx.logger.info("ha-export: Home Assistant link returned, re-announcing everything", { meta: {
1355
+ brokerId,
1356
+ inMs: delayMs
1357
+ } });
1358
+ },
1359
+ run: ({ brokerIds }) => {
1360
+ this.reconcile("link-restored");
1361
+ this.ctx.logger.debug("ha-export: re-announce triggered by a returning link", { meta: { brokers: [...brokerIds] } });
1362
+ }
1363
+ });
1170
1364
  constructor() {
1171
1365
  super({ ...DEFAULT_CONFIG });
1172
1366
  }
@@ -1189,6 +1383,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1189
1383
  this.startTimer();
1190
1384
  this.ctx.addDisposer(async () => {
1191
1385
  this.stopTimer();
1386
+ this.reachability.dispose();
1192
1387
  for (const link of this.links.values()) await link.client.dispose();
1193
1388
  this.links.clear();
1194
1389
  });
@@ -1408,10 +1603,35 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1408
1603
  * Re-resolves the brokers, rebuilds every plan, re-sends the COMPLETE
1409
1604
  * `cmps` set for every exported device, and re-pushes every value.
1410
1605
  * Events are an optimisation over this, never a substitute (D8/D11).
1606
+ *
1607
+ * A reconcile asked for while one is running is RE-RUN after it, never
1608
+ * dropped. It used to be dropped, and silently: a link that returned
1609
+ * mid-pass cleared its dedup cache and then lost the only re-announce
1610
+ * that would have refilled it, which is the exact defect this addon
1611
+ * just fixed, arriving through a different door.
1411
1612
  */
1412
1613
  async reconcile(reason) {
1413
- if (this.reconcileInFlight) return;
1614
+ if (this.reconcileInFlight) {
1615
+ this.reconcilePending = reason;
1616
+ this.ctx.logger.debug("ha-export: a reconcile is already running, re-running after it", { meta: { reason } });
1617
+ return;
1618
+ }
1414
1619
  this.reconcileInFlight = true;
1620
+ try {
1621
+ let next = reason;
1622
+ while (next !== null) {
1623
+ const current = next;
1624
+ this.reconcilePending = null;
1625
+ await this.runReconcile(current);
1626
+ next = this.reconcilePending;
1627
+ }
1628
+ } finally {
1629
+ this.reconcileInFlight = false;
1630
+ this.reconcilePending = null;
1631
+ }
1632
+ }
1633
+ /** One pass. Never throws: a failure degrades this pass and no more. */
1634
+ async runReconcile(reason) {
1415
1635
  const startedAt = Date.now();
1416
1636
  try {
1417
1637
  await this.refreshBrokers();
@@ -1496,8 +1716,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1496
1716
  } catch (err) {
1497
1717
  this.lastError = errMsg(err);
1498
1718
  this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
1499
- } finally {
1500
- this.reconcileInFlight = false;
1501
1719
  }
1502
1720
  }
1503
1721
  /**
@@ -1693,7 +1911,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
1693
1911
  const client = new PushClient({
1694
1912
  transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
1695
1913
  logger: this.ctx.logger,
1696
- brokerId: broker.id
1914
+ brokerId: broker.id,
1915
+ onLinkRestored: (brokerId) => this.reachability.request(brokerId)
1697
1916
  });
1698
1917
  client.start();
1699
1918
  this.links.set(broker.id, {
@@ -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-DdyXnBOa.mjs";
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-B04tcTnS.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
- * ~73 entities per camera with 3 zones. Only the ones people automate on
129
- * arrive enabled; everything else is registered and switched off, for the
130
- * operator to enable when they want it.
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
- ...enabled ? {} : { enabled_by_default: false },
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
- /** The derived half: every non-camera kind, from the caps it declares. */
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. This mirrors the notification artifact plane's scheme
690
- * (that module lives in another addon and addons never import each other,
691
- * so the crypto is restated here rather than reached for).
801
+ * camstack token.
692
802
  *
693
- * Expiry is checked BEFORE the constant-time compare: an expired link is
694
- * refused whether or not its signature is valid, so a leaked URL stops
695
- * working on its own.
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 createHmac("sha256", secret).update(`${id}:${expMs}`).digest("hex");
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
- if (input.exp === void 0 || input.sig === void 0) return false;
709
- const expMs = Number(input.exp);
710
- if (!Number.isFinite(expMs)) return false;
711
- if (expMs < input.nowMs) return false;
712
- const expected = signMedia(input.secret, input.id, expMs);
713
- if (expected.length !== input.sig.length) return false;
714
- return timingSafeEqual(Buffer.from(expected), Buffer.from(input.sig));
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) return;
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, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.15",
3
+ "version": "1.2.16",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",