@camstack/addon-provider-homeassistant 1.2.602 → 1.2.604

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 { $ as oauthIntegrationCapability, Bt as EventCategory, C as buildAddonRouteProvider, Et as nodePin, I as deviceExportCapability, It as string, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema } from "../dist-Dcum8auE.mjs";
1
+ import { $ as oauthIntegrationCapability, Bt as EventCategory, C as buildAddonRouteProvider, Et as nodePin, I as deviceExportCapability, It as string, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema } from "../dist-CA_cJwK-.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -2445,6 +2445,187 @@ var CAP_ENTITY_MAP = {
2445
2445
  }
2446
2446
  }
2447
2447
  },
2448
+ /**
2449
+ * A dishwasher. The headline is the appliance's OWN operation state (`Run`, `DelayedStart`,
2450
+ * `ActionRequired`, …) — not the coarse `state`, which folds nine states into six.
2451
+ *
2452
+ * Every flag is a `binary_sensor` / `switch` whose value is `null` until the appliance has
2453
+ * said: the projector publishes nothing for a null, so Home Assistant shows `unknown`, never
2454
+ * `off` (the slice is nullable for exactly this reason).
2455
+ *
2456
+ * Not mapped, by design: `availablePrograms` / `activeEvents` are arrays, and the program
2457
+ * list is the appliance's own vocabulary (a static `select` would offer programs it rejects);
2458
+ * `selectedProgram` / `program` are exported read-only. The raised events are separate
2459
+ * entities already — one `binary_sensor` per event and the events emitter.
2460
+ */
2461
+ dishwasher: {
2462
+ platform: "sensor",
2463
+ field: "operationState",
2464
+ icon: "mdi:dishwasher",
2465
+ extras: {
2466
+ program: {
2467
+ platform: "sensor",
2468
+ field: "program",
2469
+ label: "Active program",
2470
+ icon: "mdi:dishwasher"
2471
+ },
2472
+ "selected-program": {
2473
+ platform: "sensor",
2474
+ field: "selectedProgram",
2475
+ label: "Selected program",
2476
+ enabledByDefault: false
2477
+ },
2478
+ "remaining-time": {
2479
+ platform: "sensor",
2480
+ field: "remainingSeconds",
2481
+ label: "Remaining time",
2482
+ deviceClass: "duration",
2483
+ unit: "s"
2484
+ },
2485
+ "delayed-start": {
2486
+ platform: "sensor",
2487
+ field: "delayedStartSeconds",
2488
+ label: "Delayed start",
2489
+ deviceClass: "duration",
2490
+ unit: "s",
2491
+ enabledByDefault: false
2492
+ },
2493
+ progress: {
2494
+ platform: "sensor",
2495
+ field: "progressPercent",
2496
+ label: "Progress",
2497
+ unit: "%",
2498
+ enabledByDefault: false
2499
+ },
2500
+ door: {
2501
+ platform: "binary_sensor",
2502
+ field: "doorOpen",
2503
+ label: "Door",
2504
+ deviceClass: "door"
2505
+ },
2506
+ "door-locked": {
2507
+ platform: "binary_sensor",
2508
+ field: "doorLocked",
2509
+ label: "Door locked",
2510
+ icon: "mdi:lock",
2511
+ enabledByDefault: false,
2512
+ entityCategory: "diagnostic"
2513
+ },
2514
+ "remote-start-allowed": {
2515
+ platform: "binary_sensor",
2516
+ field: "remoteStartAllowed",
2517
+ label: "Remote start allowed",
2518
+ enabledByDefault: false
2519
+ },
2520
+ "remote-control": {
2521
+ platform: "binary_sensor",
2522
+ field: "remoteControlActive",
2523
+ label: "Remote control active",
2524
+ enabledByDefault: false,
2525
+ entityCategory: "diagnostic"
2526
+ },
2527
+ "local-control": {
2528
+ platform: "binary_sensor",
2529
+ field: "localControlActive",
2530
+ label: "Local control active",
2531
+ enabledByDefault: false,
2532
+ entityCategory: "diagnostic"
2533
+ },
2534
+ power: {
2535
+ platform: "switch",
2536
+ field: "powerOn",
2537
+ label: "Power",
2538
+ writable: true
2539
+ },
2540
+ "child-lock": {
2541
+ platform: "switch",
2542
+ field: "childLock",
2543
+ label: "Child lock",
2544
+ writable: true,
2545
+ entityCategory: "config",
2546
+ enabledByDefault: false
2547
+ },
2548
+ "half-load": {
2549
+ platform: "switch",
2550
+ field: "halfLoad",
2551
+ label: "Half load",
2552
+ writable: true,
2553
+ entityCategory: "config",
2554
+ enabledByDefault: false
2555
+ },
2556
+ "hygiene-plus": {
2557
+ platform: "switch",
2558
+ field: "hygienePlus",
2559
+ label: "Hygiene plus",
2560
+ writable: true,
2561
+ entityCategory: "config",
2562
+ enabledByDefault: false
2563
+ },
2564
+ "extra-dry": {
2565
+ platform: "switch",
2566
+ field: "extraDry",
2567
+ label: "Extra dry",
2568
+ writable: true,
2569
+ entityCategory: "config",
2570
+ enabledByDefault: false
2571
+ },
2572
+ "intensive-zone": {
2573
+ platform: "switch",
2574
+ field: "intensiveZone",
2575
+ label: "Intensive zone",
2576
+ writable: true,
2577
+ entityCategory: "config",
2578
+ enabledByDefault: false
2579
+ },
2580
+ "vario-speed-plus": {
2581
+ platform: "switch",
2582
+ field: "varioSpeedPlus",
2583
+ label: "VarioSpeed+",
2584
+ writable: true,
2585
+ entityCategory: "config",
2586
+ enabledByDefault: false
2587
+ },
2588
+ salt: {
2589
+ platform: "sensor",
2590
+ field: "saltLevel",
2591
+ label: "Salt level",
2592
+ enabledByDefault: false
2593
+ },
2594
+ "rinse-aid": {
2595
+ platform: "sensor",
2596
+ field: "rinseAidLevel",
2597
+ label: "Rinse aid level",
2598
+ enabledByDefault: false
2599
+ },
2600
+ error: {
2601
+ platform: "sensor",
2602
+ field: "errorLabel",
2603
+ label: "Error",
2604
+ entityCategory: "diagnostic",
2605
+ enabledByDefault: false
2606
+ }
2607
+ },
2608
+ commands: {
2609
+ start: {
2610
+ label: "Start",
2611
+ icon: "mdi:play"
2612
+ },
2613
+ pause: {
2614
+ label: "Pause",
2615
+ icon: "mdi:pause"
2616
+ },
2617
+ resume: {
2618
+ label: "Resume",
2619
+ icon: "mdi:play-pause",
2620
+ enabledByDefault: false
2621
+ },
2622
+ stop: {
2623
+ label: "Stop",
2624
+ icon: "mdi:stop",
2625
+ enabledByDefault: false
2626
+ }
2627
+ }
2628
+ },
2448
2629
  "lawn-mower-control": {
2449
2630
  platform: "sensor",
2450
2631
  field: "activity",
@@ -3432,6 +3613,18 @@ function parseEnum(schema, value) {
3432
3613
  const parsed = schema.safeParse(value.trim().toLowerCase());
3433
3614
  return parsed.success ? parsed.data : null;
3434
3615
  }
3616
+ /** A dishwasher `{ on }` switch: an unparseable payload is refused, never read as "off". */
3617
+ function dishwasherSwitch(control) {
3618
+ return (deviceId, value) => {
3619
+ const on = parseBool(value);
3620
+ return on === null ? null : {
3621
+ kind: "dishwasher-switch",
3622
+ deviceId,
3623
+ control,
3624
+ on
3625
+ };
3626
+ };
3627
+ }
3435
3628
  /**
3436
3629
  * capability → the commands that WRITE it.
3437
3630
  *
@@ -3786,6 +3979,39 @@ var CAP_COMMAND_ROUTES = {
3786
3979
  })
3787
3980
  }
3788
3981
  },
3982
+ dishwasher: {
3983
+ extras: {
3984
+ power: dishwasherSwitch("power"),
3985
+ "child-lock": dishwasherSwitch("child-lock"),
3986
+ "half-load": dishwasherSwitch("half-load"),
3987
+ "hygiene-plus": dishwasherSwitch("hygiene-plus"),
3988
+ "extra-dry": dishwasherSwitch("extra-dry"),
3989
+ "intensive-zone": dishwasherSwitch("intensive-zone"),
3990
+ "vario-speed-plus": dishwasherSwitch("vario-speed-plus")
3991
+ },
3992
+ commands: {
3993
+ start: (deviceId) => ({
3994
+ kind: "dishwasher-verb",
3995
+ deviceId,
3996
+ verb: "start"
3997
+ }),
3998
+ pause: (deviceId) => ({
3999
+ kind: "dishwasher-verb",
4000
+ deviceId,
4001
+ verb: "pause"
4002
+ }),
4003
+ resume: (deviceId) => ({
4004
+ kind: "dishwasher-verb",
4005
+ deviceId,
4006
+ verb: "resume"
4007
+ }),
4008
+ stop: (deviceId) => ({
4009
+ kind: "dishwasher-verb",
4010
+ deviceId,
4011
+ verb: "stop"
4012
+ })
4013
+ }
4014
+ },
3789
4015
  "lawn-mower-control": { commands: {
3790
4016
  start: (deviceId) => ({
3791
4017
  kind: "mower-verb",
@@ -4306,6 +4532,25 @@ async function applyCapCommand(device, command) {
4306
4532
  case "script-verb": return call(device.scriptRunner, "script-runner", (script) => command.verb === "run" ? script.run({}) : script.stop({}));
4307
4533
  case "automation-enabled": return call(device.automationControl, "automation-control", (automation) => command.enabled ? automation.enable({}) : automation.disable({}));
4308
4534
  case "automation-trigger": return call(device.automationControl, "automation-control", (automation) => automation.trigger({}));
4535
+ case "dishwasher-verb": return call(device.dishwasher, "dishwasher", (dishwasher) => {
4536
+ switch (command.verb) {
4537
+ case "start": return dishwasher.start({});
4538
+ case "pause": return dishwasher.pause({});
4539
+ case "resume": return dishwasher.resume({});
4540
+ case "stop": return dishwasher.stop({});
4541
+ }
4542
+ });
4543
+ case "dishwasher-switch": return call(device.dishwasher, "dishwasher", (dishwasher) => {
4544
+ switch (command.control) {
4545
+ case "power": return dishwasher.setPower({ on: command.on });
4546
+ case "child-lock": return dishwasher.setChildLock({ on: command.on });
4547
+ case "half-load": return dishwasher.setHalfLoad({ on: command.on });
4548
+ case "hygiene-plus": return dishwasher.setHygienePlus({ on: command.on });
4549
+ case "extra-dry": return dishwasher.setExtraDry({ on: command.on });
4550
+ case "intensive-zone": return dishwasher.setIntensiveZone({ on: command.on });
4551
+ case "vario-speed-plus": return dishwasher.setVarioSpeedPlus({ on: command.on });
4552
+ }
4553
+ });
4309
4554
  }
4310
4555
  }
4311
4556
  /**
@@ -4739,14 +4984,15 @@ var PushClient = class {
4739
4984
  * is flushed first — a value for an entity HA has not built yet is a
4740
4985
  * value it discards.
4741
4986
  */
4742
- async publishEntityChange(deviceKey, cmps, dev) {
4743
- if (this.disposed) return;
4987
+ async publishEntityChange(deviceKey, cmps, dev, full = false) {
4988
+ if (this.disposed) return false;
4744
4989
  await this.flush();
4745
- await this.send({
4990
+ return this.send({
4746
4991
  type: "entity_change",
4747
4992
  device_id: deviceKey,
4748
4993
  cmps,
4749
- dev
4994
+ dev,
4995
+ ...full ? { full: true } : {}
4750
4996
  });
4751
4997
  }
4752
4998
  /** Send everything buffered now. Called before any structure change. */
@@ -4763,12 +5009,15 @@ var PushClient = class {
4763
5009
  items
4764
5010
  });
4765
5011
  }
5012
+ /** `true` when Home Assistant took the message. */
4766
5013
  async send(message) {
4767
5014
  try {
4768
5015
  await this.transport(message);
4769
5016
  this.onReachable();
5017
+ return true;
4770
5018
  } catch (err) {
4771
5019
  this.onUnreachable(message, err);
5020
+ return false;
4772
5021
  }
4773
5022
  }
4774
5023
  onReachable() {
@@ -4882,6 +5131,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
4882
5131
  return reason === "structure-changed" && fingerprint === lastFingerprint;
4883
5132
  }
4884
5133
  //#endregion
5134
+ //#region src/ha-export/held-reads.ts
5135
+ var ReadMirror = class {
5136
+ keyOf;
5137
+ entries = /* @__PURE__ */ new Map();
5138
+ constructor(keyOf) {
5139
+ this.keyOf = keyOf;
5140
+ }
5141
+ resolve(deviceId, outcome) {
5142
+ const entry = this.entries.get(deviceId);
5143
+ if (outcome.kind === "failed") {
5144
+ if (entry === void 0) return {
5145
+ status: "unknown",
5146
+ reason: outcome.reason
5147
+ };
5148
+ return {
5149
+ status: "held-failed",
5150
+ items: entry.confirmed,
5151
+ reason: outcome.reason
5152
+ };
5153
+ }
5154
+ if (entry === void 0) {
5155
+ this.entries.set(deviceId, {
5156
+ confirmed: outcome.items,
5157
+ pendingDrop: null
5158
+ });
5159
+ return {
5160
+ status: "fresh",
5161
+ items: outcome.items
5162
+ };
5163
+ }
5164
+ const incoming = new Set(outcome.items.map(this.keyOf));
5165
+ const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
5166
+ if (dropped.length === 0) {
5167
+ entry.confirmed = outcome.items;
5168
+ entry.pendingDrop = null;
5169
+ return {
5170
+ status: "fresh",
5171
+ items: outcome.items
5172
+ };
5173
+ }
5174
+ const vote = dropped.join("\0");
5175
+ if (entry.pendingDrop === vote) {
5176
+ entry.confirmed = outcome.items;
5177
+ entry.pendingDrop = null;
5178
+ return {
5179
+ status: "fresh",
5180
+ items: outcome.items
5181
+ };
5182
+ }
5183
+ entry.pendingDrop = vote;
5184
+ return {
5185
+ status: "held-removal",
5186
+ items: entry.confirmed,
5187
+ reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
5188
+ };
5189
+ }
5190
+ };
5191
+ /**
5192
+ * The async sources `readCameraFunctions` could not obtain, or `null` when
5193
+ * every one answered. Their absence turns a function switch into "not
5194
+ * available", which removes its entity — a failure must not look like that.
5195
+ * `brokerAudioMuted` is only a read at all when the camera has a source node,
5196
+ * and `privacy` is a snapshot slice (absent = the cap is not there).
5197
+ */
5198
+ function cameraFunctionReadsFailed(reads, sourceNodeId) {
5199
+ const failed = [];
5200
+ if (reads.bindableCapNames === null) failed.push("bindableCapNames");
5201
+ if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
5202
+ if (reads.recordingEnabled === null) failed.push("recordingEnabled");
5203
+ if (reads.notificationsMuted === null) failed.push("notificationsMuted");
5204
+ if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
5205
+ return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
5206
+ }
5207
+ //#endregion
5208
+ //#region src/ha-export/resync-gate.ts
5209
+ /**
5210
+ * When the exporter must send a FULL authoritative announce rather than let an
5211
+ * unchanged structure short-circuit.
5212
+ *
5213
+ * A gap is any moment after which the hub can no longer vouch for what Home
5214
+ * Assistant holds: the first pass after a start (the receiver may have been
5215
+ * running while this addon was down), a broker link that was just opened, an
5216
+ * announce or state push that was not delivered. The next pass after a gap is
5217
+ * a full resync, once, whatever its reason — an event-debounced pass included,
5218
+ * which would otherwise skip on an identical fingerprint and leave the
5219
+ * receiver unrepaired until the periodic pass.
5220
+ *
5221
+ * Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
5222
+ */
5223
+ /** Reasons whose pass is a full resync whether or not a gap was noted. */
5224
+ var ALWAYS_FULL = new Set([
5225
+ "boot",
5226
+ "link-restored",
5227
+ "periodic"
5228
+ ]);
5229
+ var ResyncGate = class {
5230
+ gap = true;
5231
+ lastReason = "start";
5232
+ /** Note that the receiver may be out of step; the next pass will be full. */
5233
+ markGap(reason) {
5234
+ this.gap = true;
5235
+ this.lastReason = reason;
5236
+ }
5237
+ /** `true` while a gap is waiting for a pass. */
5238
+ pending() {
5239
+ return this.gap;
5240
+ }
5241
+ /** Why the gap was opened (for the log line). */
5242
+ gapReason() {
5243
+ return this.gap ? this.lastReason : null;
5244
+ }
5245
+ /**
5246
+ * Called once at the start of a pass. Returns whether the pass is a full
5247
+ * resync and consumes the gap noted so far; a gap noted DURING the pass is
5248
+ * kept for the next one.
5249
+ */
5250
+ take(reason) {
5251
+ const full = this.gap || ALWAYS_FULL.has(reason);
5252
+ this.gap = false;
5253
+ this.lastReason = null;
5254
+ return full;
5255
+ }
5256
+ };
5257
+ //#endregion
4885
5258
  //#region src/ha-export/rule-triggers.ts
4886
5259
  function reduceOne(row) {
4887
5260
  return {
@@ -5936,9 +6309,9 @@ function projectSynthetic(input, nowMs) {
5936
6309
  topic: stateTopic(srv, `${slug}_memory`),
5937
6310
  value: String(Math.round(node.memoryPercent))
5938
6311
  });
5939
- if (node.online && node.uptime > 0) values.push({
6312
+ if (node.online && node.uptimeMs > 0) values.push({
5940
6313
  topic: stateTopic(srv, `${slug}_uptime`),
5941
- value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
6314
+ value: new Date(nowMs - node.uptimeMs).toISOString()
5942
6315
  });
5943
6316
  if (node.footageUsedBytes !== null) values.push({
5944
6317
  topic: stateTopic(srv, `${slug}_footage_used_bytes`),
@@ -6004,6 +6377,8 @@ var PUSH_PATH = "/api/camstack/push";
6004
6377
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
6005
6378
  /** How long `doorbell_pressed` stays on before its OFF edge is published. */
6006
6379
  var DOORBELL_PULSE_MS = 5e3;
6380
+ /** How soon a pass with a held-back read is retried. */
6381
+ var READ_RETRY_MS = 3e4;
6007
6382
  /**
6008
6383
  * The earliest timestamp a real change could plausibly carry. Below this,
6009
6384
  * a slice field is a placeholder or a seeded "known value, unknown change
@@ -6050,6 +6425,24 @@ var HaExportAddon = class extends BaseAddon {
6050
6425
  * still holds through the periodic pass.
6051
6426
  */
6052
6427
  lastStructureFingerprint;
6428
+ /**
6429
+ * When the next pass must be a FULL authoritative announce (`full: true` on
6430
+ * the wire) whatever the fingerprint says: a start, a new broker link, an
6431
+ * undelivered push, a failed pass. See `resync-gate.ts`.
6432
+ */
6433
+ resync = new ResyncGate();
6434
+ /**
6435
+ * The last EXPORTED set of each part of a device's component set. A read
6436
+ * that failed (or a provider not registered yet) holds it; a shrink needs
6437
+ * two agreeing answers. See `held-reads.ts`.
6438
+ */
6439
+ boundCapsMirror = new ReadMirror((cap) => cap);
6440
+ zonesMirror = new ReadMirror((zone) => zone.id);
6441
+ streamsMirror = new ReadMirror((stream) => stream.id);
6442
+ presetsMirror = new ReadMirror((preset) => preset);
6443
+ switchesMirror = new ReadMirror((fn) => fn.id);
6444
+ /** A held-back read is retried soon, not at the next 300 s pass. */
6445
+ readTimer = null;
6053
6446
  entityCount = 0;
6054
6447
  /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
6055
6448
  lastDoorbellPressAt = /* @__PURE__ */ new Map();
@@ -6126,6 +6519,8 @@ var HaExportAddon = class extends BaseAddon {
6126
6519
  for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
6127
6520
  this.doorbellReleaseTimers.clear();
6128
6521
  this.reachability.dispose();
6522
+ if (this.readTimer !== null) clearTimeout(this.readTimer);
6523
+ this.readTimer = null;
6129
6524
  for (const link of this.links.values()) await link.client.dispose();
6130
6525
  this.links.clear();
6131
6526
  });
@@ -6598,6 +6993,8 @@ var HaExportAddon = class extends BaseAddon {
6598
6993
  const startedAt = Date.now();
6599
6994
  try {
6600
6995
  await this.refreshBrokers();
6996
+ const fullResync = this.resync.take(reason);
6997
+ let readsHeld = false;
6601
6998
  await this.refreshMediaBaseUrl();
6602
6999
  await this.refreshComponentSupport();
6603
7000
  const enabled = this.enabledBrokerIds();
@@ -6660,16 +7057,22 @@ var HaExportAddon = class extends BaseAddon {
6660
7057
  * used to call the deprecated `getCameraSwitches` separately for the
6661
7058
  * same answer.
6662
7059
  */
6663
- const cameraFunctions = device.type === "camera" ? deriveCameraFunctions(await this.readCameraFunctions({
7060
+ const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
7061
+ const functionReads = device.type === "camera" ? await this.readCameraFunctions({
6664
7062
  deviceId: numericId,
6665
7063
  deviceType: device.type,
6666
7064
  deviceDisabled: device.disabled,
6667
7065
  privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
6668
- sourceNodeId: this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null,
7066
+ sourceNodeId,
6669
7067
  bindableCapsByType,
6670
7068
  mutedDeviceIds
6671
- })) : null;
6672
- const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions);
7069
+ }) : null;
7070
+ const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
7071
+ const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
7072
+ const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
7073
+ if (built.heldBack) readsHeld = true;
7074
+ const catalogDevice = built.device;
7075
+ if (catalogDevice === null) continue;
6673
7076
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
6674
7077
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
6675
7078
  /**
@@ -6740,7 +7143,8 @@ var HaExportAddon = class extends BaseAddon {
6740
7143
  this.entityCount = entityCount;
6741
7144
  this.unclassified = unclassifiedCaps([...allCaps]);
6742
7145
  const fingerprint = structureFingerprint(exported);
6743
- if (shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
7146
+ if (readsHeld) this.scheduleReadRetry();
7147
+ if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
6744
7148
  this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
6745
7149
  devices: exported.size,
6746
7150
  entities: entityCount
@@ -6758,11 +7162,17 @@ var HaExportAddon = class extends BaseAddon {
6758
7162
  entities: line.entities
6759
7163
  }
6760
7164
  });
6761
- await this.announce(exported);
7165
+ const delivered = await this.announce(exported, fullResync);
6762
7166
  await this.pushFullState(exported, snapshots);
6763
7167
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
7168
+ if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
7169
+ this.resync.markGap("push-not-delivered");
7170
+ this.lastStructureFingerprint = void 0;
7171
+ this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
7172
+ }
6764
7173
  this.ctx.logger.info("ha-export: reconciled", { meta: {
6765
7174
  reason,
7175
+ fullResync,
6766
7176
  brokers: enabled.length,
6767
7177
  devices: exported.size,
6768
7178
  entities: entityCount,
@@ -6773,6 +7183,7 @@ var HaExportAddon = class extends BaseAddon {
6773
7183
  this.lastError = void 0;
6774
7184
  } catch (err) {
6775
7185
  this.lastError = errMsg(err);
7186
+ this.resync.markGap("reconcile-failed");
6776
7187
  this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
6777
7188
  }
6778
7189
  }
@@ -6971,7 +7382,7 @@ var HaExportAddon = class extends BaseAddon {
6971
7382
  online: node.isOnline,
6972
7383
  cpuPercent: node.cpuPercent,
6973
7384
  memoryPercent: node.memoryPercent,
6974
- uptime: node.uptime,
7385
+ uptimeMs: node.uptime,
6975
7386
  isHub: node.isHub,
6976
7387
  footageUsedBytes: footage.footageUsedBytes,
6977
7388
  footageFreePercent: footage.footageFreePercent,
@@ -6990,13 +7401,16 @@ var HaExportAddon = class extends BaseAddon {
6990
7401
  for (const value of values) link.client.publishState(value.topic, value.value);
6991
7402
  }
6992
7403
  }
6993
- async announce(exported) {
7404
+ /** `false` when any announce was not delivered. */
7405
+ async announce(exported, full) {
7406
+ let allDelivered = true;
6994
7407
  for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
6995
7408
  const link = this.links.get(brokerId);
6996
7409
  if (link === void 0) continue;
6997
7410
  try {
6998
- await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
7411
+ if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
6999
7412
  } catch (err) {
7413
+ allDelivered = false;
7000
7414
  this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
7001
7415
  tags: { deviceId: entry.deviceId },
7002
7416
  meta: {
@@ -7006,6 +7420,7 @@ var HaExportAddon = class extends BaseAddon {
7006
7420
  });
7007
7421
  }
7008
7422
  }
7423
+ return allDelivered;
7009
7424
  }
7010
7425
  async pushFullState(exported, snapshots) {
7011
7426
  for (const entry of exported.values()) try {
@@ -7123,46 +7538,105 @@ var HaExportAddon = class extends BaseAddon {
7123
7538
  }
7124
7539
  return values;
7125
7540
  }
7126
- async buildCatalogDevice(device, snapshotRaw, cameraFunctions) {
7541
+ /**
7542
+ * `device: null` = a part of this device's component set cannot be read and
7543
+ * nothing was ever exported for it, so the device is not announced this pass
7544
+ * (an empty part would make Home Assistant delete what it holds). `heldBack`
7545
+ * = at least one part is not a fresh answer, so a retry is worth arming.
7546
+ */
7547
+ async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
7127
7548
  const snapshot = toSnapshot(snapshotRaw);
7128
- const boundCaps = await this.loadBoundCaps(device.id);
7129
- const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
7549
+ let heldBack = false;
7550
+ let unknown = false;
7551
+ const settle = (part, mirror, outcome) => {
7552
+ const resolution = mirror.resolve(device.id, outcome);
7553
+ if (resolution.status !== "fresh") heldBack = true;
7554
+ if (resolution.status === "unknown") unknown = true;
7555
+ this.logHeldRead(device.id, part, resolution);
7556
+ return resolution.status === "unknown" ? [] : resolution.items;
7557
+ };
7558
+ const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
7559
+ const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
7130
7560
  /**
7131
7561
  * Every camera function, available or not — `entity-catalog.ts` is the
7132
7562
  * one place that filters to `available`, exactly as it did for
7133
- * `getCameraSwitches`'s answer.
7563
+ * `getCameraSwitches`'s answer. A function that did not answer is
7564
+ * "unavailable", which removes its entity, so the set goes through the
7565
+ * mirror: held when a source failed, shrunk only on two agreeing passes.
7134
7566
  */
7135
- const switches = (cameraFunctions ?? []).map((fn) => ({
7567
+ const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
7136
7568
  id: fn.id,
7137
7569
  label: fn.label,
7138
7570
  available: fn.available
7139
7571
  }));
7140
- const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
7141
- const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
7572
+ const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
7573
+ kind: "failed",
7574
+ reason: functionsFailure
7575
+ } : {
7576
+ kind: "answered",
7577
+ items: mappedSwitches.filter((fn) => fn.available)
7578
+ });
7579
+ const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
7580
+ kind: "answered",
7581
+ items: []
7582
+ }) : [];
7583
+ const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
7584
+ kind: "answered",
7585
+ items: []
7586
+ }) : [];
7587
+ if (unknown) return {
7588
+ device: null,
7589
+ heldBack
7590
+ };
7142
7591
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
7143
7592
  const model = readString(device.metadata ?? {}, "model");
7144
7593
  return {
7145
- id: device.id,
7146
- name: device.name,
7147
- type: device.type,
7148
- ...manufacturer !== null ? { manufacturer } : {},
7149
- ...model !== null ? { model } : {},
7150
- features: device.features,
7151
- boundCaps,
7152
- zones,
7153
- switches,
7154
- ptzPresets,
7155
- streams,
7156
- slices: Object.keys(snapshot),
7157
- /**
7158
- * The slice VALUES, for the native platforms only: a native
7159
- * `climate` announces the modes the device accepts, and
7160
- * `availableModes` is on the slice and nowhere else. See
7161
- * `CatalogDevice.capSlices`.
7162
- */
7163
- capSlices: snapshot
7594
+ device: {
7595
+ id: device.id,
7596
+ name: device.name,
7597
+ type: device.type,
7598
+ ...manufacturer !== null ? { manufacturer } : {},
7599
+ ...model !== null ? { model } : {},
7600
+ features: device.features,
7601
+ boundCaps,
7602
+ zones,
7603
+ switches,
7604
+ ptzPresets,
7605
+ streams,
7606
+ slices: Object.keys(snapshot),
7607
+ /**
7608
+ * The slice VALUES, for the native platforms only: a native
7609
+ * `climate` announces the modes the device accepts, and
7610
+ * `availableModes` is on the slice and nowhere else. See
7611
+ * `CatalogDevice.capSlices`.
7612
+ */
7613
+ capSlices: snapshot
7614
+ },
7615
+ heldBack
7164
7616
  };
7165
7617
  }
7618
+ /** One WARN per held part, with the device tag and the reason. */
7619
+ logHeldRead(deviceId, part, resolution) {
7620
+ if (resolution.status === "fresh") return;
7621
+ const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
7622
+ const message = resolution.status === "unknown" ? "ha-export: a read did not answer and nothing was exported yet, the device is not announced this pass" : resolution.status === "held-failed" ? "ha-export: a read did not answer, keeping the last exported components" : "ha-export: a read dropped components, keeping them until a second answer agrees";
7623
+ this.ctx.logger.warn(message, {
7624
+ tags: { deviceId },
7625
+ meta: {
7626
+ part,
7627
+ reason: resolution.reason,
7628
+ kept
7629
+ }
7630
+ });
7631
+ }
7632
+ /** Retry soon after a held-back read: the provider usually registers seconds later. */
7633
+ scheduleReadRetry() {
7634
+ if (this.readTimer !== null) return;
7635
+ this.readTimer = setTimeout(() => {
7636
+ this.readTimer = null;
7637
+ this.reconcile("read-retry");
7638
+ }, READ_RETRY_MS);
7639
+ }
7166
7640
  /**
7167
7641
  * Every stream of the camera, as the hub's own picker lists them.
7168
7642
  *
@@ -7180,45 +7654,54 @@ var HaExportAddon = class extends BaseAddon {
7180
7654
  */
7181
7655
  async loadStreamChoices(deviceId) {
7182
7656
  try {
7183
- return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
7184
- id: choice.id,
7185
- label: choice.label,
7186
- target: choice.target,
7187
- ...choice.resolution === null ? {} : { height: choice.resolution.height }
7188
- }));
7657
+ return {
7658
+ kind: "answered",
7659
+ items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
7660
+ id: choice.id,
7661
+ label: choice.label,
7662
+ target: choice.target,
7663
+ ...choice.resolution === null ? {} : { height: choice.resolution.height }
7664
+ }))
7665
+ };
7189
7666
  } catch (err) {
7190
- this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
7191
- tags: { deviceId },
7192
- meta: { error: errMsg(err) }
7193
- });
7194
- return [];
7667
+ return {
7668
+ kind: "failed",
7669
+ reason: errMsg(err)
7670
+ };
7195
7671
  }
