@camstack/addon-provider-homeassistant 1.2.639 → 1.2.640

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-C_XoeULr.js");
27
+ const require_ha_broker_source = require("../ha-broker-source-0HGmZj7D.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -32,20 +32,6 @@ node_fs = __toESM(node_fs, 1);
32
32
  let node_path = require("node:path");
33
33
  node_path = __toESM(node_path, 1);
34
34
  require("node:child_process");
35
- /** The `BrokerInfo.kind` tag the Home Assistant provider stamps. */
36
- var HA_BROKER_KIND = "home-assistant";
37
- /**
38
- * Every Home Assistant broker currently registered, cluster-wide.
39
- *
40
- * Unpinned by design (see the module docblock) and filtered by `kind`, which is
41
- * the contract check that matters: whatever mix of providers answered the
42
- * union, only `home-assistant` brokers are ever exported to over Home
43
- * Assistant's transport.
44
- */
45
- async function listHomeAssistantBrokers(query) {
46
- return (await query({})).filter((broker) => broker.kind === HA_BROKER_KIND);
47
- }
48
- //#endregion
49
35
  //#region src/ha-export/camera-function-writes.ts
50
36
  /**
51
37
  * Performs the WRITE of a camera function on the authority that owns it.
@@ -105,7 +91,7 @@ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
105
91
  await api.streamBroker.setDeviceAudioMute.mutate({
106
92
  deviceId,
107
93
  muted: write.muted
108
- }, require_dist.nodePin(sourceNodeId));
94
+ }, require_ha_broker_source.nodePin(sourceNodeId));
109
95
  return;
110
96
  }
111
97
  }
@@ -739,8 +725,8 @@ function consumableEntity(key, facet) {
739
725
  * rather than to an icon for nothing.
740
726
  */
741
727
  function consumableForm(item) {
742
- const declared = require_dist.ConsumableFormSchema.safeParse(item.form);
743
- return require_dist.consumableFormOf({
728
+ const declared = require_ha_broker_source.ConsumableFormSchema.safeParse(item.form);
729
+ return require_ha_broker_source.consumableFormOf({
744
730
  ...item.kind !== void 0 ? { kind: item.kind } : {},
745
731
  ...declared.success ? { form: declared.data } : {}
746
732
  });
@@ -753,7 +739,7 @@ function partIcon(key, label) {
753
739
  function formIcon(item, form) {
754
740
  switch (form) {
755
741
  case "part": return partIcon(item.key, item.label);
756
- case "tank": return require_dist.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
742
+ case "tank": return require_ha_broker_source.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
757
743
  case "product": return "mdi:shaker-outline";
758
744
  case "bin": return "mdi:delete";
759
745
  }
@@ -764,8 +750,8 @@ function formIcon(item, form) {
764
750
  * form this export does not know degrades one icon instead of refusing the
765
751
  * whole slice.
766
752
  */
767
- var ExportConsumableItemSchema = require_dist.ConsumableItemSchema.extend({ form: require_dist.unknown().optional() });
768
- var ExportConsumablesSchema = require_dist.object({ items: require_dist.array(ExportConsumableItemSchema) });
753
+ var ExportConsumableItemSchema = require_ha_broker_source.ConsumableItemSchema.extend({ form: require_ha_broker_source.unknown().optional() });
754
+ var ExportConsumablesSchema = require_ha_broker_source.object({ items: require_ha_broker_source.array(ExportConsumableItemSchema) });
769
755
  /**
770
756
  * A worn part's life is a diagnostic; a tank's fill is what the device page
771
757
  * is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
@@ -778,7 +764,7 @@ function consumableEntities(item) {
778
764
  capName: "consumables",
779
765
  itemKey: item.key
780
766
  };
781
- const kind = require_dist.consumableKindOf(item);
767
+ const kind = require_ha_broker_source.consumableKindOf(item);
782
768
  const form = consumableForm(item);
783
769
  const icon = formIcon(item, form);
784
770
  /** What the item IS, on the sensors that describe it (not on a counter or a button). */
@@ -818,7 +804,7 @@ function consumableEntities(item) {
818
804
  platform: "sensor",
819
805
  label: `${item.label} status`,
820
806
  deviceClass: "enum",
821
- options: require_dist.ConsumableStatusSchema.options,
807
+ options: require_ha_broker_source.ConsumableStatusSchema.options,
822
808
  icon,
823
809
  attributes,
824
810
  entityCategory: "diagnostic"
@@ -2395,7 +2381,7 @@ var CAP_ENTITY_MAP = {
2395
2381
  * failure. `fanMode` and `preset` get no such list at all: they are
2396
2382
  * free-form strings, so they stay sensors.
2397
2383
  */
2398
- options: require_dist.HvacModeSchema.options,
2384
+ options: require_ha_broker_source.HvacModeSchema.options,
2399
2385
  extras: {
2400
2386
  "current-temp": {
2401
2387
  platform: "sensor",
@@ -2545,7 +2531,7 @@ var CAP_ENTITY_MAP = {
2545
2531
  field: "direction",
2546
2532
  label: "Direction",
2547
2533
  writable: true,
2548
- options: require_dist.FanDirectionSchema.options,
2534
+ options: require_ha_broker_source.FanDirectionSchema.options,
2549
2535
  enabledByDefault: false
2550
2536
  }
2551
2537
  }
@@ -2975,7 +2961,7 @@ var CAP_ENTITY_MAP = {
2975
2961
  field: "repeat",
2976
2962
  label: "Repeat",
2977
2963
  writable: true,
2978
- options: require_dist.MediaPlayerRepeatSchema.options,
2964
+ options: require_ha_broker_source.MediaPlayerRepeatSchema.options,
2979
2965
  enabledByDefault: false
2980
2966
  },
2981
2967
  shuffle: {
@@ -3466,7 +3452,7 @@ function controlEntitySpecs(slice, quantity) {
3466
3452
  const min = readFiniteNumber(slice, "min") ?? PERCENT_RANGE.min;
3467
3453
  const max = readFiniteNumber(slice, "max") ?? PERCENT_RANGE.max;
3468
3454
  const step = readFiniteNumber(slice, "step") ?? PERCENT_RANGE.step;
3469
- const unit = quantity === void 0 ? void 0 : require_dist.baseUnitOf(quantity);
3455
+ const unit = quantity === void 0 ? void 0 : require_ha_broker_source.baseUnitOf(quantity);
3470
3456
  if (min < max && step > 0) return [{
3471
3457
  entity: primary,
3472
3458
  platform: "number",
@@ -3768,7 +3754,7 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3768
3754
  continue;
3769
3755
  }
3770
3756
  if (cap === "numeric-sensor") {
3771
- const unit = device.readingQuantity === void 0 ? void 0 : require_dist.baseUnitOf(device.readingQuantity);
3757
+ const unit = device.readingQuantity === void 0 ? void 0 : require_ha_broker_source.baseUnitOf(device.readingQuantity);
3772
3758
  specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(unit === void 0 ? spec : {
3773
3759
  ...spec,
3774
3760
  unit
@@ -3842,7 +3828,7 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3842
3828
  * was.
3843
3829
  */
3844
3830
  /** The camera function ids, keyed by the entity slug the catalog emits. */
3845
- var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_dist.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
3831
+ var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_ha_broker_source.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
3846
3832
  var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
3847
3833
  function parseBool(value) {
3848
3834
  const lower = value.trim().toLowerCase();
@@ -3905,7 +3891,7 @@ function parseAlarmAction(value) {
3905
3891
  if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
3906
3892
  if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
3907
3893
  const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
3908
- const mode = require_dist.AlarmArmModeSchema.safeParse(withoutPrefix);
3894
+ const mode = require_ha_broker_source.AlarmArmModeSchema.safeParse(withoutPrefix);
3909
3895
  return mode.success ? {
3910
3896
  kind: "arm",
3911
3897
  mode: mode.data
@@ -4054,7 +4040,7 @@ var CAP_COMMAND_ROUTES = {
4054
4040
  },
4055
4041
  "climate-control": {
4056
4042
  primary: (deviceId, value) => {
4057
- const mode = parseEnum(require_dist.HvacModeSchema, value);
4043
+ const mode = parseEnum(require_ha_broker_source.HvacModeSchema, value);
4058
4044
  return mode === null ? null : {
4059
4045
  kind: "climate-mode",
4060
4046
  deviceId,
@@ -4169,7 +4155,7 @@ var CAP_COMMAND_ROUTES = {
4169
4155
  };
4170
4156
  },
4171
4157
  direction: (deviceId, value) => {
4172
- const direction = parseEnum(require_dist.FanDirectionSchema, value);
4158
+ const direction = parseEnum(require_ha_broker_source.FanDirectionSchema, value);
4173
4159
  return direction === null ? null : {
4174
4160
  kind: "fan-direction",
4175
4161
  deviceId,
@@ -4373,7 +4359,7 @@ var CAP_COMMAND_ROUTES = {
4373
4359
  };
4374
4360
  },
4375
4361
  repeat: (deviceId, value) => {
4376
- const repeat = parseEnum(require_dist.MediaPlayerRepeatSchema, value);
4362
+ const repeat = parseEnum(require_ha_broker_source.MediaPlayerRepeatSchema, value);
4377
4363
  return repeat === null ? null : {
4378
4364
  kind: "media-repeat",
4379
4365
  deviceId,
@@ -5248,12 +5234,125 @@ var HOME_ASSISTANT_OAUTH_INTEGRATION = {
5248
5234
  refreshTokenTtlSec: 365 * 24 * 60 * 60
5249
5235
  };
5250
5236
  //#endregion
5237
+ //#region src/ha-export/push-failure.ts
5238
+ /**
5239
+ * Why a push to Home Assistant failed, in words an operator can act on.
5240
+ *
5241
+ * The reason used to live only in the log line's `meta.error`, which a log
5242
+ * viewer that shows the message alone never displays: an operator saw
5243
+ * "push failed, messages dropped" every 30 s for hours with nothing to act on.
5244
+ */
5245
+ /** Where the custom component registers its push view inside HA. */
5246
+ var PUSH_PATH = "/api/camstack/push";
5247
+ var MAX_BODY_CHARS = 200;
5248
+ /** A non-2xx answer from Home Assistant's push view. */
5249
+ var PushHttpError = class extends Error {
5250
+ status;
5251
+ body;
5252
+ constructor(status, body) {
5253
+ super(`Home Assistant answered ${String(status)}`);
5254
+ this.status = status;
5255
+ this.body = body;
5256
+ this.name = "PushHttpError";
5257
+ }
5258
+ };
5259
+ /** First 200 chars of a response body, with any bearer token removed. */
5260
+ function scrubBody(body) {
5261
+ return body.replace(/Bearer\s+[A-Za-z0-9._~+/=-]+/gi, "Bearer [redacted]").slice(0, MAX_BODY_CHARS);
5262
+ }
5263
+ var NETWORK_CODES = new Set([
5264
+ "ECONNREFUSED",
5265
+ "ENOTFOUND",
5266
+ "ETIMEDOUT",
5267
+ "ECONNRESET",
5268
+ "EHOSTUNREACH",
5269
+ "ENETUNREACH",
5270
+ "EAI_AGAIN",
5271
+ "UND_ERR_CONNECT_TIMEOUT"
5272
+ ]);
5273
+ function networkCode(err) {
5274
+ for (let current = err, depth = 0; current instanceof Error && depth < 4; depth += 1) {
5275
+ const code = Reflect.get(current, "code");
5276
+ if (typeof code === "string" && NETWORK_CODES.has(code)) return code;
5277
+ current = current.cause;
5278
+ }
5279
+ }
5280
+ function describePushFailure(err) {
5281
+ if (err instanceof PushHttpError) {
5282
+ const http = `HTTP ${String(err.status)}`;
5283
+ if (err.status === 404) return {
5284
+ kind: "component-missing",
5285
+ permanent: true,
5286
+ text: `${http} — Home Assistant has no CamStack integration installed (the ${PUSH_PATH} endpoint is missing). Install it from HACS, or turn off Home Assistant export`
5287
+ };
5288
+ if (err.status === 401 || err.status === 403) return {
5289
+ kind: "token-rejected",
5290
+ permanent: false,
5291
+ text: `${http} — Home Assistant rejected the access token (revoked, or the user lacks rights); create a new long-lived token`
5292
+ };
5293
+ if (err.status === 413) return {
5294
+ kind: "too-large",
5295
+ permanent: false,
5296
+ text: `${http} — the batch is too large for Home Assistant or a proxy in front of it`
5297
+ };
5298
+ const body = scrubBody(err.body);
5299
+ return {
5300
+ kind: "other",
5301
+ permanent: false,
5302
+ text: body.length > 0 ? `${http} — ${body}` : http
5303
+ };
5304
+ }
5305
+ const timedOut = err instanceof Error && (err.name === "TimeoutError" || err.name === "AbortError");
5306
+ const code = networkCode(err);
5307
+ const fetchFailed = err instanceof TypeError && err.message === "fetch failed";
5308
+ if (timedOut || code !== void 0 || fetchFailed) return {
5309
+ kind: "unreachable",
5310
+ permanent: false,
5311
+ text: `Home Assistant is unreachable (${timedOut ? "timed out" : code ?? "network error"}) — check the address and that it is running`
5312
+ };
5313
+ return {
5314
+ kind: "other",
5315
+ permanent: false,
5316
+ text: err instanceof Error ? err.message : String(err)
5317
+ };
5318
+ }
5319
+ /**
5320
+ * POST one message to Home Assistant's push view.
5321
+ *
5322
+ * Authenticated with the broker's OWN long-lived token: the component
5323
+ * registers its view behind HA auth, so there is no second shared secret
5324
+ * to configure or rotate. A non-2xx throws `PushHttpError` carrying the first
5325
+ * 200 chars of the body (never the request headers), which is how the push
5326
+ * client learns why the instance is not taking the push.
5327
+ */
5328
+ async function postToHomeAssistant(baseUrl, token, message) {
5329
+ const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
5330
+ method: "POST",
5331
+ headers: {
5332
+ authorization: `Bearer ${token}`,
5333
+ "content-type": "application/json"
5334
+ },
5335
+ body: JSON.stringify(message),
5336
+ signal: AbortSignal.timeout(1e4)
5337
+ });
5338
+ if (!response.ok) {
5339
+ const body = await response.text().catch(() => "");
5340
+ throw new PushHttpError(response.status, scrubBody(body));
5341
+ }
5342
+ }
5343
+ //#endregion
5251
5344
  //#region src/ha-export/push-client.ts
5252
5345
  /** How long state updates accumulate before one POST carries them all. */
5253
5346
  var BATCH_FLUSH_MS = 1e3;
5254
5347
  /** Availability cadence. The component's timeout is a multiple of this. */
5255
5348
  var HEARTBEAT_INTERVAL_MS = 3e4;
5256
5349
  var IMAGE_SIGNAL_PREFIX = "__image_updated__:";
5350
+ /**
5351
+ * How long a permanent failure (a missing component) is left alone before the
5352
+ * next probe. A 404 does not heal by retrying; it heals when the operator
5353
+ * installs the component, which the settings-change reset picks up at once.
5354
+ */
5355
+ var COMPONENT_RECHECK_MS = 10 * 6e4;
5257
5356
  /** Rate limit for the "HA is not answering" warning. */
5258
5357
  var DROP_LOG_INTERVAL_MS = 3e4;
5259
5358
  var PushClient = class {
@@ -5270,6 +5369,8 @@ var PushClient = class {
5270
5369
  dropped = 0;
5271
5370
  droppedLoggedAt = 0;
5272
5371
  disposed = false;
5372
+ lastFailure = null;
5373
+ suspendedUntil = 0;
5273
5374
  constructor(options) {
5274
5375
  this.transport = options.transport;
5275
5376
  this.logger = options.logger;
@@ -5280,6 +5381,18 @@ var PushClient = class {
5280
5381
  get healthy() {
5281
5382
  return this.available;
5282
5383
  }
5384
+ /** Why the last push failed; `null` while the link is healthy. */
5385
+ get failure() {
5386
+ return this.lastFailure;
5387
+ }
5388
+ /** A permanent failure is being left alone until the next probe. */
5389
+ get suspended() {
5390
+ return this.suspendedUntil > Date.now();
5391
+ }
5392
+ /** Probe again at once — the operator changed something. */
5393
+ resetBackoff() {
5394
+ this.suspendedUntil = 0;
5395
+ }
5283
5396
  /** Messages discarded because HA did not answer. Never silent. */
5284
5397
  get droppedCount() {
5285
5398
  return this.dropped;
@@ -5355,11 +5468,29 @@ var PushClient = class {
5355
5468
  }
5356
5469
  /** `true` when Home Assistant took the message. */
5357
5470
  async send(message) {
5471
+ if (this.suspended) {
5472
+ this.dropped += message.type === "batch" ? message.items.length : 1;
5473
+ return false;
5474
+ }
5358
5475
  try {
5359
5476
  await this.transport(message);
5360
5477
  this.onReachable();
5361
5478
  return true;
5362
5479
  } catch (err) {
5480
+ if (err instanceof PushHttpError && err.status === 413 && message.type === "batch") {
5481
+ const half = Math.ceil(message.items.length / 2);
5482
+ if (message.items.length > 1) {
5483
+ const first = await this.send({
5484
+ type: "batch",
5485
+ items: message.items.slice(0, half)
5486
+ });
5487
+ const second = await this.send({
5488
+ type: "batch",
5489
+ items: message.items.slice(half)
5490
+ });
5491
+ return first && second;
5492
+ }
5493
+ }
5363
5494
  this.onUnreachable(message, err);
5364
5495
  return false;
5365
5496
  }
@@ -5367,6 +5498,8 @@ var PushClient = class {
5367
5498
  onReachable() {
5368
5499
  if (this.available) return;
5369
5500
  this.available = true;
5501
+ this.lastFailure = null;
5502
+ this.suspendedUntil = 0;
5370
5503
  /**
5371
5504
  * The link came back. Drop the dedup cache so the reconcile that
5372
5505
  * follows re-pushes every value: HA may have restarted and lost its
@@ -5404,14 +5537,18 @@ var PushClient = class {
5404
5537
  */
5405
5538
  onUnreachable(message, err) {
5406
5539
  this.available = false;
5540
+ const failure = describePushFailure(err);
5541
+ this.lastFailure = failure;
5542
+ if (failure.permanent) this.suspendedUntil = Date.now() + COMPONENT_RECHECK_MS;
5407
5543
  this.dropped += message.type === "batch" ? message.items.length : 1;
5408
5544
  const now = Date.now();
5409
5545
  if (now - this.droppedLoggedAt < DROP_LOG_INTERVAL_MS) return;
5410
5546
  this.droppedLoggedAt = now;
5411
- this.logger.warn("ha-export: push to Home Assistant failed, messages dropped", { meta: {
5547
+ this.logger.warn(`ha-export: push to Home Assistant failed (${failure.text}), messages dropped`, { meta: {
5412
5548
  brokerId: this.brokerId,
5413
5549
  dropped: this.dropped,
5414
5550
  messageType: message.type,
5551
+ failureKind: failure.kind,
5415
5552
  error: err instanceof Error ? err.message : String(err)
5416
5553
  } });
5417
5554
  }
@@ -5657,7 +5794,7 @@ function reduceLastTriggers(rows) {
5657
5794
  function macroOf(className) {
5658
5795
  const lower = className.toLowerCase();
5659
5796
  if (EXPORTED_MACROS.includes(lower)) return lower;
5660
- const mapped = require_dist.COCO_TO_MACRO.mapping[lower];
5797
+ const mapped = require_ha_broker_source.COCO_TO_MACRO.mapping[lower];
5661
5798
  return typeof mapped === "string" ? mapped : null;
5662
5799
  }
5663
5800
  function iso(timestamp) {
@@ -6806,7 +6943,7 @@ var ExportedEntities = class {
6806
6943
  this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
6807
6944
  return;
6808
6945
  }
6809
- const { dropped } = require_dist.pruneDeletedEntities([...this.state.actions.map((e) => ({
6946
+ const { dropped } = require_ha_broker_source.pruneDeletedEntities([...this.state.actions.map((e) => ({
6810
6947
  kind: "action",
6811
6948
  actionId: e.actionId
6812
6949
  })), ...this.state.targets.map((e) => ({
@@ -6878,7 +7015,7 @@ var ExportedEntities = class {
6878
7015
  const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
6879
7016
  if (action !== void 0) return {
6880
7017
  handled: true,
6881
- applied: (await require_dist.runExportedAction(this.ports.api, this.ports.logger, {
7018
+ applied: (await require_ha_broker_source.runExportedAction(this.ports.api, this.ports.logger, {
6882
7019
  exporter: EXPORTER,
6883
7020
  actionId: action.actionId
6884
7021
  })).status === "started"
@@ -6886,7 +7023,7 @@ var ExportedEntities = class {
6886
7023
  const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
6887
7024
  if (target !== void 0) return {
6888
7025
  handled: true,
6889
- applied: (await require_dist.sendToExportedTarget(this.ports.api, this.ports.logger, {
7026
+ applied: (await require_ha_broker_source.sendToExportedTarget(this.ports.api, this.ports.logger, {
6890
7027
  exporter: EXPORTER,
6891
7028
  targetId: target.targetId,
6892
7029
  notification: { body: value }
@@ -6937,7 +7074,7 @@ var SUPPORTED_DEVICE_KINDS = [
6937
7074
  "image",
6938
7075
  "pet-feeder",
6939
7076
  "hub",
6940
- ...require_dist.PLUG_APPLIANCE_TYPES
7077
+ ...require_ha_broker_source.PLUG_APPLIANCE_TYPES
6941
7078
  ];
6942
7079
  //#endregion
6943
7080
  //#region src/ha-export/ha-export.addon.ts
@@ -6977,8 +7114,6 @@ var SUPPORTED_DEVICE_KINDS = [
6977
7114
  * one switch per broker over the single membership store.
6978
7115
  */
6979
7116
  var ADDON_ID = "homeassistant-export";
6980
- /** Where the custom component registers its push view inside HA. */
6981
- var PUSH_PATH = "/api/camstack/push";
6982
7117
  var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
6983
7118
  /** How long `doorbell_pressed` stays on before its OFF edge is published. */
6984
7119
  var DOORBELL_PULSE_MS = 5e3;
@@ -7010,7 +7145,7 @@ var DEFAULT_CONFIG = {
7010
7145
  },
7011
7146
  publicBaseUrl: ""
7012
7147
  };
7013
- var HaExportAddon = class extends require_dist.BaseAddon {
7148
+ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7014
7149
  links = /* @__PURE__ */ new Map();
7015
7150
  /** deviceKey → the device it addresses. Rebuilt on every reconcile. */
7016
7151
  exported = /* @__PURE__ */ new Map();
@@ -7117,7 +7252,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7117
7252
  super({ ...DEFAULT_CONFIG });
7118
7253
  }
7119
7254
  async onInitialize() {
7120
- const secret = this.state("haExportMediaSecret", require_dist.string(), "");
7255
+ const secret = this.state("haExportMediaSecret", require_ha_broker_source.string(), "");
7121
7256
  const existing = await secret.get();
7122
7257
  if (existing.length === 0) {
7123
7258
  const minted = randomSecret();
@@ -7150,7 +7285,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7150
7285
  this.exportedEntities = exportedEntities;
7151
7286
  return [
7152
7287
  {
7153
- capability: require_dist.deviceExportCapability,
7288
+ capability: require_ha_broker_source.deviceExportCapability,
7154
7289
  provider: {
7155
7290
  getStatus: async () => this.buildStatus(),
7156
7291
  listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
@@ -7183,8 +7318,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7183
7318
  }
7184
7319
  },
7185
7320
  {
7186
- capability: require_dist.addonRoutesCapability,
7187
- provider: require_dist.buildAddonRouteProvider(ADDON_ID, [{
7321
+ capability: require_ha_broker_source.addonRoutesCapability,
7322
+ provider: require_ha_broker_source.buildAddonRouteProvider(ADDON_ID, [{
7188
7323
  method: "POST",
7189
7324
  path: "/command",
7190
7325
  access: "authenticated",
@@ -7195,26 +7330,27 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7195
7330
  }])
7196
7331
  },
7197
7332
  {
7198
- capability: require_dist.oauthIntegrationCapability,
7333
+ capability: require_ha_broker_source.oauthIntegrationCapability,
7199
7334
  provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
7200
7335
  }
7201
7336
  ];
7202
7337
  }
7203
7338
  async onConfigChanged() {
7339
+ for (const link of this.links.values()) link.client.resetBackoff();
7204
7340
  await this.reconcile("config-changed");
7205
7341
  this.startTimer();
7206
7342
  }
7207
7343
  wireListeners() {
7208
- this.subscribe({ category: require_dist.EventCategory.DeviceStateChanged }, (event) => {
7344
+ this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceStateChanged }, (event) => {
7209
7345
  this.onSliceChanged(event.data);
7210
7346
  });
7211
- this.subscribe({ category: require_dist.EventCategory.DeviceOnline }, (event) => {
7347
+ this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceOnline }, (event) => {
7212
7348
  this.onOnlineChanged(event.data, true);
7213
7349
  });
7214
- this.subscribe({ category: require_dist.EventCategory.DeviceOffline }, (event) => {
7350
+ this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceOffline }, (event) => {
7215
7351
  this.onOnlineChanged(event.data, false);
7216
7352
  });
7217
- this.subscribe({ category: require_dist.EventCategory.MotionOnMotionChanged }, (event) => {
7353
+ this.subscribe({ category: require_ha_broker_source.EventCategory.MotionOnMotionChanged }, (event) => {
7218
7354
  this.onMotion(event.data);
7219
7355
  });
7220
7356
  /**
@@ -7223,13 +7359,13 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7223
7359
  * the entity existed and effectively never moved. It also carries the
7224
7360
  * window LEVEL, which nothing else on the export path does.
7225
7361
  */
7226
- this.subscribe({ category: require_dist.EventCategory.PipelineAudioInferenceResult }, (event) => {
7362
+ this.subscribe({ category: require_ha_broker_source.EventCategory.PipelineAudioInferenceResult }, (event) => {
7227
7363
  this.onAudioWindow(event.data);
7228
7364
  });
7229
- this.subscribe({ category: require_dist.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
7365
+ this.subscribe({ category: require_ha_broker_source.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
7230
7366
  this.onTrackLifecycle(event.data);
7231
7367
  });
7232
- this.subscribe({ category: require_dist.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
7368
+ this.subscribe({ category: require_ha_broker_source.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
7233
7369
  this.onZoneOccupancy(event.data);
7234
7370
  });
7235
7371
  /**
@@ -7238,11 +7374,11 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7238
7374
  * rather than a state push.
7239
7375
  */
7240
7376
  for (const category of [
7241
- require_dist.EventCategory.DeviceRegistered,
7242
- require_dist.EventCategory.DeviceUnregistered,
7243
- require_dist.EventCategory.DeviceBindingsChanged,
7244
- require_dist.EventCategory.DeviceMetaChanged,
7245
- require_dist.EventCategory.BrokerStatusChanged
7377
+ require_ha_broker_source.EventCategory.DeviceRegistered,
7378
+ require_ha_broker_source.EventCategory.DeviceUnregistered,
7379
+ require_ha_broker_source.EventCategory.DeviceBindingsChanged,
7380
+ require_ha_broker_source.EventCategory.DeviceMetaChanged,
7381
+ require_ha_broker_source.EventCategory.BrokerStatusChanged
7246
7382
  ]) this.subscribe({ category }, () => {
7247
7383
  this.reconcileSoon();
7248
7384
  });
@@ -7883,7 +8019,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
7883
8019
  const delivered = await this.announce(exported, fullResync);
7884
8020
  await this.pushFullState(exported, snapshots);
7885
8021
  if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
7886
- if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
8022
+ const failing = [...this.links.values()].filter((link) => !link.client.healthy && !link.client.suspended);
8023
+ const anyLive = [...this.links.values()].some((link) => !link.client.suspended);
8024
+ if (failing.length > 0 || !delivered && anyLive) {
7887
8025
  this.resync.markGap("push-not-delivered");
7888
8026
  this.lastStructureFingerprint = void 0;
7889
8027
  this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
@@ -8337,7 +8475,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
8337
8475
  */
8338
8476
  capSlices: snapshot,
8339
8477
  itemEntities: items.entities,
8340
- ...require_dist.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
8478
+ ...require_ha_broker_source.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
8341
8479
  },
8342
8480
  heldBack
8343
8481
  };
@@ -8571,7 +8709,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
8571
8709
  maskEnabled: privacySlice["enabled"] === true,
8572
8710
  audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
8573
8711
  };
8574
- const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_dist.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
8712
+ const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_ha_broker_source.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
8575
8713
  this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
8576
8714
  tags: { deviceId },
8577
8715
  meta: {
@@ -8622,7 +8760,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
8622
8760
  * reasoning.
8623
8761
  */
8624
8762
  async refreshBrokers() {
8625
- const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
8763
+ const haBrokers = await require_ha_broker_source.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
8626
8764
  const known = haBrokers.map((broker) => ({
8627
8765
  id: broker.id,
8628
8766
  name: broker.name
@@ -8964,7 +9102,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
8964
9102
  }
8965
9103
  switch (command.kind) {
8966
9104
  case "camera-function": {
8967
- const parsed = require_dist.CameraSwitchIdSchema.safeParse(command.switchId);
9105
+ const parsed = require_ha_broker_source.CameraSwitchIdSchema.safeParse(command.switchId);
8968
9106
  if (!parsed.success) {
8969
9107
  this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
8970
9108
  tags: { deviceId: command.deviceId },
@@ -9225,7 +9363,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
9225
9363
  id: "ha-export-brokers",
9226
9364
  title: "Home Assistant instances",
9227
9365
  titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
9228
- description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on." : "No Home Assistant broker is registered yet.",
9366
+ description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on. Export needs the CamStack custom component installed in Home Assistant (HACS)." : "No Home Assistant broker is registered yet.",
9229
9367
  columns: 1,
9230
9368
  fields: brokerFields.length > 0 ? brokerFields : [{
9231
9369
  type: "info",
@@ -9359,16 +9497,21 @@ var HaExportAddon = class extends require_dist.BaseAddon {
9359
9497
  buildStatus() {
9360
9498
  const enabled = this.enabledBrokerIds();
9361
9499
  const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
9500
+ const linkFailure = [...this.links.values()].map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
9362
9501
  return {
9363
9502
  linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
9364
9503
  exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
9365
- ...this.lastError !== void 0 ? { error: this.lastError } : {},
9504
+ ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
9366
9505
  setup: {
9367
9506
  fields: [
9368
9507
  {
9369
9508
  label: "Home Assistant instances",
9370
9509
  value: `${healthy} / ${this.links.size} reachable`
9371
9510
  },
9511
+ {
9512
+ label: "Last push failure",
9513
+ value: linkFailure ?? "none"
9514
+ },
9372
9515
  {
9373
9516
  label: "Entities pushed",
9374
9517
  value: String(this.entityCount)
@@ -9403,26 +9546,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
9403
9546
  * CAPABILITIES, not its type, and restricting the picker by type is what
9404
9547
  * confined the previous exporter to cameras.
9405
9548
  */
9406
- /**
9407
- * POST one message to Home Assistant's push view.
9408
- *
9409
- * Authenticated with the broker's OWN long-lived token: the component
9410
- * registers its view behind HA auth, so there is no second shared secret
9411
- * to configure or rotate. A non-2xx throws, which is how the push client
9412
- * learns the instance is unreachable.
9413
- */
9414
- async function postToHomeAssistant(baseUrl, token, message) {
9415
- const response = await fetch(`${baseUrl.replace(/\/+$/, "")}${PUSH_PATH}`, {
9416
- method: "POST",
9417
- headers: {
9418
- authorization: `Bearer ${token}`,
9419
- "content-type": "application/json"
9420
- },
9421
- body: JSON.stringify(message),
9422
- signal: AbortSignal.timeout(1e4)
9423
- });
9424
- if (!response.ok) throw new Error(`Home Assistant answered ${response.status}`);
9425
- }
9426
9549
  function randomSecret() {
9427
9550
  const bytes = new Uint8Array(32);
9428
9551
  crypto.getRandomValues(bytes);