@camstack/addon-provider-homeassistant 1.2.602 → 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.
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
24
24
  enumerable: true
25
25
  }) : target, mod));
26
26
  //#endregion
27
- const require_dist = require("../dist-xuu2eG_W.js");
27
+ const require_dist = require("../dist-DVNChk7-.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -4767,14 +4767,15 @@ var PushClient = class {
4767
4767
  * is flushed first — a value for an entity HA has not built yet is a
4768
4768
  * value it discards.
4769
4769
  */
4770
- async publishEntityChange(deviceKey, cmps, dev) {
4771
- if (this.disposed) return;
4770
+ async publishEntityChange(deviceKey, cmps, dev, full = false) {
4771
+ if (this.disposed) return false;
4772
4772
  await this.flush();
4773
- await this.send({
4773
+ return this.send({
4774
4774
  type: "entity_change",
4775
4775
  device_id: deviceKey,
4776
4776
  cmps,
4777
- dev
4777
+ dev,
4778
+ ...full ? { full: true } : {}
4778
4779
  });
4779
4780
  }
4780
4781
  /** Send everything buffered now. Called before any structure change. */
@@ -4791,12 +4792,15 @@ var PushClient = class {
4791
4792
  items
4792
4793
  });
4793
4794
  }
4795
+ /** `true` when Home Assistant took the message. */
4794
4796
  async send(message) {
4795
4797
  try {
4796
4798
  await this.transport(message);
4797
4799
  this.onReachable();
4800
+ return true;
4798
4801
  } catch (err) {
4799
4802
  this.onUnreachable(message, err);
4803
+ return false;
4800
4804
  }
4801
4805
  }
4802
4806
  onReachable() {
@@ -4910,6 +4914,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
4910
4914
  return reason === "structure-changed" && fingerprint === lastFingerprint;
4911
4915
  }
4912
4916
  //#endregion
4917
+ //#region src/ha-export/held-reads.ts
4918
+ var ReadMirror = class {
4919
+ keyOf;
4920
+ entries = /* @__PURE__ */ new Map();
4921
+ constructor(keyOf) {
4922
+ this.keyOf = keyOf;
4923
+ }
4924
+ resolve(deviceId, outcome) {
4925
+ const entry = this.entries.get(deviceId);
4926
+ if (outcome.kind === "failed") {
4927
+ if (entry === void 0) return {
4928
+ status: "unknown",
4929
+ reason: outcome.reason
4930
+ };
4931
+ return {
4932
+ status: "held-failed",
4933
+ items: entry.confirmed,
4934
+ reason: outcome.reason
4935
+ };
4936
+ }
4937
+ if (entry === void 0) {
4938
+ this.entries.set(deviceId, {
4939
+ confirmed: outcome.items,
4940
+ pendingDrop: null
4941
+ });
4942
+ return {
4943
+ status: "fresh",
4944
+ items: outcome.items
4945
+ };
4946
+ }
4947
+ const incoming = new Set(outcome.items.map(this.keyOf));
4948
+ const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
4949
+ if (dropped.length === 0) {
4950
+ entry.confirmed = outcome.items;
4951
+ entry.pendingDrop = null;
4952
+ return {
4953
+ status: "fresh",
4954
+ items: outcome.items
4955
+ };
4956
+ }
4957
+ const vote = dropped.join("\0");
4958
+ if (entry.pendingDrop === vote) {
4959
+ entry.confirmed = outcome.items;
4960
+ entry.pendingDrop = null;
4961
+ return {
4962
+ status: "fresh",
4963
+ items: outcome.items
4964
+ };
4965
+ }
4966
+ entry.pendingDrop = vote;
4967
+ return {
4968
+ status: "held-removal",
4969
+ items: entry.confirmed,
4970
+ reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
4971
+ };
4972
+ }
4973
+ };
4974
+ /**
4975
+ * The async sources `readCameraFunctions` could not obtain, or `null` when
4976
+ * every one answered. Their absence turns a function switch into "not
4977
+ * available", which removes its entity — a failure must not look like that.
4978
+ * `brokerAudioMuted` is only a read at all when the camera has a source node,
4979
+ * and `privacy` is a snapshot slice (absent = the cap is not there).
4980
+ */
4981
+ function cameraFunctionReadsFailed(reads, sourceNodeId) {
4982
+ const failed = [];
4983
+ if (reads.bindableCapNames === null) failed.push("bindableCapNames");
4984
+ if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
4985
+ if (reads.recordingEnabled === null) failed.push("recordingEnabled");
4986
+ if (reads.notificationsMuted === null) failed.push("notificationsMuted");
4987
+ if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
4988
+ return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
4989
+ }
4990
+ //#endregion
4991
+ //#region src/ha-export/resync-gate.ts
4992
+ /**
4993
+ * When the exporter must send a FULL authoritative announce rather than let an
4994
+ * unchanged structure short-circuit.
4995
+ *
4996
+ * A gap is any moment after which the hub can no longer vouch for what Home
4997
+ * Assistant holds: the first pass after a start (the receiver may have been
4998
+ * running while this addon was down), a broker link that was just opened, an
4999
+ * announce or state push that was not delivered. The next pass after a gap is
5000
+ * a full resync, once, whatever its reason — an event-debounced pass included,
5001
+ * which would otherwise skip on an identical fingerprint and leave the
5002
+ * receiver unrepaired until the periodic pass.
5003
+ *
5004
+ * Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
5005
+ */
5006
+ /** Reasons whose pass is a full resync whether or not a gap was noted. */
5007
+ var ALWAYS_FULL = new Set([
5008
+ "boot",
5009
+ "link-restored",
5010
+ "periodic"
5011
+ ]);
5012
+ var ResyncGate = class {
5013
+ gap = true;
5014
+ lastReason = "start";
5015
+ /** Note that the receiver may be out of step; the next pass will be full. */
5016
+ markGap(reason) {
5017
+ this.gap = true;
5018
+ this.lastReason = reason;
5019
+ }
5020
+ /** `true` while a gap is waiting for a pass. */
5021
+ pending() {
5022
+ return this.gap;
5023
+ }
5024
+ /** Why the gap was opened (for the log line). */
5025
+ gapReason() {
5026
+ return this.gap ? this.lastReason : null;
5027
+ }
5028
+ /**
5029
+ * Called once at the start of a pass. Returns whether the pass is a full
5030
+ * resync and consumes the gap noted so far; a gap noted DURING the pass is
5031
+ * kept for the next one.
5032
+ */
5033
+ take(reason) {
5034
+ const full = this.gap || ALWAYS_FULL.has(reason);
5035
+ this.gap = false;
5036
+ this.lastReason = null;
5037
+ return full;
5038
+ }
5039
+ };
5040
+ //#endregion
4913
5041
  //#region src/ha-export/rule-triggers.ts
4914
5042
  function reduceOne(row) {
4915
5043
  return {
@@ -5964,9 +6092,9 @@ function projectSynthetic(input, nowMs) {
5964
6092
  topic: stateTopic(srv, `${slug}_memory`),
5965
6093
  value: String(Math.round(node.memoryPercent))
5966
6094
  });
5967
- if (node.online && node.uptime > 0) values.push({
6095
+ if (node.online && node.uptimeMs > 0) values.push({
5968
6096
  topic: stateTopic(srv, `${slug}_uptime`),
5969
- value: (/* @__PURE__ */ new Date(nowMs - node.uptime * 1e3)).toISOString()
6097
+ value: new Date(nowMs - node.uptimeMs).toISOString()
5970
6098
  });
5971
6099
  if (node.footageUsedBytes !== null) values.push({
5972
6100
  topic: stateTopic(srv, `${slug}_footage_used_bytes`),
@@ -6032,6 +6160,8 @@ var PUSH_PATH = "/api/camstack/push";
6032
6160
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
6033
6161
  /** How long `doorbell_pressed` stays on before its OFF edge is published. */
6034
6162
  var DOORBELL_PULSE_MS = 5e3;
6163
+ /** How soon a pass with a held-back read is retried. */
6164
+ var READ_RETRY_MS = 3e4;
6035
6165
  /**
6036
6166
  * The earliest timestamp a real change could plausibly carry. Below this,
6037
6167
  * a slice field is a placeholder or a seeded "known value, unknown change
@@ -6078,6 +6208,24 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6078
6208
  * still holds through the periodic pass.
6079
6209
  */
6080
6210
  lastStructureFingerprint;
6211
+ /**
6212
+ * When the next pass must be a FULL authoritative announce (`full: true` on
6213
+ * the wire) whatever the fingerprint says: a start, a new broker link, an
6214
+ * undelivered push, a failed pass. See `resync-gate.ts`.
6215
+ */
6216
+ resync = new ResyncGate();
6217
+ /**
6218
+ * The last EXPORTED set of each part of a device's component set. A read
6219
+ * that failed (or a provider not registered yet) holds it; a shrink needs
6220
+ * two agreeing answers. See `held-reads.ts`.
6221
+ */
6222
+ boundCapsMirror = new ReadMirror((cap) => cap);
6223
+ zonesMirror = new ReadMirror((zone) => zone.id);
6224
+ streamsMirror = new ReadMirror((stream) => stream.id);
6225
+ presetsMirror = new ReadMirror((preset) => preset);
6226
+ switchesMirror = new ReadMirror((fn) => fn.id);
6227
+ /** A held-back read is retried soon, not at the next 300 s pass. */
6228
+ readTimer = null;
6081
6229
  entityCount = 0;
6082
6230
  /** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
6083
6231
  lastDoorbellPressAt = /* @__PURE__ */ new Map();
@@ -6154,6 +6302,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6154
6302
  for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
6155
6303
  this.doorbellReleaseTimers.clear();
6156
6304
  this.reachability.dispose();
6305
+ if (this.readTimer !== null) clearTimeout(this.readTimer);
6306
+ this.readTimer = null;
6157
6307
  for (const link of this.links.values()) await link.client.dispose();
6158
6308
  this.links.clear();
6159
6309
  });
@@ -6626,6 +6776,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6626
6776
  const startedAt = Date.now();
6627
6777
  try {
6628
6778
  await this.refreshBrokers();
6779
+ const fullResync = this.resync.take(reason);
6780
+ let readsHeld = false;
6629
6781
  await this.refreshMediaBaseUrl();
6630
6782
  await this.refreshComponentSupport();
6631
6783
  const enabled = this.enabledBrokerIds();
@@ -6688,16 +6840,22 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6688
6840
  * used to call the deprecated `getCameraSwitches` separately for the
6689
6841
  * same answer.
6690
6842
  */
6691
- const cameraFunctions = device.type === "camera" ? deriveCameraFunctions(await this.readCameraFunctions({
6843
+ const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
6844
+ const functionReads = device.type === "camera" ? await this.readCameraFunctions({
6692
6845
  deviceId: numericId,
6693
6846
  deviceType: device.type,
6694
6847
  deviceDisabled: device.disabled,
6695
6848
  privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
6696
- sourceNodeId: this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null,
6849
+ sourceNodeId,
6697
6850
  bindableCapsByType,
6698
6851
  mutedDeviceIds
6699
- })) : null;
6700
- const catalogDevice = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions);
6852
+ }) : null;
6853
+ const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
6854
+ const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
6855
+ const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
6856
+ if (built.heldBack) readsHeld = true;
6857
+ const catalogDevice = built.device;
6858
+ if (catalogDevice === null) continue;
6701
6859
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
6702
6860
  const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
6703
6861
  /**
@@ -6768,7 +6926,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6768
6926
  this.entityCount = entityCount;
6769
6927
  this.unclassified = unclassifiedCaps([...allCaps]);
6770
6928
  const fingerprint = structureFingerprint(exported);
6771
- if (shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
6929
+ if (readsHeld) this.scheduleReadRetry();
6930
+ if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
6772
6931
  this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
6773
6932
  devices: exported.size,
6774
6933
  entities: entityCount
@@ -6786,11 +6945,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6786
6945
  entities: line.entities
6787
6946
  }
6788
6947
  });
6789
- await this.announce(exported);
6948
+ const delivered = await this.announce(exported, fullResync);
6790
6949
  await this.pushFullState(exported, snapshots);
6791
6950
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
6951
+ if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
6952
+ this.resync.markGap("push-not-delivered");
6953
+ this.lastStructureFingerprint = void 0;
6954
+ this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
6955
+ }
6792
6956
  this.ctx.logger.info("ha-export: reconciled", { meta: {
6793
6957
  reason,
6958
+ fullResync,
6794
6959
  brokers: enabled.length,
6795
6960
  devices: exported.size,
6796
6961
  entities: entityCount,
@@ -6801,6 +6966,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6801
6966
  this.lastError = void 0;
6802
6967
  } catch (err) {
6803
6968
  this.lastError = errMsg(err);
6969
+ this.resync.markGap("reconcile-failed");
6804
6970
  this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
6805
6971
  }
6806
6972
  }
@@ -6999,7 +7165,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
6999
7165
  online: node.isOnline,
7000
7166
  cpuPercent: node.cpuPercent,
7001
7167
  memoryPercent: node.memoryPercent,
7002
- uptime: node.uptime,
7168
+ uptimeMs: node.uptime,
7003
7169
  isHub: node.isHub,
7004
7170
  footageUsedBytes: footage.footageUsedBytes,
7005
7171
  footageFreePercent: footage.footageFreePercent,
@@ -7018,13 +7184,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7018
7184
  for (const value of values) link.client.publishState(value.topic, value.value);
7019
7185
  }
7020
7186
  }
7021
- async announce(exported) {
7187
+ /** `false` when any announce was not delivered. */
7188
+ async announce(exported, full) {
7189
+ let allDelivered = true;
7022
7190
  for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
7023
7191
  const link = this.links.get(brokerId);
7024
7192
  if (link === void 0) continue;
7025
7193
  try {
7026
- await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
7194
+ if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
7027
7195
  } catch (err) {
7196
+ allDelivered = false;
7028
7197
  this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
7029
7198
  tags: { deviceId: entry.deviceId },
7030
7199
  meta: {
@@ -7034,6 +7203,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7034
7203
  });
7035
7204
  }
7036
7205
  }
7206
+ return allDelivered;
7037
7207
  }
7038
7208
  async pushFullState(exported, snapshots) {
7039
7209
  for (const entry of exported.values()) try {
@@ -7151,46 +7321,105 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7151
7321
  }
7152
7322
  return values;
7153
7323
  }
7154
- async buildCatalogDevice(device, snapshotRaw, cameraFunctions) {
7324
+ /**
7325
+ * `device: null` = a part of this device's component set cannot be read and
7326
+ * nothing was ever exported for it, so the device is not announced this pass
7327
+ * (an empty part would make Home Assistant delete what it holds). `heldBack`
7328
+ * = at least one part is not a fresh answer, so a retry is worth arming.
7329
+ */
7330
+ async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
7155
7331
  const snapshot = toSnapshot(snapshotRaw);
7156
- const boundCaps = await this.loadBoundCaps(device.id);
7157
- const zones = device.type === "camera" ? await this.loadZones(device.id) : [];
7332
+ let heldBack = false;
7333
+ let unknown = false;
7334
+ const settle = (part, mirror, outcome) => {
7335
+ const resolution = mirror.resolve(device.id, outcome);
7336
+ if (resolution.status !== "fresh") heldBack = true;
7337
+ if (resolution.status === "unknown") unknown = true;
7338
+ this.logHeldRead(device.id, part, resolution);
7339
+ return resolution.status === "unknown" ? [] : resolution.items;
7340
+ };
7341
+ const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
7342
+ const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
7158
7343
  /**
7159
7344
  * Every camera function, available or not — `entity-catalog.ts` is the
7160
7345
  * one place that filters to `available`, exactly as it did for
7161
- * `getCameraSwitches`'s answer.
7346
+ * `getCameraSwitches`'s answer. A function that did not answer is
7347
+ * "unavailable", which removes its entity, so the set goes through the
7348
+ * mirror: held when a source failed, shrunk only on two agreeing passes.
7162
7349
  */
7163
- const switches = (cameraFunctions ?? []).map((fn) => ({
7350
+ const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
7164
7351
  id: fn.id,
7165
7352
  label: fn.label,
7166
7353
  available: fn.available
7167
7354
  }));
7168
- const ptzPresets = device.type === "camera" && boundCaps.includes("ptz") ? await this.loadPresets(device.id) : [];
7169
- const streams = device.type === "camera" && boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : [];
7355
+ const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
7356
+ kind: "failed",
7357
+ reason: functionsFailure
7358
+ } : {
7359
+ kind: "answered",
7360
+ items: mappedSwitches.filter((fn) => fn.available)
7361
+ });
7362
+ const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
7363
+ kind: "answered",
7364
+ items: []
7365
+ }) : [];
7366
+ const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
7367
+ kind: "answered",
7368
+ items: []
7369
+ }) : [];
7370
+ if (unknown) return {
7371
+ device: null,
7372
+ heldBack
7373
+ };
7170
7374
  const manufacturer = readString(device.metadata ?? {}, "manufacturer");