7196
7672
  }
7197
7673
  async loadBoundCaps(deviceId) {
7198
7674
  try {
7199
- return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
7675
+ return {
7676
+ kind: "answered",
7677
+ items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
7678
+ };
7200
7679
  } catch (err) {
7201
- this.ctx.logger.warn("ha-export: could not read bindings, exporting without them", {
7202
- tags: { deviceId },
7203
- meta: { error: errMsg(err) }
7204
- });
7205
- return [];
7680
+ return {
7681
+ kind: "failed",
7682
+ reason: errMsg(err)
7683
+ };
7206
7684
  }
7207
7685
  }
7208
7686
  async loadZones(deviceId) {
7209
7687
  try {
7210
7688
  const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
7211
- if (snapshot === null) return [];
7212
- return snapshot.zones.map((zone) => ({
7213
- id: zone.zoneId,
7214
- name: zone.zoneName
7215
- }));
7689
+ if (snapshot === null) return {
7690
+ kind: "answered",
7691
+ items: []
7692
+ };
7693
+ return {
7694
+ kind: "answered",
7695
+ items: snapshot.zones.map((zone) => ({
7696
+ id: zone.zoneId,
7697
+ name: zone.zoneName
7698
+ }))
7699
+ };
7216
7700
  } catch (err) {
7217
- this.ctx.logger.warn("ha-export: could not read zones, exporting no zone entities", {
7218
- tags: { deviceId },
7219
- meta: { error: errMsg(err) }
7220
- });
7221
- return [];
7701
+ return {
7702
+ kind: "failed",
7703
+ reason: errMsg(err)
7704
+ };
7222
7705
  }
7223
7706
  }
