@camstack/addon-provider-homeassistant 1.2.658 → 1.2.660

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_ha_instance_settings = require("../ha-instance-settings-CRvQ5rH6.js");
27
+ const require_legacy_integration_rows = require("../legacy-integration-rows-BWVmjfDO.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -91,7 +91,7 @@ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
91
91
  await api.streamBroker.setDeviceAudioMute.mutate({
92
92
  deviceId,
93
93
  muted: write.muted
94
- }, require_ha_instance_settings.nodePin(sourceNodeId));
94
+ }, require_legacy_integration_rows.nodePin(sourceNodeId));
95
95
  return;
96
96
  }
97
97
  }
@@ -725,8 +725,8 @@ function consumableEntity(key, facet) {
725
725
  * rather than to an icon for nothing.
726
726
  */
727
727
  function consumableForm(item) {
728
- const declared = require_ha_instance_settings.ConsumableFormSchema.safeParse(item.form);
729
- return require_ha_instance_settings.consumableFormOf({
728
+ const declared = require_legacy_integration_rows.ConsumableFormSchema.safeParse(item.form);
729
+ return require_legacy_integration_rows.consumableFormOf({
730
730
  ...item.kind !== void 0 ? { kind: item.kind } : {},
731
731
  ...declared.success ? { form: declared.data } : {}
732
732
  });
@@ -739,7 +739,7 @@ function partIcon(key, label) {
739
739
  function formIcon(item, form) {
740
740
  switch (form) {
741
741
  case "part": return partIcon(item.key, item.label);
742
- case "tank": return require_ha_instance_settings.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
742
+ case "tank": return require_legacy_integration_rows.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
743
743
  case "product": return "mdi:shaker-outline";
744
744
  case "bin": return "mdi:delete";
745
745
  }
@@ -750,8 +750,8 @@ function formIcon(item, form) {
750
750
  * form this export does not know degrades one icon instead of refusing the
751
751
  * whole slice.
752
752
  */
753
- var ExportConsumableItemSchema = require_ha_instance_settings.ConsumableItemSchema.extend({ form: require_ha_instance_settings.unknown().optional() });
754
- var ExportConsumablesSchema = require_ha_instance_settings.object({ items: require_ha_instance_settings.array(ExportConsumableItemSchema) });
753
+ var ExportConsumableItemSchema = require_legacy_integration_rows.ConsumableItemSchema.extend({ form: require_legacy_integration_rows.unknown().optional() });
754
+ var ExportConsumablesSchema = require_legacy_integration_rows.object({ items: require_legacy_integration_rows.array(ExportConsumableItemSchema) });
755
755
  /**
756
756
  * A worn part's life is a diagnostic; a tank's fill is what the device page
757
757
  * is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
@@ -764,7 +764,7 @@ function consumableEntities(item) {
764
764
  capName: "consumables",
765
765
  itemKey: item.key
766
766
  };
767
- const kind = require_ha_instance_settings.consumableKindOf(item);
767
+ const kind = require_legacy_integration_rows.consumableKindOf(item);
768
768
  const form = consumableForm(item);
769
769
  const icon = formIcon(item, form);
770
770
  /** What the item IS, on the sensors that describe it (not on a counter or a button). */
@@ -804,7 +804,7 @@ function consumableEntities(item) {
804
804
  platform: "sensor",
805
805
  label: `${item.label} status`,
806
806
  deviceClass: "enum",
807
- options: require_ha_instance_settings.ConsumableStatusSchema.options,
807
+ options: require_legacy_integration_rows.ConsumableStatusSchema.options,
808
808
  icon,
809
809
  attributes,
810
810
  entityCategory: "diagnostic"
@@ -2405,7 +2405,7 @@ var CAP_ENTITY_MAP = {
2405
2405
  * failure. `fanMode` and `preset` get no such list at all: they are
2406
2406
  * free-form strings, so they stay sensors.
2407
2407
  */
2408
- options: require_ha_instance_settings.HvacModeSchema.options,
2408
+ options: require_legacy_integration_rows.HvacModeSchema.options,
2409
2409
  extras: {
2410
2410
  "current-temp": {
2411
2411
  platform: "sensor",
@@ -2555,7 +2555,7 @@ var CAP_ENTITY_MAP = {
2555
2555
  field: "direction",
2556
2556
  label: "Direction",
2557
2557
  writable: true,
2558
- options: require_ha_instance_settings.FanDirectionSchema.options,
2558
+ options: require_legacy_integration_rows.FanDirectionSchema.options,
2559
2559
  enabledByDefault: false
2560
2560
  }
2561
2561
  }
@@ -2985,7 +2985,7 @@ var CAP_ENTITY_MAP = {
2985
2985
  field: "repeat",
2986
2986
  label: "Repeat",
2987
2987
  writable: true,
2988
- options: require_ha_instance_settings.MediaPlayerRepeatSchema.options,
2988
+ options: require_legacy_integration_rows.MediaPlayerRepeatSchema.options,
2989
2989
  enabledByDefault: false
2990
2990
  },
2991
2991
  shuffle: {
@@ -3481,7 +3481,7 @@ function controlEntitySpecs(slice, quantity) {
3481
3481
  const min = readFiniteNumber(slice, "min") ?? PERCENT_RANGE.min;
3482
3482
  const max = readFiniteNumber(slice, "max") ?? PERCENT_RANGE.max;
3483
3483
  const step = readFiniteNumber(slice, "step") ?? PERCENT_RANGE.step;
3484
- const unit = quantity === void 0 ? void 0 : require_ha_instance_settings.baseUnitOf(quantity);
3484
+ const unit = quantity === void 0 ? void 0 : require_legacy_integration_rows.baseUnitOf(quantity);
3485
3485
  if (min < max && step > 0) return [{
3486
3486
  entity: primary,
3487
3487
  platform: "number",
@@ -3803,7 +3803,7 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3803
3803
  continue;
3804
3804
  }
3805
3805
  if (cap === "numeric-sensor") {
3806
- const unit = device.readingQuantity === void 0 ? void 0 : require_ha_instance_settings.baseUnitOf(device.readingQuantity);
3806
+ const unit = device.readingQuantity === void 0 ? void 0 : require_legacy_integration_rows.baseUnitOf(device.readingQuantity);
3807
3807
  specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(unit === void 0 ? spec : {
3808
3808
  ...spec,
3809
3809
  unit
@@ -3878,7 +3878,7 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3878
3878
  * was.
3879
3879
  */
3880
3880
  /** The camera function ids, keyed by the entity slug the catalog emits. */
3881
- var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_ha_instance_settings.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
3881
+ var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_legacy_integration_rows.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
3882
3882
  var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
3883
3883
  function parseBool(value) {
3884
3884
  const lower = value.trim().toLowerCase();
@@ -3941,7 +3941,7 @@ function parseAlarmAction(value) {
3941
3941
  if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
3942
3942
  if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
3943
3943
  const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
3944
- const mode = require_ha_instance_settings.AlarmArmModeSchema.safeParse(withoutPrefix);
3944
+ const mode = require_legacy_integration_rows.AlarmArmModeSchema.safeParse(withoutPrefix);
3945
3945
  return mode.success ? {
3946
3946
  kind: "arm",
3947
3947
  mode: mode.data
@@ -4090,7 +4090,7 @@ var CAP_COMMAND_ROUTES = {
4090
4090
  },
4091
4091
  "climate-control": {
4092
4092
  primary: (deviceId, value) => {
4093
- const mode = parseEnum(require_ha_instance_settings.HvacModeSchema, value);
4093
+ const mode = parseEnum(require_legacy_integration_rows.HvacModeSchema, value);
4094
4094
  return mode === null ? null : {
4095
4095
  kind: "climate-mode",
4096
4096
  deviceId,
@@ -4205,7 +4205,7 @@ var CAP_COMMAND_ROUTES = {
4205
4205
  };
4206
4206
  },
4207
4207
  direction: (deviceId, value) => {
4208
- const direction = parseEnum(require_ha_instance_settings.FanDirectionSchema, value);
4208
+ const direction = parseEnum(require_legacy_integration_rows.FanDirectionSchema, value);
4209
4209
  return direction === null ? null : {
4210
4210
  kind: "fan-direction",
4211
4211
  deviceId,
@@ -4409,7 +4409,7 @@ var CAP_COMMAND_ROUTES = {
4409
4409
  };
4410
4410
  },
4411
4411
  repeat: (deviceId, value) => {
4412
- const repeat = parseEnum(require_ha_instance_settings.MediaPlayerRepeatSchema, value);
4412
+ const repeat = parseEnum(require_legacy_integration_rows.MediaPlayerRepeatSchema, value);
4413
4413
  return repeat === null ? null : {
4414
4414
  kind: "media-repeat",
4415
4415
  deviceId,
@@ -5001,7 +5001,7 @@ async function planDropPublicBaseUrl(ports) {
5001
5001
  op: "deleteKeys",
5002
5002
  scope: ADDON_SETTINGS_TABLE,
5003
5003
  keys: present.map(rowId),
5004
- precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5004
+ precondition: require_legacy_integration_rows.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5005
5005
  addonId: HA_EXPORT_ADDON_ID,
5006
5006
  key,
5007
5007
  value: stored[key]
@@ -5231,7 +5231,7 @@ async function planDropPerBrokerFlags(ports) {
5231
5231
  op: "deleteKeys",
5232
5232
  scope: ADDON_SETTINGS_TABLE,
5233
5233
  keys: drop.map((entry) => rowId(entry.storeKey)),
5234
- precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(drop.map((entry) => [rowId(entry.storeKey), {
5234
+ precondition: require_legacy_integration_rows.preconditionOf(Object.fromEntries(drop.map((entry) => [rowId(entry.storeKey), {
5235
5235
  addonId: HA_EXPORT_ADDON_ID,
5236
5236
  key: entry.storeKey,
5237
5237
  value: entry.value
@@ -5239,6 +5239,246 @@ async function planDropPerBrokerFlags(ports) {
5239
5239
  }] };
5240
5240
  }
5241
5241
  //#endregion
5242
+ //#region src/ha-export/target-records.ts
5243
+ /**
5244
+ * An export target (#422): one `ha-export-targets` record per place the export reaches. It names
5245
+ * the Home Assistant connection it pushes to (`connectionId`, a broker id), which hub address
5246
+ * role its image links use, how often it re-announces, and the devices exported to it.
5247
+ *
5248
+ * The record is the integration instance itself (`integration-instances`, role `services`): the
5249
+ * operator creates, edits, switches and deletes targets from the Connectivity page, and the ids
5250
+ * are minted here (`hae_001`, ...). Several targets may reference one connection.
5251
+ *
5252
+ * This addon is the only writer of `devices`; the instance door writes the rest. Both go through
5253
+ * `patch` (a merge), so neither overwrites the other, and the in-memory copy here is the
5254
+ * synchronous read the event path needs, reloaded after every change.
5255
+ */
5256
+ var HA_EXPORT_TARGETS_KIND_NAME = "ha-export-targets";
5257
+ var HA_TARGET_KIND = "ha-target";
5258
+ var TARGET_ID_PREFIX = "hae_";
5259
+ /** The schema is checked against the interface by its annotation: a drifted field fails the typecheck. */
5260
+ var HaExportTargetBodySchema = require_legacy_integration_rows.object({
5261
+ name: require_legacy_integration_rows.string().min(1),
5262
+ connectionId: require_legacy_integration_rows.string(),
5263
+ mediaRole: require_legacy_integration_rows._enum(HA_MEDIA_ROLES),
5264
+ reconcileIntervalSec: require_legacy_integration_rows.number(),
5265
+ devices: require_legacy_integration_rows.array(require_legacy_integration_rows.string()),
5266
+ enabled: require_legacy_integration_rows.boolean().optional()
5267
+ });
5268
+ var haExportTargetsKind = require_legacy_integration_rows.defineEntryKind(HA_EXPORT_TARGETS_KIND_NAME, {
5269
+ title: "Home Assistant export targets",
5270
+ nameField: "name",
5271
+ schema: HaExportTargetBodySchema,
5272
+ writeTrigger: "an operator adds, edits, switches or deletes an export target, or exports a device to it"
5273
+ });
5274
+ /** What a target starts as: on, public endpoint, five-minute cadence, no device, no connection yet. */
5275
+ function newTargetBody(name) {
5276
+ return {
5277
+ name,
5278
+ connectionId: "",
5279
+ mediaRole: "publicEndpoint",
5280
+ reconcileIntervalSec: 300,
5281
+ devices: [],
5282
+ enabled: true
5283
+ };
5284
+ }
5285
+ /** The id after the highest `hae_NNN` among `existing`. */
5286
+ function nextTargetId(existing) {
5287
+ let highest = 0;
5288
+ for (const id of existing) {
5289
+ if (!id.startsWith("hae_")) continue;
5290
+ const n = Number(id.slice(4));
5291
+ if (Number.isInteger(n) && n > highest) highest = n;
5292
+ }
5293
+ return `${TARGET_ID_PREFIX}${String(highest + 1).padStart(3, "0")}`;
5294
+ }
5295
+ /** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
5296
+ async function readAllTargets(handle) {
5297
+ const rows = /* @__PURE__ */ new Map();
5298
+ let cursor;
5299
+ do {
5300
+ const page = await handle.query({
5301
+ limit: 1e3,
5302
+ ...cursor === void 0 ? {} : { cursor }
5303
+ });
5304
+ for (const row of page.records) rows.set(row.id, row.data);
5305
+ cursor = page.nextCursor ?? void 0;
5306
+ } while (cursor !== void 0);
5307
+ return rows;
5308
+ }
5309
+ function membershipOf$1(rows) {
5310
+ const out = {};
5311
+ for (const [id, row] of rows) if (row.devices.length > 0) out[id] = row.devices;
5312
+ return out;
5313
+ }
5314
+ function sameList(a, b) {
5315
+ return a.length === b.length && a.every((value, index) => value === b[index]);
5316
+ }
5317
+ /**
5318
+ * The in-memory copy of the target records. `membership()` keeps its identity until a device
5319
+ * list changes.
5320
+ */
5321
+ var ExportTargets = class ExportTargets {
5322
+ handle;
5323
+ rows;
5324
+ memberships;
5325
+ constructor(handle, rows) {
5326
+ this.handle = handle;
5327
+ this.rows = rows;
5328
+ this.memberships = membershipOf$1(rows);
5329
+ }
5330
+ /** A failed read throws: serving an empty set would unexport every device on the next pass. */
5331
+ static async load(handle) {
5332
+ return new ExportTargets(handle, await readAllTargets(handle));
5333
+ }
5334
+ /** Re-read the records after the instance door changed one; a failed read keeps the old copy. */
5335
+ async reload() {
5336
+ this.adopt(await readAllTargets(this.handle));
5337
+ }
5338
+ ids() {
5339
+ return [...this.rows.keys()];
5340
+ }
5341
+ has(id) {
5342
+ return this.rows.has(id);
5343
+ }
5344
+ get(id) {
5345
+ return this.rows.get(id);
5346
+ }
5347
+ name(id) {
5348
+ return this.rows.get(id)?.name ?? id;
5349
+ }
5350
+ /** The target's own switch; an absent field means on. */
5351
+ isEnabled(id) {
5352
+ const row = this.rows.get(id);
5353
+ return row !== void 0 && row.enabled !== false;
5354
+ }
5355
+ connectionId(id) {
5356
+ return this.rows.get(id)?.connectionId ?? "";
5357
+ }
5358
+ mediaRole(id) {
5359
+ return this.rows.get(id)?.mediaRole ?? "publicEndpoint";
5360
+ }
5361
+ /** The cadence of the quickest enabled target (the addon runs one timer); the default when none. */
5362
+ reconcileIntervalSec() {
5363
+ const seconds = [...this.rows.values()].filter((row) => row.enabled !== false).map((row) => row.reconcileIntervalSec);
5364
+ if (seconds.length === 0) return 300;
5365
+ return Math.max(30, Math.min(...seconds));
5366
+ }
5367
+ /** targetId → exported device ids, for the targets that export at least one. */
5368
+ membership() {
5369
+ return this.memberships;
5370
+ }
5371
+ /**
5372
+ * Persist the device lists that differ from the held ones. A target that does not exist is
5373
+ * never created here (an id the records do not hold is refused by the caller).
5374
+ */
5375
+ async setMembership(next) {
5376
+ let rows = this.rows;
5377
+ for (const id of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
5378
+ const current = rows.get(id);
5379
+ if (current === void 0) continue;
5380
+ const devices = next[id] ?? [];
5381
+ if (sameList(current.devices, devices)) continue;
5382
+ await this.handle.patch(id, { devices: [...devices] });
5383
+ rows = new Map(rows).set(id, {
5384
+ ...current,
5385
+ devices: [...devices]
5386
+ });
5387
+ this.adopt(rows);
5388
+ }
5389
+ }
5390
+ adopt(rows) {
5391
+ const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
5392
+ this.rows = rows;
5393
+ if (devicesChanged) this.memberships = membershipOf$1(rows);
5394
+ }
5395
+ };
5396
+ //#endregion
5397
+ //#region src/ha-export/migrations/targets-from-links.ts
5398
+ var RECONCILE_INTERVAL_KEY = "reconcileIntervalSec";
5399
+ var KNOWN_BROKERS_KEY = "knownBrokers";
5400
+ var sameDevices$1 = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
5401
+ function intervalOf(stored) {
5402
+ const value = stored[RECONCILE_INTERVAL_KEY];
5403
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 300;
5404
+ }
5405
+ function matches(target, expected) {
5406
+ return target.name === expected.name && target.connectionId === expected.connectionId && target.mediaRole === expected.mediaRole && target.reconcileIntervalSec === expected.reconcileIntervalSec && target.enabled === expected.enabled && sameDevices$1(target.devices, expected.devices);
5407
+ }
5408
+ async function moveLinksToTargets(ports) {
5409
+ const links = await ports.readLinks();
5410
+ if (links.size === 0) return {
5411
+ outcome: "already-done",
5412
+ summary: { moved: 0 }
5413
+ };
5414
+ const targets = await ports.readTargets();
5415
+ const reconcileIntervalSec = intervalOf(await ports.readStored());
5416
+ const taken = new Set([...targets.values()].map((target) => target.connectionId));
5417
+ const ids = new Set(targets.keys());
5418
+ const created = /* @__PURE__ */ new Map();
5419
+ for (const brokerId of [...links.keys()].sort()) {
5420
+ if (taken.has(brokerId)) continue;
5421
+ const link = links.get(brokerId);
5422
+ if (link === void 0) continue;
5423
+ const body = {
5424
+ name: link.name,
5425
+ connectionId: brokerId,
5426
+ mediaRole: link.mediaRole,
5427
+ reconcileIntervalSec,
5428
+ devices: [...link.devices],
5429
+ enabled: link.exportTo
5430
+ };
5431
+ const id = nextTargetId(ids);
5432
+ if (await ports.putTarget(id, body) === "exists") throw new Error(`the export target id ${id} is already taken; nothing was cleared`);
5433
+ ids.add(id);
5434
+ created.set(id, body);
5435
+ }
5436
+ if (created.size === 0) return {
5437
+ outcome: "already-done",
5438
+ summary: { moved: 0 }
5439
+ };
5440
+ const held = await ports.readTargets();
5441
+ for (const [id, expected] of created) {
5442
+ const row = held.get(id);
5443
+ if (row === void 0 || !matches(row, expected)) throw new Error(`the export target ${id} of connection ${expected.connectionId} does not match its link record; nothing was cleared`);
5444
+ }
5445
+ return {
5446
+ outcome: "done",
5447
+ summary: {
5448
+ moved: created.size,
5449
+ devices: [...created.values()].reduce((sum, target) => sum + target.devices.length, 0)
5450
+ }
5451
+ };
5452
+ }
5453
+ async function dropLinksAndCache(ports) {
5454
+ const links = await ports.readLinks();
5455
+ const stored = await ports.readStored();
5456
+ const targets = await ports.readTargets();
5457
+ const movable = [];
5458
+ const parked = [];
5459
+ for (const [brokerId, link] of links) {
5460
+ const target = [...targets.values()].find((candidate) => candidate.connectionId === brokerId);
5461
+ if (target !== void 0 && sameDevices$1(target.devices, link.devices)) movable.push(brokerId);
5462
+ else parked.push(brokerId);
5463
+ }
5464
+ if (parked.length > 0) ports.warn(`homeassistant-export: the link records of ${parked.join(", ")} are kept: no export target holds the same devices`);
5465
+ if (movable.length > 0) await ports.deleteLinks(movable);
5466
+ const keys = parked.length > 0 ? [] : [RECONCILE_INTERVAL_KEY, KNOWN_BROKERS_KEY].filter((key) => key in stored);
5467
+ if (keys.length > 0) await ports.clearSettingKeys(keys);
5468
+ if (movable.length === 0 && keys.length === 0) return {
5469
+ outcome: "already-done",
5470
+ summary: { parked: parked.length }
5471
+ };
5472
+ return {
5473
+ outcome: "done",
5474
+ summary: {
5475
+ deleted: movable.length,
5476
+ parked: parked.length,
5477
+ keys: keys.length
5478
+ }
5479
+ };
5480
+ }
5481
+ //#endregion
5242
5482
  //#region src/ha-export/migrations/links-to-record.ts
5243
5483
  /**
5244
5484
  * What the export carries to a Home Assistant instance moves into the instance's
@@ -5263,7 +5503,7 @@ var MEMBERSHIP_KEY = "membership";
5263
5503
  var EXPORT_PREFIX = "exportTo:";
5264
5504
  var ROLE_PREFIX = "mediaRole:";
5265
5505
  var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
5266
- function membershipOf$1(value) {
5506
+ function membershipOf(value) {
5267
5507
  const out = /* @__PURE__ */ new Map();
5268
5508
  if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
5269
5509
  for (const [brokerId, devices] of Object.entries(value)) {
@@ -5286,7 +5526,7 @@ function integrationByBroker(rows, warn) {
5286
5526
  }
5287
5527
  /** Every broker the old shape has something for, with the value the old reader would have used. */
5288
5528
  function legacyLinks(stored, rows, warn) {
5289
- const membership = membershipOf$1(stored[MEMBERSHIP_KEY]);
5529
+ const membership = membershipOf(stored[MEMBERSHIP_KEY]);
5290
5530
  const unreadable = rows.find((row) => Object.keys(row.settings).length === 0);
5291
5531
  if (unreadable !== void 0 && membership.size > 0) throw new Error(`the settings of integration ${unreadable.integrationId} read as empty; the export switch cannot be carried from an unreadable source, nothing was written`);
5292
5532
  const integrations = integrationByBroker(rows, warn);
@@ -5364,7 +5604,7 @@ async function planDropMovedLinks(ports) {
5364
5604
  op: "deleteKeys",
5365
5605
  scope: ADDON_SETTINGS_TABLE,
5366
5606
  keys: present.map(rowId),
5367
- precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5607
+ precondition: require_legacy_integration_rows.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5368
5608
  addonId: HA_EXPORT_ADDON_ID,
5369
5609
  key,
5370
5610
  value: stored[key]
@@ -5374,6 +5614,8 @@ async function planDropMovedLinks(ports) {
5374
5614
  //#endregion
5375
5615
  //#region src/ha-export/migrations/index.ts
5376
5616
  var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
5617
+ var DROP_MOVED_LINKS_ID = "homeassistant-export/2026-11-04-drop-moved-links";
5618
+ var TARGETS_FROM_LINKS_ID = "homeassistant-export/2026-11-04-targets-from-links";
5377
5619
  /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5378
5620
  var ADDON_MIGRATIONS = [
5379
5621
  {
@@ -5442,7 +5684,7 @@ var ADDON_MIGRATIONS = [
5442
5684
  },
5443
5685
  {
5444
5686
  meta: {
5445
- id: "homeassistant-export/2026-11-04-drop-moved-links",
5687
+ id: DROP_MOVED_LINKS_ID,
5446
5688
  owner: "homeassistant-export",
5447
5689
  level: "addon-state",
5448
5690
  seq: 5,
@@ -5455,41 +5697,65 @@ var ADDON_MIGRATIONS = [
5455
5697
  fixtureMark: "the reconcile interval the migration does not know"
5456
5698
  },
5457
5699
  plan: planDropMovedLinks
5700
+ },
5701
+ {
5702
+ meta: {
5703
+ id: TARGETS_FROM_LINKS_ID,
5704
+ owner: "homeassistant-export",
5705
+ level: "addon-state",
5706
+ seq: 6,
5707
+ dependsOn: [LINKS_TO_RECORD_ID],
5708
+ store: "settings",
5709
+ scope: "cluster",
5710
+ onFailure: "block-owner",
5711
+ bodyKind: "procedure",
5712
+ description: "Turn each ha-export-links record into an export target under a minted id that names its Home Assistant connection, carrying the switch, media role, devices and reconcile interval; read back before anything is deleted",
5713
+ fixtureMark: "the exported entities the migration does not touch"
5714
+ },
5715
+ procedure: moveLinksToTargets
5716
+ },
5717
+ {
5718
+ meta: {
5719
+ id: "homeassistant-export/2026-11-04-drop-links-and-cache",
5720
+ owner: "homeassistant-export",
5721
+ level: "addon-state",
5722
+ seq: 7,
5723
+ dependsOn: [TARGETS_FROM_LINKS_ID],
5724
+ store: "settings",
5725
+ scope: "cluster",
5726
+ onFailure: "block-owner",
5727
+ bodyKind: "procedure",
5728
+ description: "Delete each ha-export-links record whose target holds the same devices, then the reconcileIntervalSec and knownBrokers settings",
5729
+ fixtureMark: "the exported entities the migration does not touch"
5730
+ },
5731
+ procedure: dropLinksAndCache
5458
5732
  }
5459
5733
  ];
5460
5734
  //#endregion
5461
- //#region src/ha-export/link-records.ts
5735
+ //#region src/ha-export/migrations/legacy-links-kind.ts
5462
5736
  /**
5463
- * What the export carries to one Home Assistant instance (planning#408 P4): an `ha-export-links`
5464
- * record per instance, under the id of the Home Assistant broker it belongs to. It holds the
5465
- * three facts of the link: whether the export reaches the instance, which hub address role its
5466
- * image links use, and the devices exported to it.
5467
- *
5468
- * It replaces the `membership` setting, the `exportTo:<brokerId>` / `mediaRole:<brokerId>` keys
5469
- * and the `exportTo` / `mediaRole` values of the instance's integration. The migration moved
5470
- * them; the addon reads this record and nothing else.
5737
+ * The `ha-export-links` record kind (planning#408 P4): one record per Home Assistant broker with
5738
+ * its export switch, media role and devices. Export targets (`ha-export-targets`) replaced it;
5739
+ * the kind is declared ONLY so the migrations can read it and delete its rows. No runtime code
5740
+ * outside `migrations/` names it (`no-legacy-links-reader.spec.ts`).
5471
5741
  */
5472
5742
  var HA_EXPORT_LINKS_KIND_NAME = "ha-export-links";
5473
- var HaExportLinksBodySchema = require_ha_instance_settings.object({
5474
- name: require_ha_instance_settings.string().min(1),
5475
- exportTo: require_ha_instance_settings.boolean(),
5476
- mediaRole: require_ha_instance_settings._enum(HA_MEDIA_ROLES),
5477
- devices: require_ha_instance_settings.array(require_ha_instance_settings.string())
5743
+ var HaExportLinksBodySchema = require_legacy_integration_rows.object({
5744
+ name: require_legacy_integration_rows.string().min(1),
5745
+ exportTo: require_legacy_integration_rows.boolean(),
5746
+ mediaRole: require_legacy_integration_rows._enum(HA_MEDIA_ROLES),
5747
+ devices: require_legacy_integration_rows.array(require_legacy_integration_rows.string())
5478
5748
  });
5479
- var haExportLinksKind = require_ha_instance_settings.defineEntryKind(HA_EXPORT_LINKS_KIND_NAME, {
5480
- title: "Home Assistant export links",
5749
+ var haExportLinksKind = require_legacy_integration_rows.defineEntryKind(HA_EXPORT_LINKS_KIND_NAME, {
5750
+ title: "Home Assistant export links (retired)",
5481
5751
  nameField: "name",
5482
5752
  schema: HaExportLinksBodySchema,
5483
- writeTrigger: "an operator switches the export of a Home Assistant instance, picks its image-link address, or exports a device to it"
5753
+ writeTrigger: "only the migrations that move the links into export targets write it"
5484
5754
  });
5485
- /** A link the operator has not touched: off, on the public endpoint, no device. */
5486
- function defaultLinks(name) {
5487
- return {
5488
- name,
5489
- exportTo: false,
5490
- mediaRole: "publicEndpoint",
5491
- devices: []
5492
- };
5755
+ /** Declare the retired kind and hand back its handle, for the migration ports. */
5756
+ async function declareLegacyLinks(records) {
5757
+ await records.declare({ [HA_EXPORT_LINKS_KIND_NAME]: haExportLinksKind });
5758
+ return records.kind(haExportLinksKind);
5493
5759
  }
5494
5760
  /** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
5495
5761
  async function readAllLinks(handle) {
@@ -5505,128 +5771,17 @@ async function readAllLinks(handle) {
5505
5771
  } while (cursor !== void 0);
5506
5772
  return rows;
5507
5773
  }
5508
- function membershipOf(rows) {
5509
- const out = {};
5510
- for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
5511
- return out;
5512
- }
5513
- /**
5514
- * The in-memory copy of the records. This addon is their only writer, so the copy is the
5515
- * synchronous read the event path needs; a write lands in the record first and in the copy
5516
- * after it. `membership()` keeps its identity until a device list changes.
5517
- */
5518
- var ExportLinks = class ExportLinks {
5519
- handle;
5520
- rows;
5521
- memberships;
5522
- constructor(handle, rows) {
5523
- this.handle = handle;
5524
- this.rows = rows;
5525
- this.memberships = membershipOf(rows);
5526
- }
5527
- static async load(handle) {
5528
- return new ExportLinks(handle, await readAllLinks(handle));
5529
- }
5530
- isEnabled(brokerId) {
5531
- return this.rows.get(brokerId)?.exportTo === true;
5532
- }
5533
- mediaRole(brokerId) {
5534
- return this.rows.get(brokerId)?.mediaRole ?? "publicEndpoint";
5535
- }
5536
- /** brokerId → exported device ids, for the brokers that export at least one. */
5537
- membership() {
5538
- return this.memberships;
5539
- }
5540
- brokerIds() {
5541
- return [...this.rows.keys()];
5542
- }
5543
- /** Change the switch and / or the role of one instance; creates the record on first use. */
5544
- async setFlags(brokerId, name, patch) {
5545
- const current = this.rows.get(brokerId) ?? defaultLinks(name);
5546
- const next = {
5547
- ...current,
5548
- exportTo: patch.exportTo ?? current.exportTo,
5549
- mediaRole: patch.mediaRole ?? current.mediaRole
5550
- };
5551
- if (this.rows.has(brokerId) && sameLinks(current, next)) return;
5552
- await this.write(brokerId, next);
5553
- }
5554
- /** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
5555
- async setMembership(next, nameOf) {
5556
- for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
5557
- const devices = next[brokerId] ?? [];
5558
- const current = this.rows.get(brokerId);
5559
- if (sameList(current?.devices ?? [], devices)) continue;
5560
- await this.write(brokerId, {
5561
- ...current ?? defaultLinks(nameOf(brokerId)),
5562
- devices: [...devices]
5563
- });
5564
- }
5565
- }
5566
- /** Forget the instances that no longer exist; returns the ids it removed. */
5567
- async prune(knownBrokerIds) {
5568
- const known = new Set(knownBrokerIds);
5569
- const gone = [...this.rows.keys()].filter((id) => !known.has(id));
5570
- for (const id of gone) await this.handle.delete(id);
5571
- if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
5572
- return gone;
5573
- }
5574
- async write(brokerId, body) {
5575
- await this.handle.put({
5576
- id: brokerId,
5577
- data: body
5578
- });
5579
- this.adopt(new Map(this.rows).set(brokerId, body));
5580
- }
5581
- adopt(rows) {
5582
- const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
5583
- this.rows = rows;
5584
- if (devicesChanged) this.memberships = membershipOf(rows);
5585
- }
5586
- };
5587
- function sameList(a, b) {
5588
- return a.length === b.length && a.every((value, index) => value === b[index]);
5589
- }
5590
- function sameLinks(a, b) {
5591
- return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
5592
- }
5593
- /**
5594
- * The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
5595
- * `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
5596
- * records and a write is routed to the record of the broker they name.
5597
- */
5598
- var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
5599
- var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
5600
- function parseLinkFormKey(key) {
5601
- for (const flag of ["exportTo", "mediaRole"]) {
5602
- const prefix = `${flag}:`;
5603
- if (key.startsWith(prefix) && key.length > prefix.length) return {
5604
- flag,
5605
- brokerId: key.slice(prefix.length)
5606
- };
5607
- }
5608
- return null;
5609
- }
5610
- /** The form's dynamic keys carrying each instance's own value. */
5611
- function linkFormOverlay(links) {
5612
- const overlay = {};
5613
- for (const brokerId of links.brokerIds()) {
5614
- overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
5615
- overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
5616
- }
5617
- return overlay;
5618
- }
5619
5774
  //#endregion
5620
5775
  //#region src/ha-export/migrations/bind-ports.ts
5621
5776
  /**
5622
5777
  * Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
5623
- * `integrations` capability (the old home of the two flags, read by the migrations only) and the
5624
- * `ha-export-links` kind.
5778
+ * `integrations` capability (the old home of the two flags, read by the migrations only), the
5779
+ * retired `ha-export-links` kind and the `ha-export-targets` kind the links move into.
5625
5780
  */
5626
5781
  function haExportMigrationPorts(deps) {
5627
5782
  return {
5628
5783
  readStored: deps.readStored,
5629
- ...require_ha_instance_settings.haInstanceSettingsPortsOver(deps.api.integrations),
5784
+ ...require_legacy_integration_rows.haInstanceSettingsPortsOver(deps.api.integrations),
5630
5785
  warn: deps.warn,
5631
5786
  putLinks: async (brokerId, body) => {
5632
5787
  if (await deps.links.get(brokerId) !== null) return "exists";
@@ -5636,15 +5791,123 @@ function haExportMigrationPorts(deps) {
5636
5791
  data: body
5637
5792
  }, { ifRev: 0 });
5638
5793
  } catch (err) {
5639
- if (err instanceof require_ha_instance_settings.RevConflictError) return "exists";
5794
+ if (err instanceof require_legacy_integration_rows.RevConflictError) return "exists";
5795
+ throw err;
5796
+ }
5797
+ return "created";
5798
+ },
5799
+ readLinks: () => readAllLinks(deps.links),
5800
+ readTargets: () => readAllTargets(deps.targets),
5801
+ putTarget: async (id, body) => {
5802
+ try {
5803
+ await deps.targets.put({
5804
+ id,
5805
+ data: body
5806
+ }, { ifRev: 0 });
5807
+ } catch (err) {
5808
+ if (err instanceof require_legacy_integration_rows.RevConflictError) return "exists";
5640
5809
  throw err;
5641
5810
  }
5642
5811
  return "created";
5643
5812
  },
5644
- readLinks: () => readAllLinks(deps.links)
5813
+ deleteLinks: async (brokerIds) => {
5814
+ await deps.links.deleteMany(brokerIds);
5815
+ },
5816
+ clearSettingKeys: deps.clearSettingKeys
5645
5817
  };
5646
5818
  }
5647
5819
  //#endregion
5820
+ //#region src/ha-export/target-form.ts
5821
+ var NS = "provider-homeassistant:globalSettings";
5822
+ function buildTargetForm() {
5823
+ return { sections: [{
5824
+ id: "ha-export-target",
5825
+ title: "Home Assistant target",
5826
+ titleKey: `${NS}.s_ha-export-target.title`,
5827
+ description: "Where the export pushes devices. A target with no existing connection pushes nothing and keeps its devices. Export needs the CamStack custom component installed in Home Assistant (HACS).",
5828
+ descriptionKey: `${NS}.s_ha-export-target.description`,
5829
+ columns: 1,
5830
+ fields: [
5831
+ {
5832
+ type: "text",
5833
+ key: "name",
5834
+ label: "Name",
5835
+ labelKey: `${NS}.s_ha-export-target.name.label`
5836
+ },
5837
+ {
5838
+ type: "broker-select",
5839
+ key: "connectionId",
5840
+ label: "Home Assistant connection",
5841
+ labelKey: `${NS}.s_ha-export-target.connectionId.label`,
5842
+ description: "The Home Assistant instance this target pushes to.",
5843
+ descriptionKey: `${NS}.s_ha-export-target.connectionId.description`,
5844
+ brokerKind: "home-assistant"
5845
+ },
5846
+ {
5847
+ type: "select",
5848
+ key: "mediaRole",
5849
+ label: "Image links",
5850
+ labelKey: `${NS}.s_ha-export-target.mediaRole.label`,
5851
+ description: "The hub address this Home Assistant fetches image links from. Public endpoint is the default, so notifications show images away from home; when none is set the internal address is used.",
5852
+ descriptionKey: `${NS}.s_ha-export-target.mediaRole.description`,
5853
+ options: [{
5854
+ value: "internal",
5855
+ label: "Internal (LAN address)"
5856
+ }, {
5857
+ value: "publicEndpoint",
5858
+ label: "Public endpoint (reachable from outside)"
5859
+ }],
5860
+ default: "publicEndpoint"
5861
+ }
5862
+ ]
5863
+ }, {
5864
+ id: "ha-export-advanced",
5865
+ title: "Advanced",
5866
+ titleKey: `${NS}.s_ha-export-advanced.title`,
5867
+ style: "accordion",
5868
+ defaultCollapsed: true,
5869
+ columns: 1,
5870
+ fields: [{
5871
+ type: "number",
5872
+ key: "reconcileIntervalSec",
5873
+ label: "Reconcile interval (s)",
5874
+ labelKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.label`,
5875
+ description: `Full re-announce and re-push cadence; at least 30 s.`,
5876
+ descriptionKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.description`,
5877
+ default: 300
5878
+ }]
5879
+ }] };
5880
+ }
5881
+ //#endregion
5882
+ //#region src/ha-export/target-instances.ts
5883
+ /**
5884
+ * The export targets as `integration-instances` (role `services`, D996): the operator adds,
5885
+ * edits, switches and deletes targets from the Connectivity page. A target's id is MINTED
5886
+ * (`hae_001`, ...) and names its Home Assistant connection as a reference, so several targets
5887
+ * may push to one Home Assistant.
5888
+ */
5889
+ function openTargetInstances(records, deps) {
5890
+ return new require_legacy_integration_rows.RecordIntegrationInstances(records, haExportTargetsKind, {
5891
+ description: {
5892
+ role: "services",
5893
+ instanceMode: "multiple",
5894
+ kind: "target",
5895
+ canTest: false,
5896
+ kinds: [{
5897
+ id: HA_TARGET_KIND,
5898
+ label: "Home Assistant"
5899
+ }]
5900
+ },
5901
+ formSchema: () => buildTargetForm(),
5902
+ initialBody: (name) => newTargetBody(name),
5903
+ mintId: () => nextTargetId(deps.book().ids()),
5904
+ kindOf: () => HA_TARGET_KIND,
5905
+ health: (body, id) => ({ status: deps.health(id, body) }),
5906
+ configured: (body) => deps.connectionExists(body.connectionId),
5907
+ onChanged: deps.onChanged
5908
+ });
5909
+ }
5910
+ //#endregion
5648
5911
  //#region src/ha-export/membership.ts
5649
5912
  function devicesForBroker(membership, brokerId) {
5650
5913
  return membership[brokerId] ?? [];
@@ -5671,14 +5934,6 @@ function setExposed(membership, brokerId, deviceId, exposed) {
5671
5934
  [brokerId]: next
5672
5935
  };
5673
5936
  }
5674
- /** Same brokers, same order, same names — nothing for the form to re-render. */
5675
- function sameBrokers(a, b) {
5676
- if (a.length !== b.length) return false;
5677
- return a.every((broker, index) => {
5678
- const other = b[index];
5679
- return other !== void 0 && other.id === broker.id && other.name === broker.name;
5680
- });
5681
- }
5682
5937
  //#endregion
5683
5938
  //#region src/ha-export/node-reads.ts
5684
5939
  var UNREAD = {
@@ -6391,7 +6646,7 @@ function reduceLastTriggers(rows) {
6391
6646
  function macroOf(className) {
6392
6647
  const lower = className.toLowerCase();
6393
6648
  if (EXPORTED_MACROS.includes(lower)) return lower;
6394
- const mapped = require_ha_instance_settings.COCO_TO_MACRO.mapping[lower];
6649
+ const mapped = require_legacy_integration_rows.COCO_TO_MACRO.mapping[lower];
6395
6650
  return typeof mapped === "string" ? mapped : null;
6396
6651
  }
6397
6652
  function iso(timestamp) {
@@ -7540,7 +7795,7 @@ var ExportedEntities = class {
7540
7795
  this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
7541
7796
  return;
7542
7797
  }
7543
- const { dropped } = require_ha_instance_settings.pruneDeletedEntities([...this.state.actions.map((e) => ({
7798
+ const { dropped } = require_legacy_integration_rows.pruneDeletedEntities([...this.state.actions.map((e) => ({
7544
7799
  kind: "action",
7545
7800
  actionId: e.actionId
7546
7801
  })), ...this.state.targets.map((e) => ({
@@ -7612,7 +7867,7 @@ var ExportedEntities = class {
7612
7867
  const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
7613
7868
  if (action !== void 0) return {
7614
7869
  handled: true,
7615
- applied: (await require_ha_instance_settings.runExportedAction(this.ports.api, this.ports.logger, {
7870
+ applied: (await require_legacy_integration_rows.runExportedAction(this.ports.api, this.ports.logger, {
7616
7871
  exporter: EXPORTER,
7617
7872
  actionId: action.actionId
7618
7873
  })).status === "started"
@@ -7620,7 +7875,7 @@ var ExportedEntities = class {
7620
7875
  const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
7621
7876
  if (target !== void 0) return {
7622
7877
  handled: true,
7623
- applied: (await require_ha_instance_settings.sendToExportedTarget(this.ports.api, this.ports.logger, {
7878
+ applied: (await require_legacy_integration_rows.sendToExportedTarget(this.ports.api, this.ports.logger, {
7624
7879
  exporter: EXPORTER,
7625
7880
  targetId: target.targetId,
7626
7881
  notification: { body: value }
@@ -7671,7 +7926,7 @@ var SUPPORTED_DEVICE_KINDS = [
7671
7926
  "image",
7672
7927
  "pet-feeder",
7673
7928
  "hub",
7674
- ...require_ha_instance_settings.PLUG_APPLIANCE_TYPES
7929
+ ...require_legacy_integration_rows.PLUG_APPLIANCE_TYPES
7675
7930
  ];
7676
7931
  //#endregion
7677
7932
  //#region src/ha-export/ha-export.addon.ts
@@ -7732,16 +7987,15 @@ var PTZ_MOVE_BY_DIRECTION = {
7732
7987
  zoom_in: { zoom: .1 },
7733
7988
  zoom_out: { zoom: -.1 }
7734
7989
  };
7735
- var DEFAULT_CONFIG = {
7736
- reconcileIntervalSec: 300,
7737
- knownBrokers: [],
7738
- exportedEntities: {
7739
- actions: [],
7740
- targets: []
7741
- }
7742
- };
7743
- var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7990
+ var DEFAULT_CONFIG = { exportedEntities: {
7991
+ actions: [],
7992
+ targets: []
7993
+ } };
7994
+ var HaExportAddon = class extends require_legacy_integration_rows.BaseAddon {
7744
7995
  links = /* @__PURE__ */ new Map();
7996
+ instances = null;
7997
+ /** The Home Assistant connections (broker ids) the last `broker.list` answered. */
7998
+ connections = /* @__PURE__ */ new Set();
7745
7999
  /** deviceKey → the device it addresses. Rebuilt on every reconcile. */
7746
8000
  exported = /* @__PURE__ */ new Map();
7747
8001
  /** numeric deviceId → deviceKey, for the event path. */
@@ -7848,16 +8102,27 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7848
8102
  async onInitialize() {
7849
8103
  const records = this.ctx.records;
7850
8104
  if (records === void 0) throw new Error("ha-export: records are not available");
7851
- await records.declare({ "ha-export-links": haExportLinksKind });
7852
- const linksHandle = records.kind(haExportLinksKind);
7853
- await this.runMigrations(require_ha_instance_settings.defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
8105
+ await records.declare({ "ha-export-targets": haExportTargetsKind });
8106
+ const targetsHandle = records.kind(haExportTargetsKind);
8107
+ const legacyLinks = await declareLegacyLinks(records);
8108
+ await this.runMigrations(require_legacy_integration_rows.defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
7854
8109
  readStored: async () => this.resolveGlobalStore(),
7855
8110
  api: this.ctx.api,
7856
- links: linksHandle,
8111
+ links: legacyLinks,
8112
+ targets: targetsHandle,
8113
+ clearSettingKeys: async (keys) => {
8114
+ await this.updateGlobalSettingsAsOperator(Object.fromEntries(keys.map((key) => [key, null])));
8115
+ },
7857
8116
  warn: (message) => this.ctx.logger.warn(message)
7858
8117
  })));
7859
- this.linkBook = await ExportLinks.load(linksHandle);
7860
- const secret = this.state("haExportMediaSecret", require_ha_instance_settings.string(), "");
8118
+ this.targetBook = await ExportTargets.load(targetsHandle);
8119
+ this.instances = openTargetInstances(records, {
8120
+ book: () => this.book(),
8121
+ connectionExists: (connectionId) => this.connectionExists(connectionId),
8122
+ health: (id, body) => this.targetHealth(id, body),
8123
+ onChanged: (id, change) => this.onTargetChanged(id, change)
8124
+ });
8125
+ const secret = this.state("haExportMediaSecret", require_legacy_integration_rows.string(), "");
7861
8126
  const existing = await secret.get();
7862
8127
  if (existing.length === 0) {
7863
8128
  const minted = randomSecret();
@@ -7890,29 +8155,29 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7890
8155
  this.exportedEntities = exportedEntities;
7891
8156
  return [
7892
8157
  {
7893
- capability: require_ha_instance_settings.deviceExportCapability,
8158
+ capability: require_legacy_integration_rows.deviceExportCapability,
7894
8159
  provider: {
7895
- getStatus: async (input) => this.buildStatus(this.brokerScope(require_ha_instance_settings.statusInstanceOf(input))),
7896
- listInstances: async () => this.config.knownBrokers.map((broker) => ({
7897
- instanceId: broker.id,
7898
- name: broker.name,
7899
- enabled: this.enabledBrokerIds().includes(broker.id),
7900
- kind: "home-assistant"
8160
+ getStatus: async (input) => this.buildStatus(this.targetScope(require_legacy_integration_rows.statusInstanceOf(input))),
8161
+ listInstances: async () => this.book().ids().map((id) => ({
8162
+ instanceId: id,
8163
+ name: this.book().name(id),
8164
+ enabled: this.book().isEnabled(id),
8165
+ kind: HA_TARGET_KIND
7901
8166
  })),
7902
8167
  listSupportedDeviceKinds: async ({ instanceId }) => {
7903
- this.brokerScope(instanceId);
8168
+ this.targetScope(instanceId);
7904
8169
  return [...SUPPORTED_DEVICE_KINDS];
7905
8170
  },
7906
- listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
7907
- exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
7908
- unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
8171
+ listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.targetScope(instanceId)),
8172
+ exposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, true),
8173
+ unexposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, false),
7909
8174
  listSupportedEntityKinds: async () => [
7910
8175
  "device",
7911
8176
  "action",
7912
8177
  "notifier"
7913
8178
  ],
7914
8179
  listExposedEntities: async ({ instanceId }) => {
7915
- const scope = this.brokerScope(instanceId);
8180
+ const scope = this.targetScope(instanceId);
7916
8181
  await exportedEntities.pruneDeleted();
7917
8182
  return [...this.exposedDeviceRows(scope).map((row) => ({
7918
8183
  entity: {
@@ -7923,13 +8188,13 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7923
8188
  })), ...exportedEntities.list()];
7924
8189
  },
7925
8190
  exposeEntity: async ({ instanceId, entity }) => {
7926
- if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
7927
- this.brokerScope(instanceId);
8191
+ if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, true);
8192
+ this.targetScope(instanceId);
7928
8193
  return exportedEntities.expose(entity);
7929
8194
  },
7930
8195
  unexposeEntity: async ({ instanceId, entity }) => {
7931
- if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
7932
- this.brokerScope(instanceId);
8196
+ if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, false);
8197
+ this.targetScope(instanceId);
7933
8198
  return exportedEntities.unexpose(entity);
7934
8199
  },
7935
8200
  getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
@@ -7938,8 +8203,12 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7938
8203
  }
7939
8204
  },
7940
8205
  {
7941
- capability: require_ha_instance_settings.addonRoutesCapability,
7942
- provider: require_ha_instance_settings.buildAddonRouteProvider(ADDON_ID, [{
8206
+ capability: require_legacy_integration_rows.integrationInstancesCapability,
8207
+ provider: this.requireInstances().toProvider()
8208
+ },
8209
+ {
8210
+ capability: require_legacy_integration_rows.addonRoutesCapability,
8211
+ provider: require_legacy_integration_rows.buildAddonRouteProvider(ADDON_ID, [{
7943
8212
  method: "POST",
7944
8213
  path: "/command",
7945
8214
  access: "authenticated",
@@ -7950,70 +8219,45 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7950
8219
  }])
7951
8220
  },
7952
8221
  {
7953
- capability: require_ha_instance_settings.oauthIntegrationCapability,
8222
+ capability: require_legacy_integration_rows.oauthIntegrationCapability,
7954
8223
  provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
7955
8224
  }
7956
8225
  ];
7957
8226
  }
7958
- /**
7959
- * The form of the addon shows each instance's own switch and media role. They are not settings
7960
- * of this addon: the values come from the instance's `ha-export-links` record.
7961
- */
7962
- async getGlobalSettings(overlay, cap, nodeId) {
7963
- return super.getGlobalSettings({
7964
- ...overlay,
7965
- ...linkFormOverlay(this.book())
7966
- }, cap, nodeId);
8227
+ async onConfigChanged() {
8228
+ if (this.targetBook === null) return;
8229
+ for (const link of this.links.values()) link.client.resetBackoff();
8230
+ await this.reconcile("config-changed");
8231
+ this.startTimer();
7967
8232
  }
7968
8233
  /**
7969
- * An operator write: the `exportTo:` / `mediaRole:` keys go to the record of their broker,
7970
- * everything else to the addon store as before. A key naming a broker this addon does not know
7971
- * is refused, never parked.
8234
+ * A target was created, edited, switched or deleted through `integration-instances`: reload
8235
+ * the records, then apply them (a new connection opens, a removed one closes, a changed
8236
+ * cadence restarts the timer). A deleted target's devices simply stop being exported.
7972
8237
  */
7973
- async updateGlobalSettingsAsOperator(patch, nodeId) {
7974
- const rest = {};
7975
- const flags = /* @__PURE__ */ new Map();
7976
- for (const [key, value] of Object.entries(patch)) {
7977
- const field = parseLinkFormKey(key);
7978
- if (field === null) {
7979
- rest[key] = value;
7980
- continue;
7981
- }
7982
- this.brokerScope(field.brokerId);
7983
- const entry = flags.get(field.brokerId) ?? {};
7984
- if (field.flag === "exportTo") {
7985
- if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
7986
- entry.exportTo = value;
7987
- } else {
7988
- const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
7989
- if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
7990
- entry.mediaRole = role;
7991
- }
7992
- flags.set(field.brokerId, entry);
7993
- }
7994
- for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
7995
- if (Object.keys(rest).length > 0 || flags.size === 0) {
7996
- await super.updateGlobalSettingsAsOperator(rest, nodeId);
7997
- return;
7998
- }
8238
+ async onTargetChanged(id, change) {
8239
+ await this.book().reload();
8240
+ this.ctx.logger.info("ha-export: an export target changed", { meta: {
8241
+ targetId: id,
8242
+ change
8243
+ } });
7999
8244
  await this.onConfigChanged();
8000
8245
  }
8001
- async onConfigChanged() {
8002
- for (const link of this.links.values()) link.client.resetBackoff();
8003
- await this.reconcile("config-changed");
8004
- this.startTimer();
8246
+ requireInstances() {
8247
+ if (this.instances === null) throw new Error("ha-export: the target instances are not open");
8248
+ return this.instances;
8005
8249
  }
8006
8250
  wireListeners() {
8007
- this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceStateChanged }, (event) => {
8251
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.DeviceStateChanged }, (event) => {
8008
8252
  this.onSliceChanged(event.data);
8009
8253
  });
8010
- this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceOnline }, (event) => {
8254
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.DeviceOnline }, (event) => {
8011
8255
  this.onOnlineChanged(event.data, true);
8012
8256
  });
8013
- this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceOffline }, (event) => {
8257
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.DeviceOffline }, (event) => {
8014
8258
  this.onOnlineChanged(event.data, false);
8015
8259
  });
8016
- this.subscribe({ category: require_ha_instance_settings.EventCategory.MotionOnMotionChanged }, (event) => {
8260
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.MotionOnMotionChanged }, (event) => {
8017
8261
  this.onMotion(event.data);
8018
8262
  });
8019
8263
  /**
@@ -8022,13 +8266,13 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
8022
8266
  * the entity existed and effectively never moved. It also carries the
8023
8267
  * window LEVEL, which nothing else on the export path does.
8024
8268
  */
8025
- this.subscribe({ category: require_ha_instance_settings.EventCategory.PipelineAudioInferenceResult }, (event) => {
8269
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.PipelineAudioInferenceResult }, (event) => {
8026
8270
  this.onAudioWindow(event.data);
8027
8271
  });
8028
- this.subscribe({ category: require_ha_instance_settings.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
8272
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
8029
8273
  this.onTrackLifecycle(event.data);
8030
8274
  });
8031
- this.subscribe({ category: require_ha_instance_settings.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
8275
+ this.subscribe({ category: require_legacy_integration_rows.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
8032
8276
  this.onZoneOccupancy(event.data);
8033
8277
  });
8034
8278
  /**
@@ -8037,11 +8281,11 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
8037
8281
  * rather than a state push.
8038
8282
  */
8039
8283
  for (const category of [
8040
- require_ha_instance_settings.EventCategory.DeviceRegistered,
8041
- require_ha_instance_settings.EventCategory.DeviceUnregistered,
8042
- require_ha_instance_settings.EventCategory.DeviceBindingsChanged,
8043
- require_ha_instance_settings.EventCategory.DeviceMetaChanged,
8044
- require_ha_instance_settings.EventCategory.BrokerStatusChanged
8284
+ require_legacy_integration_rows.EventCategory.DeviceRegistered,
8285
+ require_legacy_integration_rows.EventCategory.DeviceUnregistered,
8286
+ require_legacy_integration_rows.EventCategory.DeviceBindingsChanged,
8287
+ require_legacy_integration_rows.EventCategory.DeviceMetaChanged,
8288
+ require_legacy_integration_rows.EventCategory.BrokerStatusChanged
8045
8289
  ]) this.subscribe({ category }, () => {
8046
8290
  this.reconcileSoon();
8047
8291
  });
@@ -8494,7 +8738,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
8494
8738
  const fullResync = this.resync.take(reason);
8495
8739
  let readsHeld = false;
8496
8740
  await this.refreshComponentSupport();
8497
- const enabled = this.enabledBrokerIds();
8741
+ const enabled = this.enabledTargetIds();
8498
8742
  const devices = await this.ctx.api.deviceManager.listAll.query({});
8499
8743
  const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
8500
8744
  const byId = new Map(devices.map((device) => [device.id, device]));
@@ -9154,7 +9398,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9154
9398
  */
9155
9399
  capSlices: snapshot,
9156
9400
  itemEntities: items.entities,
9157
- ...require_ha_instance_settings.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
9401
+ ...require_legacy_integration_rows.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
9158
9402
  },
9159
9403
  heldBack
9160
9404
  };
@@ -9388,7 +9632,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9388
9632
  maskEnabled: privacySlice["enabled"] === true,
9389
9633
  audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
9390
9634
  };
9391
- const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_ha_instance_settings.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
9635
+ const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_legacy_integration_rows.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
9392
9636
  this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
9393
9637
  tags: { deviceId },
9394
9638
  meta: {
@@ -9439,72 +9683,64 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9439
9683
  * reasoning.
9440
9684
  */
9441
9685
  async refreshBrokers() {
9442
- const haBrokers = await require_ha_instance_settings.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
9443
- const known = haBrokers.map((broker) => ({
9444
- id: broker.id,
9445
- name: broker.name
9446
- }));
9686
+ const haBrokers = await require_legacy_integration_rows.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
9447
9687
  /**
9448
- * NEVER prune on an empty enumeration.
9449
- *
9450
- * `broker.list` is a fan-out union, so it answers `[]` for the whole window
9451
- * in which `provider-homeassistant` has not finished registering — and it
9452
- * registers its caps only at the END of an `initialize()` that restores 239
9453
- * devices. An empty list there means "I do not know yet", never "the
9454
- * operator deleted their instance", and the two were indistinguishable to
9455
- * this code: every boot pruned the membership to nothing and PERSISTED it,
9456
- * so the operator's exported cameras silently switched themselves off. Seen
9457
- * live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
9458
- * `devices=2` and both switches back to `false` with no line anywhere.
9688
+ * NEVER treat an empty enumeration as "the connections are gone".
9459
9689
  *
9460
- * This is D49 exactly: a destructive step gated on a fallible read must not
9461
- * fire on the reading that destroys work. A broker that really was deleted
9462
- * is pruned on the next pass that returns a NON-empty list without it,
9463
- * which costs one interval and cannot cost the operator their config.
9690
+ * `broker.list` is a fan-out union, so it answers `[]` for the whole window in which
9691
+ * `provider-homeassistant` has not finished registering. An empty list there means "I do not
9692
+ * know yet", never "the operator deleted their instance". A target whose connection is not in
9693
+ * the list is UNCONFIGURED for this pass: it pushes nothing, and its devices stay in its
9694
+ * record, so the next pass that sees the connection resumes them. Nothing is ever pruned or
9695
+ * unexposed from here (D49; 2026-08-09, 2026-08-24).
9464
9696
  */
9465
- if (haBrokers.length === 0) {
9466
- if (this.book().brokerIds().length > 0) this.ctx.logger.warn("ha-export: no Home Assistant broker answered — keeping the export links rather than pruning them", { meta: { brokers: this.book().brokerIds() } });
9467
- } else {
9468
- const gone = await this.book().prune(haBrokers.map((broker) => broker.id));
9469
- if (gone.length > 0) this.ctx.logger.info("ha-export: forgot the export links of removed instances", { meta: { brokers: gone } });
9697
+ this.connections = new Set(haBrokers.map((broker) => broker.id));
9698
+ this.refreshEnabledTargets();
9699
+ for (const id of this.book().ids()) {
9700
+ const target = this.book().get(id);
9701
+ if (target === void 0 || !this.book().isEnabled(id)) continue;
9702
+ if (!this.connectionExists(target.connectionId)) this.ctx.logger.warn(haBrokers.length === 0 ? "ha-export: no Home Assistant connection answered, so this export target is unconfigured for now; its devices stay in place" : "ha-export: the Home Assistant connection of this export target does not exist, so it is unconfigured; its devices stay in place", { meta: {
9703
+ targetId: id,
9704
+ connectionId: target.connectionId
9705
+ } });
9470
9706
  }
9471
- /**
9472
- * Only the roster is a setting; the membership is not, so a boot-window read that answers
9473
- * empty can no longer erase it (2026-08-24: a refresh wrote `membership: {}` over the
9474
- * operator's export list). The records are read at boot and a failed read stops it.
9475
- */
9476
- if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
9477
- this.refreshEnabledBrokers(known);
9478
- const wanted = new Set(this.enabledBrokerIds());
9479
- for (const [brokerId, link] of this.links) {
9480
- if (wanted.has(brokerId)) continue;
9707
+ const wanted = new Set(this.enabledTargetIds());
9708
+ for (const [targetId, link] of this.links) {
9709
+ if (wanted.has(targetId) && link.connectionId === this.book().connectionId(targetId)) continue;
9481
9710
  await link.client.dispose();
9482
- this.links.delete(brokerId);
9483
- this.componentSupport.forget(brokerId);
9484
- this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
9485
- }
9486
- for (const broker of haBrokers) {
9487
- if (!wanted.has(broker.id)) continue;
9488
- if (this.links.has(broker.id)) continue;
9489
- const resolved = await this.resolveBrokerConnection(broker.id, broker.addonId);
9711
+ this.links.delete(targetId);
9712
+ this.componentSupport.forget(targetId);
9713
+ this.ctx.logger.info("ha-export: stopped exporting to a target", { meta: {
9714
+ targetId,
9715
+ connectionId: link.connectionId
9716
+ } });
9717
+ }
9718
+ for (const targetId of wanted) {
9719
+ if (this.links.has(targetId)) continue;
9720
+ const connectionId = this.book().connectionId(targetId);
9721
+ const broker = haBrokers.find((candidate) => candidate.id === connectionId);
9722
+ if (broker === void 0) continue;
9723
+ const resolved = await this.resolveBrokerConnection(connectionId, broker.addonId);
9490
9724
  if (resolved === null) continue;
9491
9725
  const client = new PushClient({
9492
9726
  transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
9493
9727
  logger: this.ctx.logger,
9494
- brokerId: broker.id,
9495
- onLinkRestored: (brokerId) => this.reachability.request(brokerId)
9728
+ brokerId: targetId,
9729
+ onLinkRestored: (restoredId) => this.reachability.request(restoredId)
9496
9730
  });
9497
9731
  client.start();
9498
9732
  this.resync.markGap("broker-connected");
9499
- this.links.set(broker.id, {
9500
- brokerId: broker.id,
9501
- name: broker.name,
9733
+ this.links.set(targetId, {
9734
+ brokerId: targetId,
9735
+ connectionId,
9736
+ name: this.book().name(targetId),
9502
9737
  baseUrl: resolved.baseUrl,
9503
9738
  token: resolved.token,
9504
9739
  client
9505
9740
  });
9506
9741
  this.ctx.logger.info("ha-export: exporting to Home Assistant", { meta: {
9507
- brokerId: broker.id,
9742
+ targetId,
9743
+ connectionId,
9508
9744
  baseUrl: resolved.baseUrl
9509
9745
  } });
9510
9746
  }
@@ -9618,35 +9854,62 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9618
9854
  }
9619
9855
  }
9620
9856
  /**
9621
- * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
9622
- *
9623
- * NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
9857
+ * The targets that push, as of the last {@link refreshEnabledTargets}: switched on AND with a
9858
+ * connection the hub knows. NOT read from `this.config`: the switch is a fact of the target's
9624
9859
  * record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
9625
9860
  * and before the per-device panel renders, never written to.
9626
9861
  */
9627
9862
  enabled = [];
9628
- /** The `ha-export-links` records, loaded at boot after the migrations. */
9629
- linkBook = null;
9863
+ /** The `ha-export-targets` records, loaded at boot after the migrations. */
9864
+ targetBook = null;
9630
9865
  book() {
9631
- if (this.linkBook === null) throw new Error("ha-export: the export links are not loaded yet");
9632
- return this.linkBook;
9866
+ if (this.targetBook === null) throw new Error("ha-export: the export targets are not loaded yet");
9867
+ return this.targetBook;
9633
9868
  }
9634
- /** brokerId → exported device ids, from the records. */
9869
+ /** targetId → exported device ids, from the records. */
9635
9870
  membership() {
9636
9871
  return this.book().membership();
9637
9872
  }
9638
- /** Persist a membership change: each instance's record first, the in-memory copy after it. */
9873
+ /** Persist a membership change: each target's record first, the in-memory copy after it. */
9639
9874
  async persistMembership(next) {
9640
- await this.book().setMembership(next, (brokerId) => this.brokerName(brokerId));
9875
+ await this.book().setMembership(next);
9641
9876
  }
9642
- refreshEnabledBrokers(known) {
9643
- this.enabled = known.map((broker) => broker.id).filter((id) => this.book().isEnabled(id));
9877
+ connectionExists(connectionId) {
9878
+ return connectionId.length > 0 && this.connections.has(connectionId);
9644
9879
  }
9645
- enabledBrokerIds() {
9880
+ refreshEnabledTargets() {
9881
+ this.enabled = this.book().ids().filter((id) => this.book().isEnabled(id) && this.connectionExists(this.book().connectionId(id)));
9882
+ }
9883
+ enabledTargetIds() {
9646
9884
  return this.enabled;
9647
9885
  }
9648
- brokerName(brokerId) {
9649
- return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
9886
+ targetName(targetId) {
9887
+ return this.book().name(targetId);
9888
+ }
9889
+ /** The Status column of the instances table: why a target is, or is not, pushing. */
9890
+ targetHealth(id, body) {
9891
+ if (body.enabled === false) return {
9892
+ state: "off",
9893
+ label: "Off"
9894
+ };
9895
+ if (!this.connectionExists(body.connectionId)) return {
9896
+ state: "error",
9897
+ label: "Unconfigured",
9898
+ detail: body.connectionId.length === 0 ? "Pick the Home Assistant connection this target pushes to" : "The Home Assistant connection of this target does not exist"
9899
+ };
9900
+ const link = this.links.get(id);
9901
+ if (link === void 0) return {
9902
+ state: "unknown",
9903
+ label: "Not connected yet"
9904
+ };
9905
+ return link.client.healthy ? {
9906
+ state: "ok",
9907
+ label: "Connected"
9908
+ } : {
9909
+ state: "error",
9910
+ label: "Unreachable",
9911
+ detail: link.client.failure?.text ?? "unreachable"
9912
+ };
9650
9913
  }
9651
9914
  async handleCommandRoute(body, reply) {
9652
9915
  const topic = readString(body, "topic");
@@ -9801,7 +10064,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9801
10064
  }
9802
10065
  switch (command.kind) {
9803
10066
  case "camera-function": {
9804
- const parsed = require_ha_instance_settings.CameraSwitchIdSchema.safeParse(command.switchId);
10067
+ const parsed = require_legacy_integration_rows.CameraSwitchIdSchema.safeParse(command.switchId);
9805
10068
  if (!parsed.success) {
9806
10069
  this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
9807
10070
  tags: { deviceId: command.deviceId },
@@ -10034,7 +10297,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10034
10297
  }
10035
10298
  /**
10036
10299
  * Each broker's media base, read per use: the palette's role the broker chose
10037
- * (the `ha-export-links` record's `mediaRole`, `publicEndpoint` by default, internal when none is set). A role that resolves to nothing
10300
+ * (the export target record's `mediaRole`, `publicEndpoint` by default, internal when none is set). A role that resolves to nothing
10038
10301
  * is `null` and says so — the image entities then carry no value, which reads as
10039
10302
  * empty rather than as a broken link.
10040
10303
  */
@@ -10069,27 +10332,26 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10069
10332
  return bases;
10070
10333
  }
10071
10334
  /**
10072
- * Every broker this addon knows, enabled or not: a membership write may
10073
- * target a broker that is switched off (its devices resume when it is turned
10074
- * back on). A cold `knownBrokers` still counts the enabled ids.
10335
+ * Every target this addon holds, enabled or not: a membership write may target one that is
10336
+ * switched off or unconfigured (its devices resume when it is turned back on).
10075
10337
  */
10076
- knownBrokerIds() {
10077
- return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
10338
+ knownTargetIds() {
10339
+ return new Set(this.book().ids());
10078
10340
  }
10079
10341
  /**
10080
10342
  * The brokers a `device-export` read answers over: the named instance, or
10081
10343
  * (absent) every enabled broker. A broker this addon does not know is
10082
10344
  * refused, never answered empty.
10083
10345
  */
10084
- brokerScope(instanceId) {
10085
- if (instanceId === void 0) return this.enabledBrokerIds();
10086
- if (!this.knownBrokerIds().has(instanceId)) throw new require_ha_instance_settings.ExportInstanceUnknownError("provider-homeassistant", instanceId);
10346
+ targetScope(instanceId) {
10347
+ if (instanceId === void 0) return this.enabledTargetIds();
10348
+ if (!this.knownTargetIds().has(instanceId)) throw new require_legacy_integration_rows.ExportInstanceUnknownError("provider-homeassistant", instanceId);
10087
10349
  return [instanceId];
10088
10350
  }
10089
10351
  exposedDeviceRows(scope) {
10090
10352
  return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
10091
10353
  deviceId,
10092
- exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
10354
+ exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.targetName(brokerId)).join(", ")
10093
10355
  }));
10094
10356
  }
10095
10357
  /**
@@ -10097,15 +10359,15 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10097
10359
  * instance. There is no write without a target and no "everywhere": the
10098
10360
  * other brokers' membership is not touched.
10099
10361
  */
10100
- async setBrokerMembership(instanceId, deviceId, exposed) {
10101
- this.brokerScope(instanceId);
10362
+ async setTargetMembership(instanceId, deviceId, exposed) {
10363
+ this.targetScope(instanceId);
10102
10364
  const next = setExposed(this.membership(), instanceId, deviceId, exposed);
10103
10365
  if (next === this.membership()) {
10104
10366
  this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
10105
10367
  tags: { deviceId: Number(deviceId) },
10106
10368
  meta: {
10107
10369
  exposed,
10108
- brokerId: instanceId
10370
+ targetId: instanceId
10109
10371
  }
10110
10372
  });
10111
10373
  return;
@@ -10115,93 +10377,29 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10115
10377
  tags: { deviceId: Number(deviceId) },
10116
10378
  meta: {
10117
10379
  exposed,
10118
- brokerId: instanceId
10380
+ targetId: instanceId
10119
10381
  }
10120
10382
  });
10121
10383
  await this.reconcile(exposed ? "expose" : "unexpose");
10122
10384
  }
10123
- globalSettingsSchema() {
10124
- const brokerFields = this.config.knownBrokers.map((broker) => ({
10125
- type: "boolean",
10126
- key: exportToFormKey(broker.id),
10127
- label: broker.name,
10128
- style: "switch",
10129
- default: false
10130
- }));
10131
- return { sections: [
10132
- {
10133
- id: "ha-export-brokers",
10134
- title: "Home Assistant instances",
10135
- titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
10136
- 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.",
10137
- columns: 1,
10138
- fields: brokerFields.length > 0 ? brokerFields : [{
10139
- type: "info",
10140
- key: "ha-export:__no-brokers",
10141
- label: "No brokers",
10142
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.ha-export___no-brokers.label",
10143
- content: "Register a Home Assistant broker to enable the export.",
10144
- variant: "warning"
10145
- }]
10146
- },
10147
- ...this.config.knownBrokers.length > 0 ? [{
10148
- id: "ha-export-transport",
10149
- title: "Connection",
10150
- titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
10151
- description: "The hub address each Home Assistant instance fetches image links from. Public endpoint is the default, so notifications show images away from home; when none is set the internal address is used. Choose Internal to never use the public endpoint.",
10152
- columns: 1,
10153
- fields: this.config.knownBrokers.map((broker) => ({
10154
- type: "select",
10155
- key: mediaRoleFormKey(broker.id),
10156
- label: `Image links for ${broker.name}`,
10157
- options: [{
10158
- value: "internal",
10159
- label: "Internal (LAN address)"
10160
- }, {
10161
- value: "publicEndpoint",
10162
- label: "Public endpoint (reachable from outside)"
10163
- }],
10164
- default: "publicEndpoint"
10165
- }))
10166
- }] : [],
10167
- {
10168
- id: "ha-export-advanced",
10169
- title: "Advanced",
10170
- titleKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.title",
10171
- style: "accordion",
10172
- defaultCollapsed: true,
10173
- columns: 1,
10174
- fields: [{
10175
- type: "number",
10176
- key: "reconcileIntervalSec",
10177
- label: "Reconcile interval (s)",
10178
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.label",
10179
- description: "Full re-announce and re-push cadence.",
10180
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.description",
10181
- default: DEFAULT_CONFIG.reconcileIntervalSec
10182
- }]
10183
- }
10184
- ] };
10185
- }
10186
10385
  /**
10187
10386
  * Per-device Export panel — one switch PER BROKER.
10188
10387
  *
10189
- * Field keys are namespaced `haExport:<deviceId>:<brokerId>`: the
10388
+ * Field keys are namespaced `haExport:<deviceId>:<targetId>`: the
10190
10389
  * device-details form flattens fields by key across every contributing
10191
10390
  * section, so a bare `enabled` collides with the Alexa and HomeKit
10192
10391
  * exporters' identically-named fields and the operator sees another
10193
10392
  * exporter's value in this one's toggle.
10194
10393
  */
10195
10394
  async buildContribution(deviceId) {
10196
- this.refreshEnabledBrokers(this.config.knownBrokers);
10197
10395
  const idStr = String(deviceId);
10198
- const enabled = this.enabledBrokerIds();
10199
- const fields = enabled.map((brokerId) => ({
10396
+ const enabled = this.book().ids().filter((id) => this.book().isEnabled(id));
10397
+ const fields = enabled.map((targetId) => ({
10200
10398
  type: "boolean",
10201
- key: `haExport:${deviceId}:${brokerId}`,
10202
- label: `Export to ${this.brokerName(brokerId)}`,
10399
+ key: `haExport:${deviceId}:${targetId}`,
10400
+ label: `Export to ${this.targetName(targetId)}`,
10203
10401
  style: "switch",
10204
- value: isExposed(this.membership(), brokerId, idStr),
10402
+ value: isExposed(this.membership(), targetId, idStr),
10205
10403
  immediate: true
10206
10404
  }));
10207
10405
  return {
@@ -10215,7 +10413,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10215
10413
  id: "ha-export",
10216
10414
  title: "Home Assistant",
10217
10415
  titleKey: "provider-homeassistant:contribution.s_ha-export.title",
10218
- description: enabled.length > 0 ? "Each instance receives only the devices switched on for it." : "No Home Assistant instance is enabled for export.",
10416
+ description: enabled.length > 0 ? "Each target receives only the devices switched on for it." : "No Home Assistant export target is enabled.",
10219
10417
  tab: "export",
10220
10418
  columns: 1,
10221
10419
  order: 30,
@@ -10223,7 +10421,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10223
10421
  type: "info",
10224
10422
  key: `haExport:${deviceId}:__none`,
10225
10423
  label: "Not configured",
10226
- content: "Enable a Home Assistant instance in the addon settings first.",
10424
+ content: "Add an export target on the Connectivity page first.",
10227
10425
  variant: "info"
10228
10426
  }]
10229
10427
  }]
@@ -10236,10 +10434,10 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10236
10434
  /**
10237
10435
  * Driven by the PATCH KEYS, not by the enabled-broker list.
10238
10436
  *
10239
- * The key already carries the broker (`haExport:<deviceId>:<brokerId>`) —
10437
+ * The key already carries the broker (`haExport:<deviceId>:<targetId>`) —
10240
10438
  * the operator could not have been shown the switch otherwise — so reading
10241
10439
  * the broker from it needs no other state to be warm. Iterating
10242
- * `enabledBrokerIds()` instead made this depend on a read-through cache,
10440
+ * `enabledTargetIds()` instead made this depend on a read-through cache,
10243
10441
  * and when that cache was cold the loop matched nothing, `touched` stayed
10244
10442
  * false, and the method returned `{success: true}` HAVING DONE NOTHING.
10245
10443
  * The admin UI showed the switch flip and it silently did not persist;
@@ -10249,9 +10447,16 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10249
10447
  const prefix = `haExport:${deviceId}:`;
10250
10448
  for (const [key, value] of Object.entries(patch)) {
10251
10449
  if (!key.startsWith(prefix)) continue;
10252
- const brokerId = key.slice(prefix.length);
10253
- if (brokerId.length === 0) continue;
10254
- next = setExposed(next, brokerId, idStr, value === true);
10450
+ const targetId = key.slice(prefix.length);
10451
+ if (targetId.length === 0) continue;
10452
+ if (!this.book().has(targetId)) {
10453
+ this.ctx.logger.warn("ha-export: a device patch names an export target that does not exist", {
10454
+ tags: { deviceId },
10455
+ meta: { targetId }
10456
+ });
10457
+ continue;
10458
+ }
10459
+ next = setExposed(next, targetId, idStr, value === true);
10255
10460
  touched = true;
10256
10461
  }
10257
10462
  if (!touched) {
@@ -10262,9 +10467,9 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10262
10467
  return { success: true };
10263
10468
  }
10264
10469
  await this.persistMembership(next);
10265
- this.ctx.logger.info("ha-export: per-broker export membership changed", {
10470
+ this.ctx.logger.info("ha-export: per-target export membership changed", {
10266
10471
  tags: { deviceId },
10267
- meta: { brokers: brokersExposing(next, idStr) }
10472
+ meta: { targets: brokersExposing(next, idStr) }
10268
10473
  });
10269
10474
  await this.reconcile("device-panel");
10270
10475
  return { success: true };
@@ -10273,15 +10478,17 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10273
10478
  const enabled = scope;
10274
10479
  const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
10275
10480
  const healthy = links.filter((link) => link.client.healthy).length;
10276
- const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
10481
+ const failures = links.map((link) => link.client.failure).filter((failure) => failure !== null);
10482
+ const unconfigured = scope.filter((id) => this.book().isEnabled(id) && !this.connectionExists(this.book().connectionId(id)));
10483
+ const linkFailure = failures[0]?.text ?? (unconfigured.length > 0 ? `The Home Assistant connection of ${unconfigured.map((id) => this.targetName(id)).join(", ")} is not set or does not exist` : void 0);
10277
10484
  return {
10278
- linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10485
+ linkState: links.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10279
10486
  exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
10280
10487
  ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
10281
10488
  setup: {
10282
10489
  fields: [
10283
10490
  {
10284
- label: "Home Assistant instances",
10491
+ label: "Export targets",
10285
10492
  value: `${healthy} / ${links.length} reachable`
10286
10493
  },
10287
10494
  {
@@ -10307,7 +10514,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
10307
10514
  }
10308
10515
  startTimer() {
10309
10516
  this.stopTimer();
10310
- const ms = Math.max(30, this.config.reconcileIntervalSec) * 1e3;
10517
+ const ms = this.book().reconcileIntervalSec() * 1e3;
10311
10518
  this.reconcileTimer = setInterval(() => void this.reconcile("periodic"), ms);
10312
10519
  }
10313
10520
  stopTimer() {