7171
7375
  const model = readString(device.metadata ?? {}, "model");
7172
7376
  return {
7173
- id: device.id,
7174
- name: device.name,
7175
- type: device.type,
7176
- ...manufacturer !== null ? { manufacturer } : {},
7177
- ...model !== null ? { model } : {},
7178
- features: device.features,
7179
- boundCaps,
7180
- zones,
7181
- switches,
7182
- ptzPresets,
7183
- streams,
7184
- slices: Object.keys(snapshot),
7185
- /**
7186
- * The slice VALUES, for the native platforms only: a native
7187
- * `climate` announces the modes the device accepts, and
7188
- * `availableModes` is on the slice and nowhere else. See
7189
- * `CatalogDevice.capSlices`.
7190
- */
7191
- capSlices: snapshot
7377
+ device: {
7378
+ id: device.id,
7379
+ name: device.name,
7380
+ type: device.type,
7381
+ ...manufacturer !== null ? { manufacturer } : {},
7382
+ ...model !== null ? { model } : {},
7383
+ features: device.features,
7384
+ boundCaps,
7385
+ zones,
7386
+ switches,
7387
+ ptzPresets,
7388
+ streams,
7389
+ slices: Object.keys(snapshot),
7390
+ /**
7391
+ * The slice VALUES, for the native platforms only: a native
7392
+ * `climate` announces the modes the device accepts, and
7393
+ * `availableModes` is on the slice and nowhere else. See
7394
+ * `CatalogDevice.capSlices`.
7395
+ */
7396
+ capSlices: snapshot
7397
+ },
7398
+ heldBack
7192
7399
  };
