@camstack/addon-provider-homeassistant 1.2.601 → 1.2.603

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-BQLjEZxA.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-CM5Z-Rdj.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -3195,6 +3195,10 @@ var CAPS_NOT_EXPORTED = [
3195
3195
  {
3196
3196
  cap: "zone-rules",
3197
3197
  reason: "Per-stage zone rules — configuration the pipeline consumes, with no value an automation reads."
3198
+ },
3199
+ {
3200
+ cap: "heating-valve",
3201
+ reason: "Radiator valve position telemetry (0–100 %). Shown on the CamStack thermostat row; no Home Assistant / voice-assistant entity mapping is defined yet — export would mint a sensor with no agreed device_class or control surface."
3198
3202
  }
3199
3203
  ];
3200
3204
  var UNNEGOTIATED_PLAN_OPTIONS = { platforms: UNNEGOTIATED_SUPPORT };
@@ -4735,14 +4739,15 @@ var PushClient = class {
4735
4739
  * is flushed first — a value for an entity HA has not built yet is a
4736
4740
  * value it discards.
4737
4741
  */
4738
- async publishEntityChange(deviceKey, cmps, dev) {
4739
- if (this.disposed) return;
4742
+ async publishEntityChange(deviceKey, cmps, dev, full = false) {
4743
+ if (this.disposed) return false;
4740
4744
  await this.flush();
4741
- await this.send({
4745
+ return this.send({
4742
4746
  type: "entity_change",
4743
4747
  device_id: deviceKey,
4744
4748
  cmps,
4745
- dev
4749
+ dev,
4750
+ ...full ? { full: true } : {}
4746
4751
  });
4747
4752
  }
4748
4753
  /** Send everything buffered now. Called before any structure change. */
@@ -4759,12 +4764,15 @@ var PushClient = class {
4759
4764
  items
4760
4765
  });
4761
4766
  }
4767
+ /** `true` when Home Assistant took the message. */
4762
4768
  async send(message) {
4763
4769
  try {
4764
4770
  await this.transport(message);
4765
4771
  this.onReachable();
4772
+ return true;
4766
4773
  } catch (err) {
4767
4774
  this.onUnreachable(message, err);
4775
+ return false;
4768
4776
  }
4769
4777
  }
4770
4778
  onReachable() {
@@ -4878,6 +4886,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
4878
4886
  return reason === "structure-changed" && fingerprint === lastFingerprint;
4879
4887
  }
4880
4888
  //#endregion
4889
+ //#region src/ha-export/held-reads.ts
4890
+ var ReadMirror = class {
4891
+ keyOf;
4892
+ entries = /* @__PURE__ */ new Map();
4893
+ constructor(keyOf) {
4894
+ this.keyOf = keyOf;
4895
+ }
4896
+ resolve(deviceId, outcome) {
4897
+ const entry = this.entries.get(deviceId);
4898
+ if (outcome.kind === "failed") {
4899
+ if (entry === void 0) return {
4900
+ status: "unknown",
4901
+ reason: outcome.reason
4902
+ };
4903
+ return {
4904
+ status: "held-failed",
4905
+ items: entry.confirmed,
4906
+ reason: outcome.reason
4907
+ };
4908
+ }
4909
+ if (entry === void 0) {
4910
+ this.entries.set(deviceId, {
4911
+ confirmed: outcome.items,
4912
+ pendingDrop: null
4913
+ });
4914
+ return {
4915
+ status: "fresh",
4916
+ items: outcome.items
4917
+ };
4918
+ }
4919
+ const incoming = new Set(outcome.items.map(this.keyOf));
4920
+ const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
4921
+ if (dropped.length === 0) {
4922
+ entry.confirmed = outcome.items;
4923
+ entry.pendingDrop = null;
4924
+ return {
4925
+ status: "fresh",
4926
+ items: outcome.items
4927
+ };
4928
+ }
4929
+ const vote = dropped.join("\0");
4930
+ if (entry.pendingDrop === vote) {
4931
+ entry.confirmed = outcome.items;
4932
+ entry.pendingDrop = null;
4933
+ return {
4934
+ status: "fresh",
4935
+ items: outcome.items
4936
+ };
4937
+ }
4938
+ entry.pendingDrop = vote;
4939
+ return {
4940
+ status: "held-removal",
4941
+ items: entry.confirmed,
4942
+ reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
4943
+ };
4944
+ }
4945
+ };
4946
+ /**
4947
+ * The async sources `readCameraFunctions` could not obtain, or `null` when
4948
+ * every one answered. Their absence turns a function switch into "not
4949
+ * available", which removes its entity — a failure must not look like that.
4950
+ * `brokerAudioMuted` is only a read at all when the camera has a source node,
4951
+ * and `privacy` is a snapshot slice (absent = the cap is not there).
4952
+ */
4953
+ function cameraFunctionReadsFailed(reads, sourceNodeId) {
4954
+ const failed = [];
4955
+ if (reads.bindableCapNames === null) failed.push("bindableCapNames");
4956
+ if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
4957
+ if (reads.recordingEnabled === null) failed.push("recordingEnabled");
4958
+ if (reads.notificationsMuted === null) failed.push("notificationsMuted");
4959
+ if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
4960
+ return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
4961
+ }
4962
+ //#endregion
4963
+ //#region src/ha-export/resync-gate.ts
4964
+ /**
4965
+ * When the exporter must send a FULL authoritative announce rather than let an
4966
+ * unchanged structure short-circuit.
4967
+ *
4968
+ * A gap is any moment after which the hub can no longer vouch for what Home
4969
+ * Assistant holds: the first pass after a start (the receiver may have been
4970
+ * running while this addon was down), a broker link that was just opened, an
4971
+ * announce or state push that was not delivered. The next pass after a gap is
4972
+ * a full resync, once, whatever its reason — an event-debounced pass included,
4973
+ * which would otherwise skip on an identical fingerprint and leave the
4974
+ * receiver unrepaired until the periodic pass.
4975
+ *
4976
+ * Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
4977
+ */
4978
+ /** Reasons whose pass is a full resync whether or not a gap was noted. */
4979
+ var ALWAYS_FULL = new Set([
4980
+ "boot",
4981
+ "link-restored",
4982
+ "periodic"
4983
+ ]);
4984
+ var ResyncGate = class {
4985
+ gap = true;
4986
+ lastReason = "start";
4987
+ /** Note that the receiver may be out of step; the next pass will be full. */
4988
+ markGap(reason) {
4989
+ this.gap = true;
4990
+ this.lastReason = reason;
4991
+ }
4992
+ /** `true` while a gap is waiting for a pass. */
4993
+ pending() {
4994
+ return this.gap;
4995
+ }
4996
+ /** Why the gap was opened (for the log line). */
4997
+ gapReason() {
4998
+ return this.gap ? this.lastReason : null;
4999
+ }
5000
+ /**
5001
+ * Called once at the start of a pass. Returns whether the pass is a full
5002
+ * resync and consumes the gap noted so far; a gap noted DURING the pass is
5003
+ * kept for the next one.
5004
+ */
5005
+ take(reason) {
5006
+ const full = this.gap || ALWAYS_FULL.has(reason);
5007
+ this.gap = false;
5008
+ this.lastReason = null;
5009
+ return full;
5010
+ }
5011
+ };
5012
+ //#endregion
4881
5013
  //#region src/ha-export/rule-triggers.ts
4882
5014
  function reduceOne(row) {
4883
5015
  return {
@@ -5932,9 +6064,9 @@ function projectSynthetic(input, nowMs) {
5932
6064
  topic: stateTopic(srv, `${slug}_memory`),
5933
6065
  value: String(Math.round(node.memoryPercent))
5934
6066
  });
5935
- if (node.online && node.uptime > 0) values.push({
6067
+ if (node.online && node.uptimeMs > 0) values.push({
5936
6068
  topic: stateTopic(srv, `${slug}_uptime`),
5937
- value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
6069
+ value: new Date(nowMs - node.uptimeMs).toISOString()
5938
6070
  });
5939
6071
  if (node.footageUsedBytes !== null) values.push({
5940
6072
  topic: stateTopic(srv, `${slug}_footage_used_bytes`),
@@ -6000,6 +6132,8 @@ var PUSH_PATH = "/api/camstack/push";
6000
6132
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
6001
6133
  /** How long `doorbell_pressed` stays on before its OFF edge is published. */
6002
6134
  var DOORBELL_PULSE_MS = 5e3;
6135
+ /** How soon a pass with a held-back read is retried. */
6136
+ var READ_RETRY_MS = 3e4;
6003
6137
  /**
6004
6138
  * The earliest timestamp a real change could plausibly carry. Below this,
6005
6139
  * a slice field is a placeholder or a seeded "known value, unknown change
@@ -6046,6 +6180,24 @@ var HaExportAddon = class extends BaseAddon {
6046
6180
  * still holds through the periodic pass.
6047
6181
  */
6048
6182
  lastStructureFingerprint;
6183
+ /**
6184
+ * When the next pass must be a FULL authoritative announce (`full: true` on
6185
+ * the wire) whatever the fingerprint says: a start, a new broker link, an
6186
+ * undelivered push, a failed pass. See `resync-gate.ts`.
6187
+ */
6188
+ resync = new ResyncGate();
6189
+ /**
6190
+ * The last EXPORTED set of each part of a device's component set. A read
6191
+ * that failed (or a provider not registered yet) holds it; a shrink needs
6192
+ * two agreeing answers. See `held-reads.ts`.
6193
+ */
6194
+ boundCapsMirror = new ReadMirror((cap) => cap);
6195
+ zonesMirror = new ReadMirror((zone) => zone.id);
6196
+ streamsMirror = new ReadMirror((stream) => stream.id);
6197
+ presetsMirror = new ReadMirror((preset) => preset);
6198
+ switchesMirror = new ReadMirror((fn) => fn.id);
6199
+ /** A held-back read is retried soon, not at the next 300 s pass. */
6200
+ readTimer = null;
6049
6201
  entityCount = 0;
6050
6202
  /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
6051
6203
  lastDoorbellPressAt = /* @__PURE__ */ new Map();
@@ -6122,6 +6274,8 @@ var HaExportAddon = class extends BaseAddon {
6122
6274
  for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
6123
6275
  this.doorbellReleaseTimers.clear();
6124
6276
  this.reachability.dispose();
6277
+ if (this.readTimer !== null) clearTimeout(this.readTimer);
6278
+ this.readTimer = null;
6125
6279
  for (const link of this.links.values()) await link.client.dispose();
6126
6280
  this.links.clear();
6127
6281
  });
@@ -6594,6 +6748,8 @@ var HaExportAddon = class extends BaseAddon {
6594
6748
  const startedAt = Date.now();
6595
6749
  try {
6596
6750
  await this.refreshBrokers();
6751
+ const fullResync = this.resync.take(reason);
6752
+ let readsHeld = false;
6597
6753
  await this.refreshMediaBaseUrl();
6598
6754
  await this.refreshComponentSupport();
6599
6755
  const enabled = this.enabledBrokerIds();
@@ -6656,16 +6812,22 @@ var HaExportAddon = class extends BaseAddon {
6656
6812
  * used to call the deprecated `getCameraSwitches` separately for the
6657
6813
  * same answer.
6658
6814
  */
6659
- const cameraFunctions = device.type === "camera" ? deriveCameraFunctions(await this.readCameraFunctions({
6815
+ const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
6816
+ const functionReads = device.type === "camera" ? await this.readCameraFunctions({
6660
6817
  deviceId: numericId,
6661
6818
  deviceType: device.type,
6662
6819
  deviceDisabled: device.disabled,
6663
6820
  privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
6664
- sourceNodeId: this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null,
6821
+ sourceNodeId,
6665
6822
  bindableCapsByType,
6666
6823
  mutedDeviceIds
6667
- })) : null;
6668
- const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions);
6824
+ }) : null;
6825
+ const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
6826
+ const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
6827
+ const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
6828
+ if (built.heldBack) readsHeld = true;
6829
+ const catalogDevice = built.device;
6830
+ if (catalogDevice === null) continue;
6669
6831
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
6670
6832
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
6671
6833
  /**
@@ -6736,7 +6898,8 @@ var HaExportAddon = class extends BaseAddon {
6736
6898
  this.entityCount = entityCount;
6737
6899
  this.unclassified = unclassifiedCaps([...allCaps]);
6738
6900
  const fingerprint = structureFingerprint(exported);
6739
- if (shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
6901
+ if (readsHeld) this.scheduleReadRetry();
6902
+ if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
6740
6903
  this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
6741
6904
  devices: exported.size,
6742
6905
  entities: entityCount
@@ -6754,11 +6917,17 @@ var HaExportAddon = class extends BaseAddon {
6754
6917
  entities: line.entities
6755
6918
  }
6756
6919
  });
6757
- await this.announce(exported);
6920
+ const delivered = await this.announce(exported, fullResync);
6758
6921
  await this.pushFullState(exported, snapshots);
6759
6922
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
6923
+ if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
6924
+ this.resync.markGap("push-not-delivered");
6925
+ this.lastStructureFingerprint = void 0;
6926
+ this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
6927
+ }
6760
6928
  this.ctx.logger.info("ha-export: reconciled", { meta: {
6761
6929
  reason,
6930
+ fullResync,
6762
6931
  brokers: enabled.length,
6763
6932
  devices: exported.size,
6764
6933
  entities: entityCount,
@@ -6769,6 +6938,7 @@ var HaExportAddon = class extends BaseAddon {
6769
6938
  this.lastError = void 0;
6770
6939
  } catch (err) {
6771
6940
  this.lastError = errMsg(err);
6941
+ this.resync.markGap("reconcile-failed");
6772
6942
  this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
6773
6943
  }
6774
6944
  }
@@ -6967,7 +7137,7 @@ var HaExportAddon = class extends BaseAddon {
6967
7137
  online: node.isOnline,
6968
7138
  cpuPercent: node.cpuPercent,
6969
7139
  memoryPercent: node.memoryPercent,
6970
- uptime: node.uptime,
7140
+ uptimeMs: node.uptime,
6971
7141
  isHub: node.isHub,
6972
7142
  footageUsedBytes: footage.footageUsedBytes,
6973
7143
  footageFreePercent: footage.footageFreePercent,
@@ -6986,13 +7156,16 @@ var HaExportAddon = class extends BaseAddon {
6986
7156
  for (const value of values) link.client.publishState(value.topic, value.value);
6987
7157
  }
6988
7158
  }
6989
- async announce(exported) {
7159
+ /** `false` when any announce was not delivered. */
7160
+ async announce(exported, full) {
7161
+ let allDelivered = true;
6990
7162
  for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
6991
7163
  const link = this.links.get(brokerId);
6992
7164
  if (link === void 0) continue;
6993
7165
  try {
6994
- await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
7166
+ if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
6995
7167
  } catch (err) {
7168
+ allDelivered = false;
6996
7169
  this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
6997
7170
  tags: { deviceId: entry.deviceId },
6998
7171
  meta: {
@@ -7002,6 +7175,7 @@ var HaExportAddon = class extends BaseAddon {
7002
7175
  });
7003
7176
  }
7004
7177
  }
7178
+ return allDelivered;
7005
7179
  }
7006
7180
  async pushFullState(exported, snapshots) {
7007
7181
  for (const entry of exported.values()) try {
@@ -7119,46 +7293,105 @@ var HaExportAddon = class extends BaseAddon {
7119
7293
  }
7120
7294
  return values;
7121
7295
  }
7122
- async buildCatalogDevice(device, snapshotRaw, cameraFunctions) {
7296
+ /**
7297
+ * `device: null` = a part of this device's component set cannot be read and
7298
+ * nothing was ever exported for it, so the device is not announced this pass
7299
+ * (an empty part would make Home Assistant delete what it holds). `heldBack`
7300
+ * = at least one part is not a fresh answer, so a retry is worth arming.
7301
+ */
7302
+ async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
7123
7303
  const snapshot = toSnapshot(snapshotRaw);
7124
- const boundCaps = await this.loadBoundCaps(device.id);
7125
- const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
7304
+ let heldBack = false;
7305
+ let unknown = false;
7306
+ const settle = (part, mirror, outcome) => {
7307
+ const resolution = mirror.resolve(device.id, outcome);
7308
+ if (resolution.status !== "fresh") heldBack = true;
7309
+ if (resolution.status === "unknown") unknown = true;
7310
+ this.logHeldRead(device.id, part, resolution);
7311
+ return resolution.status === "unknown" ? [] : resolution.items;
7312
+ };
7313
+ const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
7314
+ const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
7126
7315
  /**
7127
7316
  * Every camera function, available or not — `entity-catalog.ts` is the
7128
7317
  * one place that filters to `available`, exactly as it did for
7129
- * `getCameraSwitches`'s answer.
7318
+ * `getCameraSwitches`'s answer. A function that did not answer is
7319
+ * "unavailable", which removes its entity, so the set goes through the
7320
+ * mirror: held when a source failed, shrunk only on two agreeing passes.
7130
7321
  */
7131
- const switches = (cameraFunctions ?? []).map((fn) => ({
7322
+ const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
7132
7323
  id: fn.id,
7133
7324
  label: fn.label,
7134
7325
  available: fn.available
7135
7326
  }));
7136
- const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
7137
- const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
7327
+ const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
7328
+ kind: "failed",
7329
+ reason: functionsFailure
7330
+ } : {
7331
+ kind: "answered",
7332
+ items: mappedSwitches.filter((fn) => fn.available)
7333
+ });
7334
+ const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
7335
+ kind: "answered",
7336
+ items: []
7337
+ }) : [];
7338
+ const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
7339
+ kind: "answered",
7340
+ items: []
7341
+ }) : [];
7342
+ if (unknown) return {
7343
+ device: null,
7344
+ heldBack
7345
+ };
7138
7346
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
7139
7347
  const model = readString(device.metadata ?? {}, "model");
7140
7348
  return {
7141
- id: device.id,
7142
- name: device.name,
7143
- type: device.type,
7144
- ...manufacturer !== null ? { manufacturer } : {},
7145
- ...model !== null ? { model } : {},
7146
- features: device.features,
7147
- boundCaps,
7148
- zones,
7149
- switches,
7150
- ptzPresets,
7151
- streams,
7152
- slices: Object.keys(snapshot),
7153
- /**
7154
- * The slice VALUES, for the native platforms only: a native
7155
- * `climate` announces the modes the device accepts, and
7156
- * `availableModes` is on the slice and nowhere else. See
7157
- * `CatalogDevice.capSlices`.
7158
- */
7159
- capSlices: snapshot
7349
+ device: {
7350
+ id: device.id,
7351
+ name: device.name,
7352
+ type: device.type,
7353
+ ...manufacturer !== null ? { manufacturer } : {},
7354
+ ...model !== null ? { model } : {},
7355
+ features: device.features,
7356
+ boundCaps,
7357
+ zones,
7358
+ switches,
7359
+ ptzPresets,
7360
+ streams,
7361
+ slices: Object.keys(snapshot),
7362
+ /**
7363
+ * The slice VALUES, for the native platforms only: a native
7364
+ * `climate` announces the modes the device accepts, and
7365
+ * `availableModes` is on the slice and nowhere else. See
7366
+ * `CatalogDevice.capSlices`.
7367
+ */
7368
+ capSlices: snapshot
7369
+ },
7370
+ heldBack
7160
7371
  };
7161
7372
  }
7373
+ /** One WARN per held part, with the device tag and the reason. */
7374
+ logHeldRead(deviceId, part, resolution) {
7375
+ if (resolution.status === "fresh") return;
7376
+ const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
7377
+ 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";
7378
+ this.ctx.logger.warn(message, {
7379
+ tags: { deviceId },
7380
+ meta: {
7381
+ part,
7382
+ reason: resolution.reason,
7383
+ kept
7384
+ }
7385
+ });
7386
+ }
7387
+ /** Retry soon after a held-back read: the provider usually registers seconds later. */
7388
+ scheduleReadRetry() {
7389
+ if (this.readTimer !== null) return;
7390
+ this.readTimer = setTimeout(() => {
7391
+ this.readTimer = null;
7392
+ this.reconcile("read-retry");
7393
+ }, READ_RETRY_MS);
7394
+ }
7162
7395
  /**
7163
7396
  * Every stream of the camera, as the hub's own picker lists them.
7164
7397
  *
@@ -7176,45 +7409,54 @@ var HaExportAddon = class extends BaseAddon {
7176
7409
  */
7177
7410
  async loadStreamChoices(deviceId) {
7178
7411
  try {
7179
- return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
7180
- id: choice.id,
7181
- label: choice.label,
7182
- target: choice.target,
7183
- ...choice.resolution === null ? {} : { height: choice.resolution.height }
7184
- }));
7412
+ return {
7413
+ kind: "answered",
7414
+ items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
7415
+ id: choice.id,
7416
+ label: choice.label,
7417
+ target: choice.target,
7418
+ ...choice.resolution === null ? {} : { height: choice.resolution.height }
7419
+ }))
7420
+ };
7185
7421
  } catch (err) {
7186
- this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
7187
- tags: { deviceId },
7188
- meta: { error: errMsg(err) }
7189
- });
7190
- return [];
7422
+ return {
7423
+ kind: "failed",
7424
+ reason: errMsg(err)
7425
+ };
7191
7426
  }
