@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.
@@ -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-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
- * ~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.17",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",