7193
7400
  }
7401
+ /** One WARN per held part, with the device tag and the reason. */
7402
+ logHeldRead(deviceId, part, resolution) {
7403
+ if (resolution.status === "fresh") return;
7404
+ const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
7405
+ 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";
7406
+ this.ctx.logger.warn(message, {
7407
+ tags: { deviceId },
7408
+ meta: {
7409
+ part,
7410
+ reason: resolution.reason,
7411
+ kept
7412
+ }
7413
+ });
7414
+ }
7415
+ /** Retry soon after a held-back read: the provider usually registers seconds later. */
7416
+ scheduleReadRetry() {
7417
+ if (this.readTimer !== null) return;
7418
+ this.readTimer = setTimeout(() => {
7419
+ this.readTimer = null;
7420
+ this.reconcile("read-retry");
7421
+ }, READ_RETRY_MS);
7422
+ }
7194
7423
  /**
7195
7424
  * Every stream of the camera, as the hub's own picker lists them.
7196
7425
  *
@@ -7208,45 +7437,54 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7208
7437
  */
7209
7438
  async loadStreamChoices(deviceId) {
7210
7439
  try {
7211
- return (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
7212
- id: choice.id,
7213
- label: choice.label,
7214
- target: choice.target,
7215
- ...choice.resolution === null ? {} : { height: choice.resolution.height }
7216
- }));
7440
+ return {
7441
+ kind: "answered",
7442
+ items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
7443
+ id: choice.id,
7444
+ label: choice.label,
7445
+ target: choice.target,
7446
+ ...choice.resolution === null ? {} : { height: choice.resolution.height }
7447
+ }))
7448
+ };
7217
7449
  } catch (err) {
7218
- this.ctx.logger.warn("ha-export: could not read the camera streams, no camera entity", {
7219
- tags: { deviceId },
7220
- meta: { error: errMsg(err) }
7221
- });
7222
- return [];
7450
+ return {
7451
+ kind: "failed",
7452
+ reason: errMsg(err)
7453
+ };
7223
7454
  }