7192
7427
  }
7193
7428
  async loadBoundCaps(deviceId) {
7194
7429
  try {
7195
- return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
7430
+ return {
7431
+ kind: "answered",
7432
+ items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
7433
+ };
7196
7434
  } catch (err) {
7197
- this.ctx.logger.warn("ha-export: could not read bindings, exporting without them", {
7198
- tags: { deviceId },
7199
- meta: { error: errMsg(err) }
7200
- });
7201
- return [];
7435
+ return {
7436
+ kind: "failed",
7437
+ reason: errMsg(err)
7438
+ };
7202
7439
  }
7203
7440
  }
7204
7441
  async loadZones(deviceId) {
7205
7442
  try {
7206
7443
  const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
7207
- if (snapshot === null) return [];
7208
- return snapshot.zones.map((zone) => ({
7209
- id: zone.zoneId,
7210
- name: zone.zoneName
7211
- }));
7444
+ if (snapshot === null) return {
7445
+ kind: "answered",
7446
+ items: []
7447
+ };
7448
+ return {
7449
+ kind: "answered",
7450
+ items: snapshot.zones.map((zone) => ({
7451
+ id: zone.zoneId,
7452
+ name: zone.zoneName
7453
+ }))
7454
+ };
7212
7455
  } catch (err) {
7213
- this.ctx.logger.warn("ha-export: could not read zones, exporting no zone entities", {
7214
- tags: { deviceId },
7215
- meta: { error: errMsg(err) }
7216
- });
7217
- return [];
7456
+ return {
7457
+ kind: "failed",
7458
+ reason: errMsg(err)
7459
+ };
7218
7460
  }
7219
7461
  }