7224
7707
  /**
@@ -7337,14 +7820,19 @@ var HaExportAddon = class extends BaseAddon {
7337
7820
  async loadPresets(deviceId) {
7338
7821
  try {
7339
7822
  const device = await this.ctx.fetchDevice(deviceId);
7340
- if (device.ptz === void 0) return [];
7341
- return (await device.ptz.getPresets({})).map((preset) => preset.name);
7823
+ if (device.ptz === void 0) return {
7824
+ kind: "answered",
7825
+ items: []
7826
+ };
7827
+ return {
7828
+ kind: "answered",
7829
+ items: (await device.ptz.getPresets({})).map((preset) => preset.name)
7830
+ };
7342
7831
  } catch (err) {
7343
- this.ctx.logger.debug("ha-export: could not probe PTZ presets", {
7344
- tags: { deviceId },
7345
- meta: { error: errMsg(err) }
7346
- });
7347
- return [];
7832
+ return {
7833
+ kind: "failed",
7834
+ reason: errMsg(err)
7835
+ };
7348
7836
  }
7349
7837
  }
7350
7838
  /**
@@ -7423,6 +7911,7 @@ var HaExportAddon = class extends BaseAddon {
7423
7911
  onLinkRestored: (brokerId) => this.reachability.request(brokerId)
7424
7912
  });
7425
7913
  client.start();
7914
+ this.resync.markGap("broker-connected");
7426
7915
  this.links.set(broker.id, {
7427
7916
  brokerId: broker.id,
7428
7917
  name: broker.name,