7224
7455
  }
7225
7456
  async loadBoundCaps(deviceId) {
7226
7457
  try {
7227
- return (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName);
7458
+ return {
7459
+ kind: "answered",
7460
+ items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
7461
+ };
7228
7462
  } catch (err) {
7229
- this.ctx.logger.warn("ha-export: could not read bindings, exporting without them", {
7230
- tags: { deviceId },
7231
- meta: { error: errMsg(err) }
7232
- });
7233
- return [];
7463
+ return {
7464
+ kind: "failed",
7465
+ reason: errMsg(err)
7466
+ };
7234
7467
  }
7235
7468
  }
7236
7469
  async loadZones(deviceId) {
7237
7470
  try {
7238
7471
  const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
7239
- if (snapshot === null) return [];
7240
- return snapshot.zones.map((zone) => ({
7241
- id: zone.zoneId,
7242
- name: zone.zoneName
7243
- }));
7472
+ if (snapshot === null) return {
7473
+ kind: "answered",
7474
+ items: []
7475
+ };
7476
+ return {
7477
+ kind: "answered",
7478
+ items: snapshot.zones.map((zone) => ({
7479
+ id: zone.zoneId,
7480
+ name: zone.zoneName
7481
+ }))
7482
+ };
7244
7483
  } catch (err) {
7245
- this.ctx.logger.warn("ha-export: could not read zones, exporting no zone entities", {
7246
- tags: { deviceId },
7247
- meta: { error: errMsg(err) }
7248
- });
7249
- return [];
7484
+ return {
7485
+ kind: "failed",
7486
+ reason: errMsg(err)
7487
+ };
7250
7488
  }
7251
7489
  }
