@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.
@@ -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-DIa87XAf.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, {