7220
7462
  /**
@@ -7333,14 +7575,19 @@ var HaExportAddon = class extends BaseAddon {
7333
7575
  async loadPresets(deviceId) {
7334
7576
  try {
7335
7577
  const device = await this.ctx.fetchDevice(deviceId);
7336
- if (device.ptz === void 0) return [];
7337
- return (await device.ptz.getPresets({})).map((preset) => preset.name);
7578
+ if (device.ptz === void 0) return {
7579
+ kind: "answered",
7580
+ items: []
7581
+ };
7582
+ return {
7583
+ kind: "answered",
7584
+ items: (await device.ptz.getPresets({})).map((preset) => preset.name)
7585
+ };
7338
7586
  } catch (err) {
7339
- this.ctx.logger.debug("ha-export: could not probe PTZ presets", {
7340
- tags: { deviceId },
7341
- meta: { error: errMsg(err) }
7342
- });
7343
- return [];
7587
+ return {
7588
+ kind: "failed",
7589
+ reason: errMsg(err)
7590
+ };
7344
7591
  }
7345
7592
  }
7346
7593
  /**
@@ -7419,6 +7666,7 @@ var HaExportAddon = class extends BaseAddon {
7419
7666
  onLinkRestored: (brokerId) => this.reachability.request(brokerId)
7420
7667
  });
7421
7668
  client.start();
7669
+ this.resync.markGap("broker-connected");
7422
7670
  this.links.set(broker.id, {
7423
7671
  brokerId: broker.id,
7424
7672
  name: broker.name,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.601",
3
+ "version": "1.2.603",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",