7252
7490
  /**
@@ -7365,14 +7603,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7365
7603
  async loadPresets(deviceId) {
7366
7604
  try {
7367
7605
  const device = await this.ctx.fetchDevice(deviceId);
7368
- if (device.ptz === void 0) return [];
7369
- return (await device.ptz.getPresets({})).map((preset) => preset.name);
7606
+ if (device.ptz === void 0) return {
7607
+ kind: "answered",
7608
+ items: []
7609
+ };
7610
+ return {
7611
+ kind: "answered",
7612
+ items: (await device.ptz.getPresets({})).map((preset) => preset.name)
7613
+ };
7370
7614
  } catch (err) {
7371
- this.ctx.logger.debug("ha-export: could not probe PTZ presets", {
7372
- tags: { deviceId },
7373
- meta: { error: errMsg(err) }
7374
- });
7375
- return [];
7615
+ return {
7616
+ kind: "failed",
7617
+ reason: errMsg(err)
7618
+ };
7376
7619
  }
7377
7620
  }
7378
7621
  /**
@@ -7451,6 +7694,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7451
7694
  onLinkRestored: (brokerId) => this.reachability.request(brokerId)
7452
7695
  });
7453
7696
  client.start();
7697
+ this.resync.markGap("broker-connected");
7454
7698
  this.links.set(broker.id, {
7455
7699
  brokerId: broker.id,
7456
7700
  name: broker.name,