@camstack/addon-provider-homeassistant 1.2.658 → 1.2.659

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { F as buildAddonRouteProvider, It as statusInstanceOf, Jt as BaseAddon, Ot as runExportedAction, Pt as sendToExportedTarget, Qt as baseUnitOf, Sn as EventCategory, U as consumableFormOf, W as consumableKindOf, X as deviceExportCapability, Zt as RevConflictError, _ as HvacModeSchema, _t as oauthIntegrationCapability, a as AlarmArmModeSchema, an as nodePin, bt as preconditionOf, c as CAMERA_SWITCH_ORDER, cn as boolean, d as ConsumableFormSchema, en as defineAddonMigrations, f as ConsumableItemSchema, fn as object, g as FanDirectionSchema, gn as unknown, h as ExportInstanceUnknownError, i as listHomeAssistantBrokers, in as isReadingQuantity, l as COCO_TO_MACRO, mn as string, on as _enum, p as ConsumableStatusSchema, sn as array, t as haInstanceSettingsPortsOver, tn as defineEntryKind, u as CameraSwitchIdSchema, v as MediaPlayerRepeatSchema, w as addonRoutesCapability, wt as pruneDeletedEntities, y as PLUG_APPLIANCE_TYPES } from "../ha-instance-settings-qhCNJbbn.mjs";
1
+ import { F as buildAddonRouteProvider, It as statusInstanceOf, Jt as BaseAddon, Ot as runExportedAction, Pt as sendToExportedTarget, Qt as baseUnitOf, Sn as EventCategory, U as consumableFormOf, W as consumableKindOf, X as deviceExportCapability, Zt as RevConflictError, _ as HvacModeSchema, _t as oauthIntegrationCapability, a as AlarmArmModeSchema, an as nodePin, bt as preconditionOf, c as CAMERA_SWITCH_ORDER, cn as boolean, ct as integrationInstancesCapability, d as ConsumableFormSchema, dn as number, en as defineAddonMigrations, f as ConsumableItemSchema, fn as object, g as FanDirectionSchema, gn as unknown, h as ExportInstanceUnknownError, i as listHomeAssistantBrokers, in as isReadingQuantity, l as COCO_TO_MACRO, mn as string, on as _enum, p as ConsumableStatusSchema, sn as array, t as haInstanceSettingsPortsOver, tn as defineEntryKind, u as CameraSwitchIdSchema, v as MediaPlayerRepeatSchema, w as addonRoutesCapability, wt as pruneDeletedEntities, x as RecordIntegrationInstances, y as PLUG_APPLIANCE_TYPES } from "../ha-instance-settings-qhCNJbbn.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -5204,6 +5204,244 @@ async function planDropPerBrokerFlags(ports) {
5204
5204
  }] };
5205
5205
  }
5206
5206
  //#endregion
5207
+ //#region src/ha-export/target-records.ts
5208
+ /**
5209
+ * An export target (#422): one `ha-export-targets` record per place the export reaches. It names
5210
+ * the Home Assistant connection it pushes to (`connectionId`, a broker id), which hub address
5211
+ * role its image links use, how often it re-announces, and the devices exported to it.
5212
+ *
5213
+ * The record is the integration instance itself (`integration-instances`, role `services`): the
5214
+ * operator creates, edits, switches and deletes targets from the Connectivity page, and the ids
5215
+ * are minted here (`hae_001`, ...). Several targets may reference one connection.
5216
+ *
5217
+ * This addon is the only writer of `devices`; the instance door writes the rest. Both go through
5218
+ * `patch` (a merge), so neither overwrites the other, and the in-memory copy here is the
5219
+ * synchronous read the event path needs, reloaded after every change.
5220
+ */
5221
+ var HA_EXPORT_TARGETS_KIND_NAME = "ha-export-targets";
5222
+ var HA_TARGET_KIND = "ha-target";
5223
+ var TARGET_ID_PREFIX = "hae_";
5224
+ var haExportTargetsKind = defineEntryKind(HA_EXPORT_TARGETS_KIND_NAME, {
5225
+ title: "Home Assistant export targets",
5226
+ nameField: "name",
5227
+ schema: object({
5228
+ name: string().min(1),
5229
+ connectionId: string(),
5230
+ mediaRole: _enum(HA_MEDIA_ROLES),
5231
+ reconcileIntervalSec: number(),
5232
+ devices: array(string()),
5233
+ enabled: boolean().optional()
5234
+ }),
5235
+ writeTrigger: "an operator adds, edits, switches or deletes an export target, or exports a device to it"
5236
+ });
5237
+ /** What a target starts as: on, public endpoint, five-minute cadence, no device, no connection yet. */
5238
+ function newTargetBody(name) {
5239
+ return {
5240
+ name,
5241
+ connectionId: "",
5242
+ mediaRole: "publicEndpoint",
5243
+ reconcileIntervalSec: 300,
5244
+ devices: [],
5245
+ enabled: true
5246
+ };
5247
+ }
5248
+ /** The id after the highest `hae_NNN` among `existing`. */
5249
+ function nextTargetId(existing) {
5250
+ let highest = 0;
5251
+ for (const id of existing) {
5252
+ if (!id.startsWith("hae_")) continue;
5253
+ const n = Number(id.slice(4));
5254
+ if (Number.isInteger(n) && n > highest) highest = n;
5255
+ }
5256
+ return `${TARGET_ID_PREFIX}${String(highest + 1).padStart(3, "0")}`;
5257
+ }
5258
+ /** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
5259
+ async function readAllTargets(handle) {
5260
+ const rows = /* @__PURE__ */ new Map();
5261
+ let cursor;
5262
+ do {
5263
+ const page = await handle.query({
5264
+ limit: 1e3,
5265
+ ...cursor === void 0 ? {} : { cursor }
5266
+ });
5267
+ for (const row of page.records) rows.set(row.id, row.data);
5268
+ cursor = page.nextCursor ?? void 0;
5269
+ } while (cursor !== void 0);
5270
+ return rows;
5271
+ }
5272
+ function membershipOf$1(rows) {
5273
+ const out = {};
5274
+ for (const [id, row] of rows) if (row.devices.length > 0) out[id] = row.devices;
5275
+ return out;
5276
+ }
5277
+ function sameList(a, b) {
5278
+ return a.length === b.length && a.every((value, index) => value === b[index]);
5279
+ }
5280
+ /**
5281
+ * The in-memory copy of the target records. `membership()` keeps its identity until a device
5282
+ * list changes.
5283
+ */
5284
+ var ExportTargets = class ExportTargets {
5285
+ handle;
5286
+ rows;
5287
+ memberships;
5288
+ constructor(handle, rows) {
5289
+ this.handle = handle;
5290
+ this.rows = rows;
5291
+ this.memberships = membershipOf$1(rows);
5292
+ }
5293
+ /** A failed read throws: serving an empty set would unexport every device on the next pass. */
5294
+ static async load(handle) {
5295
+ return new ExportTargets(handle, await readAllTargets(handle));
5296
+ }
5297
+ /** Re-read the records after the instance door changed one; a failed read keeps the old copy. */
5298
+ async reload() {
5299
+ this.adopt(await readAllTargets(this.handle));
5300
+ }
5301
+ ids() {
5302
+ return [...this.rows.keys()];
5303
+ }
5304
+ has(id) {
5305
+ return this.rows.has(id);
5306
+ }
5307
+ get(id) {
5308
+ return this.rows.get(id);
5309
+ }
5310
+ name(id) {
5311
+ return this.rows.get(id)?.name ?? id;
5312
+ }
5313
+ /** The target's own switch; an absent field means on. */
5314
+ isEnabled(id) {
5315
+ const row = this.rows.get(id);
5316
+ return row !== void 0 && row.enabled !== false;
5317
+ }
5318
+ connectionId(id) {
5319
+ return this.rows.get(id)?.connectionId ?? "";
5320
+ }
5321
+ mediaRole(id) {
5322
+ return this.rows.get(id)?.mediaRole ?? "publicEndpoint";
5323
+ }
5324
+ /** The cadence of the quickest enabled target (the addon runs one timer); the default when none. */
5325
+ reconcileIntervalSec() {
5326
+ const seconds = [...this.rows.values()].filter((row) => row.enabled !== false).map((row) => row.reconcileIntervalSec);
5327
+ if (seconds.length === 0) return 300;
5328
+ return Math.max(30, Math.min(...seconds));
5329
+ }
5330
+ /** targetId → exported device ids, for the targets that export at least one. */
5331
+ membership() {
5332
+ return this.memberships;
5333
+ }
5334
+ /**
5335
+ * Persist the device lists that differ from the held ones. A target that does not exist is
5336
+ * never created here (an id the records do not hold is refused by the caller).
5337
+ */
5338
+ async setMembership(next) {
5339
+ let rows = this.rows;
5340
+ for (const id of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
5341
+ const current = rows.get(id);
5342
+ if (current === void 0) continue;
5343
+ const devices = next[id] ?? [];
5344
+ if (sameList(current.devices, devices)) continue;
5345
+ await this.handle.patch(id, { devices: [...devices] });
5346
+ rows = new Map(rows).set(id, {
5347
+ ...current,
5348
+ devices: [...devices]
5349
+ });
5350
+ this.adopt(rows);
5351
+ }
5352
+ }
5353
+ adopt(rows) {
5354
+ const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
5355
+ this.rows = rows;
5356
+ if (devicesChanged) this.memberships = membershipOf$1(rows);
5357
+ }
5358
+ };
5359
+ //#endregion
5360
+ //#region src/ha-export/migrations/targets-from-links.ts
5361
+ var RECONCILE_INTERVAL_KEY = "reconcileIntervalSec";
5362
+ var KNOWN_BROKERS_KEY = "knownBrokers";
5363
+ var sameDevices$1 = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
5364
+ function intervalOf(stored) {
5365
+ const value = stored[RECONCILE_INTERVAL_KEY];
5366
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 300;
5367
+ }
5368
+ function matches(target, expected) {
5369
+ 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);
5370
+ }
5371
+ async function moveLinksToTargets(ports) {
5372
+ const links = await ports.readLinks();
5373
+ if (links.size === 0) return {
5374
+ outcome: "already-done",
5375
+ summary: { moved: 0 }
5376
+ };
5377
+ const targets = await ports.readTargets();
5378
+ const reconcileIntervalSec = intervalOf(await ports.readStored());
5379
+ const taken = new Set([...targets.values()].map((target) => target.connectionId));
5380
+ const ids = new Set(targets.keys());
5381
+ const created = /* @__PURE__ */ new Map();
5382
+ for (const brokerId of [...links.keys()].sort()) {
5383
+ if (taken.has(brokerId)) continue;
5384
+ const link = links.get(brokerId);
5385
+ if (link === void 0) continue;
5386
+ const body = {
5387
+ name: link.name,
5388
+ connectionId: brokerId,
5389
+ mediaRole: link.mediaRole,
5390
+ reconcileIntervalSec,
5391
+ devices: [...link.devices],
5392
+ enabled: link.exportTo
5393
+ };
5394
+ const id = nextTargetId(ids);
5395
+ if (await ports.putTarget(id, body) === "exists") throw new Error(`the export target id ${id} is already taken; nothing was cleared`);
5396
+ ids.add(id);
5397
+ created.set(id, body);
5398
+ }
5399
+ if (created.size === 0) return {
5400
+ outcome: "already-done",
5401
+ summary: { moved: 0 }
5402
+ };
5403
+ const held = await ports.readTargets();
5404
+ for (const [id, expected] of created) {
5405
+ const row = held.get(id);
5406
+ 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`);
5407
+ }
5408
+ return {
5409
+ outcome: "done",
5410
+ summary: {
5411
+ moved: created.size,
5412
+ devices: [...created.values()].reduce((sum, target) => sum + target.devices.length, 0)
5413
+ }
5414
+ };
5415
+ }
5416
+ async function dropLinksAndCache(ports) {
5417
+ const links = await ports.readLinks();
5418
+ const stored = await ports.readStored();
5419
+ const targets = await ports.readTargets();
5420
+ const movable = [];
5421
+ const parked = [];
5422
+ for (const [brokerId, link] of links) {
5423
+ const target = [...targets.values()].find((candidate) => candidate.connectionId === brokerId);
5424
+ if (target !== void 0 && sameDevices$1(target.devices, link.devices)) movable.push(brokerId);
5425
+ else parked.push(brokerId);
5426
+ }
5427
+ if (parked.length > 0) ports.warn(`homeassistant-export: the link records of ${parked.join(", ")} are kept: no export target holds the same devices`);
5428
+ if (movable.length > 0) await ports.deleteLinks(movable);
5429
+ const keys = parked.length > 0 ? [] : [RECONCILE_INTERVAL_KEY, KNOWN_BROKERS_KEY].filter((key) => key in stored);
5430
+ if (keys.length > 0) await ports.clearSettingKeys(keys);
5431
+ if (movable.length === 0 && keys.length === 0) return {
5432
+ outcome: "already-done",
5433
+ summary: { parked: parked.length }
5434
+ };
5435
+ return {
5436
+ outcome: "done",
5437
+ summary: {
5438
+ deleted: movable.length,
5439
+ parked: parked.length,
5440
+ keys: keys.length
5441
+ }
5442
+ };
5443
+ }
5444
+ //#endregion
5207
5445
  //#region src/ha-export/migrations/links-to-record.ts
5208
5446
  /**
5209
5447
  * What the export carries to a Home Assistant instance moves into the instance's
@@ -5228,7 +5466,7 @@ var MEMBERSHIP_KEY = "membership";
5228
5466
  var EXPORT_PREFIX = "exportTo:";
5229
5467
  var ROLE_PREFIX = "mediaRole:";
5230
5468
  var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
5231
- function membershipOf$1(value) {
5469
+ function membershipOf(value) {
5232
5470
  const out = /* @__PURE__ */ new Map();
5233
5471
  if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
5234
5472
  for (const [brokerId, devices] of Object.entries(value)) {
@@ -5251,7 +5489,7 @@ function integrationByBroker(rows, warn) {
5251
5489
  }
5252
5490
  /** Every broker the old shape has something for, with the value the old reader would have used. */
5253
5491
  function legacyLinks(stored, rows, warn) {
5254
- const membership = membershipOf$1(stored[MEMBERSHIP_KEY]);
5492
+ const membership = membershipOf(stored[MEMBERSHIP_KEY]);
5255
5493
  const unreadable = rows.find((row) => Object.keys(row.settings).length === 0);
5256
5494
  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`);
5257
5495
  const integrations = integrationByBroker(rows, warn);
@@ -5339,6 +5577,8 @@ async function planDropMovedLinks(ports) {
5339
5577
  //#endregion
5340
5578
  //#region src/ha-export/migrations/index.ts
5341
5579
  var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
5580
+ var DROP_MOVED_LINKS_ID = "homeassistant-export/2026-11-04-drop-moved-links";
5581
+ var TARGETS_FROM_LINKS_ID = "homeassistant-export/2026-11-04-targets-from-links";
5342
5582
  /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5343
5583
  var ADDON_MIGRATIONS = [
5344
5584
  {
@@ -5407,7 +5647,7 @@ var ADDON_MIGRATIONS = [
5407
5647
  },
5408
5648
  {
5409
5649
  meta: {
5410
- id: "homeassistant-export/2026-11-04-drop-moved-links",
5650
+ id: DROP_MOVED_LINKS_ID,
5411
5651
  owner: "homeassistant-export",
5412
5652
  level: "addon-state",
5413
5653
  seq: 5,
@@ -5420,10 +5660,51 @@ var ADDON_MIGRATIONS = [
5420
5660
  fixtureMark: "the reconcile interval the migration does not know"
5421
5661
  },
5422
5662
  plan: planDropMovedLinks
5663
+ },
5664
+ {
5665
+ meta: {
5666
+ id: TARGETS_FROM_LINKS_ID,
5667
+ owner: "homeassistant-export",
5668
+ level: "addon-state",
5669
+ seq: 6,
5670
+ dependsOn: [LINKS_TO_RECORD_ID],
5671
+ store: "settings",
5672
+ scope: "cluster",
5673
+ onFailure: "block-owner",
5674
+ bodyKind: "procedure",
5675
+ 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",
5676
+ fixtureMark: "the exported entities the migration does not touch"
5677
+ },
5678
+ procedure: moveLinksToTargets
5679
+ },
5680
+ {
5681
+ meta: {
5682
+ id: "homeassistant-export/2026-11-04-drop-links-and-cache",
5683
+ owner: "homeassistant-export",
5684
+ level: "addon-state",
5685
+ seq: 7,
5686
+ dependsOn: [TARGETS_FROM_LINKS_ID],
5687
+ store: "settings",
5688
+ scope: "cluster",
5689
+ onFailure: "block-owner",
5690
+ bodyKind: "procedure",
5691
+ description: "Delete each ha-export-links record whose target holds the same devices, then the reconcileIntervalSec and knownBrokers settings",
5692
+ fixtureMark: "the exported entities the migration does not touch"
5693
+ },
5694
+ procedure: dropLinksAndCache
5423
5695
  }
5424
5696
  ];
5425
- var haExportLinksKind = defineEntryKind("ha-export-links", {
5426
- title: "Home Assistant export links",
5697
+ //#endregion
5698
+ //#region src/ha-export/migrations/legacy-links-kind.ts
5699
+ /**
5700
+ * The `ha-export-links` record kind (planning#408 P4): one record per Home Assistant broker with
5701
+ * its export switch, media role and devices. Export targets (`ha-export-targets`) replaced it;
5702
+ * the kind is declared ONLY so the migrations can read it and delete its rows. No runtime code
5703
+ * outside `migrations/` names it (`no-legacy-links-reader.spec.ts`).
5704
+ */
5705
+ var HA_EXPORT_LINKS_KIND_NAME = "ha-export-links";
5706
+ var haExportLinksKind = defineEntryKind(HA_EXPORT_LINKS_KIND_NAME, {
5707
+ title: "Home Assistant export links (retired)",
5427
5708
  nameField: "name",
5428
5709
  schema: object({
5429
5710
  name: string().min(1),
@@ -5431,16 +5712,12 @@ var haExportLinksKind = defineEntryKind("ha-export-links", {
5431
5712
  mediaRole: _enum(HA_MEDIA_ROLES),
5432
5713
  devices: array(string())
5433
5714
  }),
5434
- writeTrigger: "an operator switches the export of a Home Assistant instance, picks its image-link address, or exports a device to it"
5715
+ writeTrigger: "only the migrations that move the links into export targets write it"
5435
5716
  });
5436
- /** A link the operator has not touched: off, on the public endpoint, no device. */
5437
- function defaultLinks(name) {
5438
- return {
5439
- name,
5440
- exportTo: false,
5441
- mediaRole: "publicEndpoint",
5442
- devices: []
5443
- };
5717
+ /** Declare the retired kind and hand back its handle, for the migration ports. */
5718
+ async function declareLegacyLinks(records) {
5719
+ await records.declare({ [HA_EXPORT_LINKS_KIND_NAME]: haExportLinksKind });
5720
+ return records.kind(haExportLinksKind);
5444
5721
  }
5445
5722
  /** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
5446
5723
  async function readAllLinks(handle) {
@@ -5456,123 +5733,12 @@ async function readAllLinks(handle) {
5456
5733
  } while (cursor !== void 0);
5457
5734
  return rows;
5458
5735
  }
5459
- function membershipOf(rows) {
5460
- const out = {};
5461
- for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
5462
- return out;
5463
- }
5464
- /**
5465
- * The in-memory copy of the records. This addon is their only writer, so the copy is the
5466
- * synchronous read the event path needs; a write lands in the record first and in the copy
5467
- * after it. `membership()` keeps its identity until a device list changes.
5468
- */
5469
- var ExportLinks = class ExportLinks {
5470
- handle;
5471
- rows;
5472
- memberships;
5473
- constructor(handle, rows) {
5474
- this.handle = handle;
5475
- this.rows = rows;
5476
- this.memberships = membershipOf(rows);
5477
- }
5478
- static async load(handle) {
5479
- return new ExportLinks(handle, await readAllLinks(handle));
5480
- }
5481
- isEnabled(brokerId) {
5482
- return this.rows.get(brokerId)?.exportTo === true;
5483
- }
5484
- mediaRole(brokerId) {
5485
- return this.rows.get(brokerId)?.mediaRole ?? "publicEndpoint";
5486
- }
5487
- /** brokerId → exported device ids, for the brokers that export at least one. */
5488
- membership() {
5489
- return this.memberships;
5490
- }
5491
- brokerIds() {
5492
- return [...this.rows.keys()];
5493
- }
5494
- /** Change the switch and / or the role of one instance; creates the record on first use. */
5495
- async setFlags(brokerId, name, patch) {
5496
- const current = this.rows.get(brokerId) ?? defaultLinks(name);
5497
- const next = {
5498
- ...current,
5499
- exportTo: patch.exportTo ?? current.exportTo,
5500
- mediaRole: patch.mediaRole ?? current.mediaRole
5501
- };
5502
- if (this.rows.has(brokerId) && sameLinks(current, next)) return;
5503
- await this.write(brokerId, next);
5504
- }
5505
- /** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
5506
- async setMembership(next, nameOf) {
5507
- for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
5508
- const devices = next[brokerId] ?? [];
5509
- const current = this.rows.get(brokerId);
5510
- if (sameList(current?.devices ?? [], devices)) continue;
5511
- await this.write(brokerId, {
5512
- ...current ?? defaultLinks(nameOf(brokerId)),
5513
- devices: [...devices]
5514
- });
5515
- }
5516
- }
5517
- /** Forget the instances that no longer exist; returns the ids it removed. */
5518
- async prune(knownBrokerIds) {
5519
- const known = new Set(knownBrokerIds);
5520
- const gone = [...this.rows.keys()].filter((id) => !known.has(id));
5521
- for (const id of gone) await this.handle.delete(id);
5522
- if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
5523
- return gone;
5524
- }
5525
- async write(brokerId, body) {
5526
- await this.handle.put({
5527
- id: brokerId,
5528
- data: body
5529
- });
5530
- this.adopt(new Map(this.rows).set(brokerId, body));
5531
- }
5532
- adopt(rows) {
5533
- const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
5534
- this.rows = rows;
5535
- if (devicesChanged) this.memberships = membershipOf(rows);
5536
- }
5537
- };
5538
- function sameList(a, b) {
5539
- return a.length === b.length && a.every((value, index) => value === b[index]);
5540
- }
5541
- function sameLinks(a, b) {
5542
- return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
5543
- }
5544
- /**
5545
- * The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
5546
- * `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
5547
- * records and a write is routed to the record of the broker they name.
5548
- */
5549
- var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
5550
- var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
5551
- function parseLinkFormKey(key) {
5552
- for (const flag of ["exportTo", "mediaRole"]) {
5553
- const prefix = `${flag}:`;
5554
- if (key.startsWith(prefix) && key.length > prefix.length) return {
5555
- flag,
5556
- brokerId: key.slice(prefix.length)
5557
- };
5558
- }
5559
- return null;
5560
- }
5561
- /** The form's dynamic keys carrying each instance's own value. */
5562
- function linkFormOverlay(links) {
5563
- const overlay = {};
5564
- for (const brokerId of links.brokerIds()) {
5565
- overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
5566
- overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
5567
- }
5568
- return overlay;
5569
- }
5570
5736
  //#endregion
5571
5737
  //#region src/ha-export/migrations/bind-ports.ts
5572
5738
  /**
5573
5739
  * Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
5574
- * `integrations` capability (the old home of the two flags, read by the migrations only) and the
5575
- * `ha-export-links` kind.
5740
+ * `integrations` capability (the old home of the two flags, read by the migrations only), the
5741
+ * retired `ha-export-links` kind and the `ha-export-targets` kind the links move into.
5576
5742
  */
5577
5743
  function haExportMigrationPorts(deps) {
5578
5744
  return {
@@ -5592,10 +5758,118 @@ function haExportMigrationPorts(deps) {
5592
5758
  }
5593
5759
  return "created";
5594
5760
  },
5595
- readLinks: () => readAllLinks(deps.links)
5761
+ readLinks: () => readAllLinks(deps.links),
5762
+ readTargets: () => readAllTargets(deps.targets),
5763
+ putTarget: async (id, body) => {
5764
+ try {
5765
+ await deps.targets.put({
5766
+ id,
5767
+ data: body
5768
+ }, { ifRev: 0 });
5769
+ } catch (err) {
5770
+ if (err instanceof RevConflictError) return "exists";
5771
+ throw err;
5772
+ }
5773
+ return "created";
5774
+ },
5775
+ deleteLinks: async (brokerIds) => {
5776
+ await deps.links.deleteMany(brokerIds);
5777
+ },
5778
+ clearSettingKeys: deps.clearSettingKeys
5596
5779
  };
5597
5780
  }
5598
5781
  //#endregion
5782
+ //#region src/ha-export/target-form.ts
5783
+ var NS = "provider-homeassistant:globalSettings";
5784
+ function buildTargetForm() {
5785
+ return { sections: [{
5786
+ id: "ha-export-target",
5787
+ title: "Home Assistant target",
5788
+ titleKey: `${NS}.s_ha-export-target.title`,
5789
+ 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).",
5790
+ descriptionKey: `${NS}.s_ha-export-target.description`,
5791
+ columns: 1,
5792
+ fields: [
5793
+ {
5794
+ type: "text",
5795
+ key: "name",
5796
+ label: "Name",
5797
+ labelKey: `${NS}.s_ha-export-target.name.label`
5798
+ },
5799
+ {
5800
+ type: "broker-select",
5801
+ key: "connectionId",
5802
+ label: "Home Assistant connection",
5803
+ labelKey: `${NS}.s_ha-export-target.connectionId.label`,
5804
+ description: "The Home Assistant instance this target pushes to.",
5805
+ descriptionKey: `${NS}.s_ha-export-target.connectionId.description`,
5806
+ brokerKind: "home-assistant"
5807
+ },
5808
+ {
5809
+ type: "select",
5810
+ key: "mediaRole",
5811
+ label: "Image links",
5812
+ labelKey: `${NS}.s_ha-export-target.mediaRole.label`,
5813
+ 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.",
5814
+ descriptionKey: `${NS}.s_ha-export-target.mediaRole.description`,
5815
+ options: [{
5816
+ value: "internal",
5817
+ label: "Internal (LAN address)"
5818
+ }, {
5819
+ value: "publicEndpoint",
5820
+ label: "Public endpoint (reachable from outside)"
5821
+ }],
5822
+ default: "publicEndpoint"
5823
+ }
5824
+ ]
5825
+ }, {
5826
+ id: "ha-export-advanced",
5827
+ title: "Advanced",
5828
+ titleKey: `${NS}.s_ha-export-advanced.title`,
5829
+ style: "accordion",
5830
+ defaultCollapsed: true,
5831
+ columns: 1,
5832
+ fields: [{
5833
+ type: "number",
5834
+ key: "reconcileIntervalSec",
5835
+ label: "Reconcile interval (s)",
5836
+ labelKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.label`,
5837
+ description: `Full re-announce and re-push cadence; at least 30 s.`,
5838
+ descriptionKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.description`,
5839
+ default: 300
5840
+ }]
5841
+ }] };
5842
+ }
5843
+ //#endregion
5844
+ //#region src/ha-export/target-instances.ts
5845
+ /**
5846
+ * The export targets as `integration-instances` (role `services`, D996): the operator adds,
5847
+ * edits, switches and deletes targets from the Connectivity page. A target's id is MINTED
5848
+ * (`hae_001`, ...) and names its Home Assistant connection as a reference, so several targets
5849
+ * may push to one Home Assistant.
5850
+ */
5851
+ function openTargetInstances(records, deps) {
5852
+ return new RecordIntegrationInstances(records, haExportTargetsKind, {
5853
+ description: {
5854
+ role: "services",
5855
+ instanceMode: "multiple",
5856
+ kind: "target",
5857
+ canTest: false,
5858
+ kinds: [{
5859
+ id: HA_TARGET_KIND,
5860
+ label: "Home Assistant"
5861
+ }]
5862
+ },
5863
+ formSchema: () => buildTargetForm(),
5864
+ initialBody: (name) => newTargetBody(name),
5865
+ mintId: () => nextTargetId(deps.book().ids()),
5866
+ kindOf: () => HA_TARGET_KIND,
5867
+ health: (body, id) => ({ status: deps.health(id, body) }),
5868
+ configured: (body) => deps.connectionExists(body.connectionId),
5869
+ onChanged: deps.onChanged
5870
+ });
5871
+ }
5872
+ //#endregion
5599
5873
  //#region src/ha-export/membership.ts
5600
5874
  function devicesForBroker(membership, brokerId) {
5601
5875
  return membership[brokerId] ?? [];
@@ -5622,14 +5896,6 @@ function setExposed(membership, brokerId, deviceId, exposed) {
5622
5896
  [brokerId]: next
5623
5897
  };
5624
5898
  }
5625
- /** Same brokers, same order, same names — nothing for the form to re-render. */
5626
- function sameBrokers(a, b) {
5627
- if (a.length !== b.length) return false;
5628
- return a.every((broker, index) => {
5629
- const other = b[index];
5630
- return other !== void 0 && other.id === broker.id && other.name === broker.name;
5631
- });
5632
- }
5633
5899
  //#endregion
5634
5900
  //#region src/ha-export/node-reads.ts
5635
5901
  var UNREAD = {
@@ -7683,16 +7949,15 @@ var PTZ_MOVE_BY_DIRECTION = {
7683
7949
  zoom_in: { zoom: .1 },
7684
7950
  zoom_out: { zoom: -.1 }
7685
7951
  };
7686
- var DEFAULT_CONFIG = {
7687
- reconcileIntervalSec: 300,
7688
- knownBrokers: [],
7689
- exportedEntities: {
7690
- actions: [],
7691
- targets: []
7692
- }
7693
- };
7952
+ var DEFAULT_CONFIG = { exportedEntities: {
7953
+ actions: [],
7954
+ targets: []
7955
+ } };
7694
7956
  var HaExportAddon = class extends BaseAddon {
7695
7957
  links = /* @__PURE__ */ new Map();
7958
+ instances = null;
7959
+ /** The Home Assistant connections (broker ids) the last `broker.list` answered. */
7960
+ connections = /* @__PURE__ */ new Set();
7696
7961
  /** deviceKey → the device it addresses. Rebuilt on every reconcile. */
7697
7962
  exported = /* @__PURE__ */ new Map();
7698
7963
  /** numeric deviceId → deviceKey, for the event path. */
@@ -7799,15 +8064,26 @@ var HaExportAddon = class extends BaseAddon {
7799
8064
  async onInitialize() {
7800
8065
  const records = this.ctx.records;
7801
8066
  if (records === void 0) throw new Error("ha-export: records are not available");
7802
- await records.declare({ "ha-export-links": haExportLinksKind });
7803
- const linksHandle = records.kind(haExportLinksKind);
8067
+ await records.declare({ "ha-export-targets": haExportTargetsKind });
8068
+ const targetsHandle = records.kind(haExportTargetsKind);
8069
+ const legacyLinks = await declareLegacyLinks(records);
7804
8070
  await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
7805
8071
  readStored: async () => this.resolveGlobalStore(),
7806
8072
  api: this.ctx.api,
7807
- links: linksHandle,
8073
+ links: legacyLinks,
8074
+ targets: targetsHandle,
8075
+ clearSettingKeys: async (keys) => {
8076
+ await this.updateGlobalSettingsAsOperator(Object.fromEntries(keys.map((key) => [key, null])));
8077
+ },
7808
8078
  warn: (message) => this.ctx.logger.warn(message)
7809
8079
  })));
7810
- this.linkBook = await ExportLinks.load(linksHandle);
8080
+ this.targetBook = await ExportTargets.load(targetsHandle);
8081
+ this.instances = openTargetInstances(records, {
8082
+ book: () => this.book(),
8083
+ connectionExists: (connectionId) => this.connectionExists(connectionId),
8084
+ health: (id, body) => this.targetHealth(id, body),
8085
+ onChanged: (id, change) => this.onTargetChanged(id, change)
8086
+ });
7811
8087
  const secret = this.state("haExportMediaSecret", string(), "");
7812
8088
  const existing = await secret.get();
7813
8089
  if (existing.length === 0) {
@@ -7843,27 +8119,27 @@ var HaExportAddon = class extends BaseAddon {
7843
8119
  {
7844
8120
  capability: deviceExportCapability,
7845
8121
  provider: {
7846
- getStatus: async (input) => this.buildStatus(this.brokerScope(statusInstanceOf(input))),
7847
- listInstances: async () => this.config.knownBrokers.map((broker) => ({
7848
- instanceId: broker.id,
7849
- name: broker.name,
7850
- enabled: this.enabledBrokerIds().includes(broker.id),
7851
- kind: "home-assistant"
8122
+ getStatus: async (input) => this.buildStatus(this.targetScope(statusInstanceOf(input))),
8123
+ listInstances: async () => this.book().ids().map((id) => ({
8124
+ instanceId: id,
8125
+ name: this.book().name(id),
8126
+ enabled: this.book().isEnabled(id),
8127
+ kind: HA_TARGET_KIND
7852
8128
  })),
7853
8129
  listSupportedDeviceKinds: async ({ instanceId }) => {
7854
- this.brokerScope(instanceId);
8130
+ this.targetScope(instanceId);
7855
8131
  return [...SUPPORTED_DEVICE_KINDS];
7856
8132
  },
7857
- listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
7858
- exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
7859
- unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
8133
+ listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.targetScope(instanceId)),
8134
+ exposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, true),
8135
+ unexposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, false),
7860
8136
  listSupportedEntityKinds: async () => [
7861
8137
  "device",
7862
8138
  "action",
7863
8139
  "notifier"
7864
8140
  ],
7865
8141
  listExposedEntities: async ({ instanceId }) => {
7866
- const scope = this.brokerScope(instanceId);
8142
+ const scope = this.targetScope(instanceId);
7867
8143
  await exportedEntities.pruneDeleted();
7868
8144
  return [...this.exposedDeviceRows(scope).map((row) => ({
7869
8145
  entity: {
@@ -7874,13 +8150,13 @@ var HaExportAddon = class extends BaseAddon {
7874
8150
  })), ...exportedEntities.list()];
7875
8151
  },
7876
8152
  exposeEntity: async ({ instanceId, entity }) => {
7877
- if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
7878
- this.brokerScope(instanceId);
8153
+ if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, true);
8154
+ this.targetScope(instanceId);
7879
8155
  return exportedEntities.expose(entity);
7880
8156
  },
7881
8157
  unexposeEntity: async ({ instanceId, entity }) => {
7882
- if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
7883
- this.brokerScope(instanceId);
8158
+ if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, false);
8159
+ this.targetScope(instanceId);
7884
8160
  return exportedEntities.unexpose(entity);
7885
8161
  },
7886
8162
  getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
@@ -7888,6 +8164,10 @@ var HaExportAddon = class extends BaseAddon {
7888
8164
  applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
7889
8165
  }
7890
8166
  },
8167
+ {
8168
+ capability: integrationInstancesCapability,
8169
+ provider: this.requireInstances().toProvider()
8170
+ },
7891
8171
  {
7892
8172
  capability: addonRoutesCapability,
7893
8173
  provider: buildAddonRouteProvider(ADDON_ID, [{
@@ -7906,53 +8186,28 @@ var HaExportAddon = class extends BaseAddon {
7906
8186
  }
7907
8187
  ];
7908
8188
  }
7909
- /**
7910
- * The form of the addon shows each instance's own switch and media role. They are not settings
7911
- * of this addon: the values come from the instance's `ha-export-links` record.
7912
- */
7913
- async getGlobalSettings(overlay, cap, nodeId) {
7914
- return super.getGlobalSettings({
7915
- ...overlay,
7916
- ...linkFormOverlay(this.book())
7917
- }, cap, nodeId);
8189
+ async onConfigChanged() {
8190
+ if (this.targetBook === null) return;
8191
+ for (const link of this.links.values()) link.client.resetBackoff();
8192
+ await this.reconcile("config-changed");
8193
+ this.startTimer();
7918
8194
  }
7919
8195
  /**
7920
- * An operator write: the `exportTo:` / `mediaRole:` keys go to the record of their broker,
7921
- * everything else to the addon store as before. A key naming a broker this addon does not know
7922
- * is refused, never parked.
8196
+ * A target was created, edited, switched or deleted through `integration-instances`: reload
8197
+ * the records, then apply them (a new connection opens, a removed one closes, a changed
8198
+ * cadence restarts the timer). A deleted target's devices simply stop being exported.
7923
8199
  */
7924
- async updateGlobalSettingsAsOperator(patch, nodeId) {
7925
- const rest = {};
7926
- const flags = /* @__PURE__ */ new Map();
7927
- for (const [key, value] of Object.entries(patch)) {
7928
- const field = parseLinkFormKey(key);
7929
- if (field === null) {
7930
- rest[key] = value;
7931
- continue;
7932
- }
7933
- this.brokerScope(field.brokerId);
7934
- const entry = flags.get(field.brokerId) ?? {};
7935
- if (field.flag === "exportTo") {
7936
- if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
7937
- entry.exportTo = value;
7938
- } else {
7939
- const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
7940
- if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
7941
- entry.mediaRole = role;
7942
- }
7943
- flags.set(field.brokerId, entry);
7944
- }
7945
- for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
7946
- if (Object.keys(rest).length > 0 || flags.size === 0) {
7947
- await super.updateGlobalSettingsAsOperator(rest, nodeId);
7948
- return;
7949
- }
8200
+ async onTargetChanged(id, change) {
8201
+ await this.book().reload();
8202
+ this.ctx.logger.info("ha-export: an export target changed", { meta: {
8203
+ targetId: id,
8204
+ change
8205
+ } });
7950
8206
  await this.onConfigChanged();
7951
8207
  }
7952
- async onConfigChanged() {
7953
- for (const link of this.links.values()) link.client.resetBackoff();
7954
- await this.reconcile("config-changed");
7955
- this.startTimer();
8208
+ requireInstances() {
8209
+ if (this.instances === null) throw new Error("ha-export: the target instances are not open");
8210
+ return this.instances;
7956
8211
  }
7957
8212
  wireListeners() {
7958
8213
  this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
@@ -8445,7 +8700,7 @@ var HaExportAddon = class extends BaseAddon {
8445
8700
  const fullResync = this.resync.take(reason);
8446
8701
  let readsHeld = false;
8447
8702
  await this.refreshComponentSupport();
8448
- const enabled = this.enabledBrokerIds();
8703
+ const enabled = this.enabledTargetIds();
8449
8704
  const devices = await this.ctx.api.deviceManager.listAll.query({});
8450
8705
  const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
8451
8706
  const byId = new Map(devices.map((device) => [device.id, device]));
@@ -9391,71 +9646,63 @@ var HaExportAddon = class extends BaseAddon {
9391
9646
  */
9392
9647
  async refreshBrokers() {
9393
9648
  const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
9394
- const known = haBrokers.map((broker) => ({
9395
- id: broker.id,
9396
- name: broker.name
9397
- }));
9398
9649
  /**
9399
- * NEVER prune on an empty enumeration.
9650
+ * NEVER treat an empty enumeration as "the connections are gone".
9400
9651
  *
9401
- * `broker.list` is a fan-out union, so it answers `[]` for the whole window
9402
- * in which `provider-homeassistant` has not finished registering — and it
9403
- * registers its caps only at the END of an `initialize()` that restores 239
9404
- * devices. An empty list there means "I do not know yet", never "the
9405
- * operator deleted their instance", and the two were indistinguishable to
9406
- * this code: every boot pruned the membership to nothing and PERSISTED it,
9407
- * so the operator's exported cameras silently switched themselves off. Seen
9408
- * live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
9409
- * `devices=2` and both switches back to `false` with no line anywhere.
9410
- *
9411
- * This is D49 exactly: a destructive step gated on a fallible read must not
9412
- * fire on the reading that destroys work. A broker that really was deleted
9413
- * is pruned on the next pass that returns a NON-empty list without it,
9414
- * which costs one interval and cannot cost the operator their config.
9652
+ * `broker.list` is a fan-out union, so it answers `[]` for the whole window in which
9653
+ * `provider-homeassistant` has not finished registering. An empty list there means "I do not
9654
+ * know yet", never "the operator deleted their instance". A target whose connection is not in
9655
+ * the list is UNCONFIGURED for this pass: it pushes nothing, and its devices stay in its
9656
+ * record, so the next pass that sees the connection resumes them. Nothing is ever pruned or
9657
+ * unexposed from here (D49; 2026-08-09, 2026-08-24).
9415
9658
  */
9416
- if (haBrokers.length === 0) {
9417
- 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() } });
9418
- } else {
9419
- const gone = await this.book().prune(haBrokers.map((broker) => broker.id));
9420
- if (gone.length > 0) this.ctx.logger.info("ha-export: forgot the export links of removed instances", { meta: { brokers: gone } });
9659
+ this.connections = new Set(haBrokers.map((broker) => broker.id));
9660
+ this.refreshEnabledTargets();
9661
+ for (const id of this.book().ids()) {
9662
+ const target = this.book().get(id);
9663
+ if (target === void 0 || !this.book().isEnabled(id)) continue;
9664
+ 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: {
9665
+ targetId: id,
9666
+ connectionId: target.connectionId
9667
+ } });
9421
9668
  }
9422
- /**
9423
- * Only the roster is a setting; the membership is not, so a boot-window read that answers
9424
- * empty can no longer erase it (2026-08-24: a refresh wrote `membership: {}` over the
9425
- * operator's export list). The records are read at boot and a failed read stops it.
9426
- */
9427
- if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
9428
- this.refreshEnabledBrokers(known);
9429
- const wanted = new Set(this.enabledBrokerIds());
9430
- for (const [brokerId, link] of this.links) {
9431
- if (wanted.has(brokerId)) continue;
9669
+ const wanted = new Set(this.enabledTargetIds());
9670
+ for (const [targetId, link] of this.links) {
9671
+ if (wanted.has(targetId) && link.connectionId === this.book().connectionId(targetId)) continue;
9432
9672
  await link.client.dispose();
9433
- this.links.delete(brokerId);
9434
- this.componentSupport.forget(brokerId);
9435
- this.ctx.logger.info("ha-export: stopped exporting to a broker", { meta: { brokerId } });
9436
- }
9437
- for (const broker of haBrokers) {
9438
- if (!wanted.has(broker.id)) continue;
9439
- if (this.links.has(broker.id)) continue;
9440
- const resolved = await this.resolveBrokerConnection(broker.id, broker.addonId);
9673
+ this.links.delete(targetId);
9674
+ this.componentSupport.forget(targetId);
9675
+ this.ctx.logger.info("ha-export: stopped exporting to a target", { meta: {
9676
+ targetId,
9677
+ connectionId: link.connectionId
9678
+ } });
9679
+ }
9680
+ for (const targetId of wanted) {
9681
+ if (this.links.has(targetId)) continue;
9682
+ const connectionId = this.book().connectionId(targetId);
9683
+ const broker = haBrokers.find((candidate) => candidate.id === connectionId);
9684
+ if (broker === void 0) continue;
9685
+ const resolved = await this.resolveBrokerConnection(connectionId, broker.addonId);
9441
9686
  if (resolved === null) continue;
9442
9687
  const client = new PushClient({
9443
9688
  transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
9444
9689
  logger: this.ctx.logger,
9445
- brokerId: broker.id,
9446
- onLinkRestored: (brokerId) => this.reachability.request(brokerId)
9690
+ brokerId: targetId,
9691
+ onLinkRestored: (restoredId) => this.reachability.request(restoredId)
9447
9692
  });
9448
9693
  client.start();
9449
9694
  this.resync.markGap("broker-connected");
9450
- this.links.set(broker.id, {
9451
- brokerId: broker.id,
9452
- name: broker.name,
9695
+ this.links.set(targetId, {
9696
+ brokerId: targetId,
9697
+ connectionId,
9698
+ name: this.book().name(targetId),
9453
9699
  baseUrl: resolved.baseUrl,
9454
9700
  token: resolved.token,
9455
9701
  client
9456
9702
  });
9457
9703
  this.ctx.logger.info("ha-export: exporting to Home Assistant", { meta: {
9458
- brokerId: broker.id,
9704
+ targetId,
9705
+ connectionId,
9459
9706
  baseUrl: resolved.baseUrl
9460
9707
  } });
9461
9708
  }
@@ -9569,35 +9816,62 @@ var HaExportAddon = class extends BaseAddon {
9569
9816
  }
9570
9817
  }
9571
9818
  /**
9572
- * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
9573
- *
9574
- * NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
9819
+ * The targets that push, as of the last {@link refreshEnabledTargets}: switched on AND with a
9820
+ * connection the hub knows. NOT read from `this.config`: the switch is a fact of the target's
9575
9821
  * record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
9576
9822
  * and before the per-device panel renders, never written to.
9577
9823
  */
9578
9824
  enabled = [];
9579
- /** The `ha-export-links` records, loaded at boot after the migrations. */
9580
- linkBook = null;
9825
+ /** The `ha-export-targets` records, loaded at boot after the migrations. */
9826
+ targetBook = null;
9581
9827
  book() {
9582
- if (this.linkBook === null) throw new Error("ha-export: the export links are not loaded yet");
9583
- return this.linkBook;
9828
+ if (this.targetBook === null) throw new Error("ha-export: the export targets are not loaded yet");
9829
+ return this.targetBook;
9584
9830
  }
9585
- /** brokerId → exported device ids, from the records. */
9831
+ /** targetId → exported device ids, from the records. */
9586
9832
  membership() {
9587
9833
  return this.book().membership();
9588
9834
  }
9589
- /** Persist a membership change: each instance's record first, the in-memory copy after it. */
9835
+ /** Persist a membership change: each target's record first, the in-memory copy after it. */
9590
9836
  async persistMembership(next) {
9591
- await this.book().setMembership(next, (brokerId) => this.brokerName(brokerId));
9837
+ await this.book().setMembership(next);
9592
9838
  }
9593
- refreshEnabledBrokers(known) {
9594
- this.enabled = known.map((broker) => broker.id).filter((id) => this.book().isEnabled(id));
9839
+ connectionExists(connectionId) {
9840
+ return connectionId.length > 0 && this.connections.has(connectionId);
9595
9841
  }
9596
- enabledBrokerIds() {
9842
+ refreshEnabledTargets() {
9843
+ this.enabled = this.book().ids().filter((id) => this.book().isEnabled(id) && this.connectionExists(this.book().connectionId(id)));
9844
+ }
9845
+ enabledTargetIds() {
9597
9846
  return this.enabled;
9598
9847
  }
9599
- brokerName(brokerId) {
9600
- return this.config.knownBrokers.find((broker) => broker.id === brokerId)?.name ?? brokerId;
9848
+ targetName(targetId) {
9849
+ return this.book().name(targetId);
9850
+ }
9851
+ /** The Status column of the instances table: why a target is, or is not, pushing. */
9852
+ targetHealth(id, body) {
9853
+ if (body.enabled === false) return {
9854
+ state: "off",
9855
+ label: "Off"
9856
+ };
9857
+ if (!this.connectionExists(body.connectionId)) return {
9858
+ state: "error",
9859
+ label: "Unconfigured",
9860
+ detail: body.connectionId.length === 0 ? "Pick the Home Assistant connection this target pushes to" : "The Home Assistant connection of this target does not exist"
9861
+ };
9862
+ const link = this.links.get(id);
9863
+ if (link === void 0) return {
9864
+ state: "unknown",
9865
+ label: "Not connected yet"
9866
+ };
9867
+ return link.client.healthy ? {
9868
+ state: "ok",
9869
+ label: "Connected"
9870
+ } : {
9871
+ state: "error",
9872
+ label: "Unreachable",
9873
+ detail: link.client.failure?.text ?? "unreachable"
9874
+ };
9601
9875
  }
9602
9876
  async handleCommandRoute(body, reply) {
9603
9877
  const topic = readString(body, "topic");
@@ -9985,7 +10259,7 @@ var HaExportAddon = class extends BaseAddon {
9985
10259
  }
9986
10260
  /**
9987
10261
  * Each broker's media base, read per use: the palette's role the broker chose
9988
- * (the `ha-export-links` record's `mediaRole`, `publicEndpoint` by default, internal when none is set). A role that resolves to nothing
10262
+ * (the export target record's `mediaRole`, `publicEndpoint` by default, internal when none is set). A role that resolves to nothing
9989
10263
  * is `null` and says so — the image entities then carry no value, which reads as
9990
10264
  * empty rather than as a broken link.
9991
10265
  */
@@ -10020,27 +10294,26 @@ var HaExportAddon = class extends BaseAddon {
10020
10294
  return bases;
10021
10295
  }
10022
10296
  /**
10023
- * Every broker this addon knows, enabled or not: a membership write may
10024
- * target a broker that is switched off (its devices resume when it is turned
10025
- * back on). A cold `knownBrokers` still counts the enabled ids.
10297
+ * Every target this addon holds, enabled or not: a membership write may target one that is
10298
+ * switched off or unconfigured (its devices resume when it is turned back on).
10026
10299
  */
10027
- knownBrokerIds() {
10028
- return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
10300
+ knownTargetIds() {
10301
+ return new Set(this.book().ids());
10029
10302
  }
10030
10303
  /**
10031
10304
  * The brokers a `device-export` read answers over: the named instance, or
10032
10305
  * (absent) every enabled broker. A broker this addon does not know is
10033
10306
  * refused, never answered empty.
10034
10307
  */
10035
- brokerScope(instanceId) {
10036
- if (instanceId === void 0) return this.enabledBrokerIds();
10037
- if (!this.knownBrokerIds().has(instanceId)) throw new ExportInstanceUnknownError("provider-homeassistant", instanceId);
10308
+ targetScope(instanceId) {
10309
+ if (instanceId === void 0) return this.enabledTargetIds();
10310
+ if (!this.knownTargetIds().has(instanceId)) throw new ExportInstanceUnknownError("provider-homeassistant", instanceId);
10038
10311
  return [instanceId];
10039
10312
  }
10040
10313
  exposedDeviceRows(scope) {
10041
10314
  return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
10042
10315
  deviceId,
10043
- exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
10316
+ exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.targetName(brokerId)).join(", ")
10044
10317
  }));
10045
10318
  }
10046
10319
  /**
@@ -10048,15 +10321,15 @@ var HaExportAddon = class extends BaseAddon {
10048
10321
  * instance. There is no write without a target and no "everywhere": the
10049
10322
  * other brokers' membership is not touched.
10050
10323
  */
10051
- async setBrokerMembership(instanceId, deviceId, exposed) {
10052
- this.brokerScope(instanceId);
10324
+ async setTargetMembership(instanceId, deviceId, exposed) {
10325
+ this.targetScope(instanceId);
10053
10326
  const next = setExposed(this.membership(), instanceId, deviceId, exposed);
10054
10327
  if (next === this.membership()) {
10055
10328
  this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
10056
10329
  tags: { deviceId: Number(deviceId) },
10057
10330
  meta: {
10058
10331
  exposed,
10059
- brokerId: instanceId
10332
+ targetId: instanceId
10060
10333
  }
10061
10334
  });
10062
10335
  return;
@@ -10066,93 +10339,29 @@ var HaExportAddon = class extends BaseAddon {
10066
10339
  tags: { deviceId: Number(deviceId) },
10067
10340
  meta: {
10068
10341
  exposed,
10069
- brokerId: instanceId
10342
+ targetId: instanceId
10070
10343
  }
10071
10344
  });
10072
10345
  await this.reconcile(exposed ? "expose" : "unexpose");
10073
10346
  }
10074
- globalSettingsSchema() {
10075
- const brokerFields = this.config.knownBrokers.map((broker) => ({
10076
- type: "boolean",
10077
- key: exportToFormKey(broker.id),
10078
- label: broker.name,
10079
- style: "switch",
10080
- default: false
10081
- }));
10082
- return { sections: [
10083
- {
10084
- id: "ha-export-brokers",
10085
- title: "Home Assistant instances",
10086
- titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
10087
- 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.",
10088
- columns: 1,
10089
- fields: brokerFields.length > 0 ? brokerFields : [{
10090
- type: "info",
10091
- key: "ha-export:__no-brokers",
10092
- label: "No brokers",
10093
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.ha-export___no-brokers.label",
10094
- content: "Register a Home Assistant broker to enable the export.",
10095
- variant: "warning"
10096
- }]
10097
- },
10098
- ...this.config.knownBrokers.length > 0 ? [{
10099
- id: "ha-export-transport",
10100
- title: "Connection",
10101
- titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
10102
- 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.",
10103
- columns: 1,
10104
- fields: this.config.knownBrokers.map((broker) => ({
10105
- type: "select",
10106
- key: mediaRoleFormKey(broker.id),
10107
- label: `Image links for ${broker.name}`,
10108
- options: [{
10109
- value: "internal",
10110
- label: "Internal (LAN address)"
10111
- }, {
10112
- value: "publicEndpoint",
10113
- label: "Public endpoint (reachable from outside)"
10114
- }],
10115
- default: "publicEndpoint"
10116
- }))
10117
- }] : [],
10118
- {
10119
- id: "ha-export-advanced",
10120
- title: "Advanced",
10121
- titleKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.title",
10122
- style: "accordion",
10123
- defaultCollapsed: true,
10124
- columns: 1,
10125
- fields: [{
10126
- type: "number",
10127
- key: "reconcileIntervalSec",
10128
- label: "Reconcile interval (s)",
10129
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.label",
10130
- description: "Full re-announce and re-push cadence.",
10131
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.description",
10132
- default: DEFAULT_CONFIG.reconcileIntervalSec
10133
- }]
10134
- }
10135
- ] };
10136
- }
10137
10347
  /**
10138
10348
  * Per-device Export panel — one switch PER BROKER.
10139
10349
  *
10140
- * Field keys are namespaced `haExport:<deviceId>:<brokerId>`: the
10350
+ * Field keys are namespaced `haExport:<deviceId>:<targetId>`: the
10141
10351
  * device-details form flattens fields by key across every contributing
10142
10352
  * section, so a bare `enabled` collides with the Alexa and HomeKit
10143
10353
  * exporters' identically-named fields and the operator sees another
10144
10354
  * exporter's value in this one's toggle.
10145
10355
  */
10146
10356
  async buildContribution(deviceId) {
10147
- this.refreshEnabledBrokers(this.config.knownBrokers);
10148
10357
  const idStr = String(deviceId);
10149
- const enabled = this.enabledBrokerIds();
10150
- const fields = enabled.map((brokerId) => ({
10358
+ const enabled = this.book().ids().filter((id) => this.book().isEnabled(id));
10359
+ const fields = enabled.map((targetId) => ({
10151
10360
  type: "boolean",
10152
- key: `haExport:${deviceId}:${brokerId}`,
10153
- label: `Export to ${this.brokerName(brokerId)}`,
10361
+ key: `haExport:${deviceId}:${targetId}`,
10362
+ label: `Export to ${this.targetName(targetId)}`,
10154
10363
  style: "switch",
10155
- value: isExposed(this.membership(), brokerId, idStr),
10364
+ value: isExposed(this.membership(), targetId, idStr),
10156
10365
  immediate: true
10157
10366
  }));
10158
10367
  return {
@@ -10166,7 +10375,7 @@ var HaExportAddon = class extends BaseAddon {
10166
10375
  id: "ha-export",
10167
10376
  title: "Home Assistant",
10168
10377
  titleKey: "provider-homeassistant:contribution.s_ha-export.title",
10169
- description: enabled.length > 0 ? "Each instance receives only the devices switched on for it." : "No Home Assistant instance is enabled for export.",
10378
+ description: enabled.length > 0 ? "Each target receives only the devices switched on for it." : "No Home Assistant export target is enabled.",
10170
10379
  tab: "export",
10171
10380
  columns: 1,
10172
10381
  order: 30,
@@ -10174,7 +10383,7 @@ var HaExportAddon = class extends BaseAddon {
10174
10383
  type: "info",
10175
10384
  key: `haExport:${deviceId}:__none`,
10176
10385
  label: "Not configured",
10177
- content: "Enable a Home Assistant instance in the addon settings first.",
10386
+ content: "Add an export target on the Connectivity page first.",
10178
10387
  variant: "info"
10179
10388
  }]
10180
10389
  }]
@@ -10187,10 +10396,10 @@ var HaExportAddon = class extends BaseAddon {
10187
10396
  /**
10188
10397
  * Driven by the PATCH KEYS, not by the enabled-broker list.
10189
10398
  *
10190
- * The key already carries the broker (`haExport:<deviceId>:<brokerId>`) —
10399
+ * The key already carries the broker (`haExport:<deviceId>:<targetId>`) —
10191
10400
  * the operator could not have been shown the switch otherwise — so reading
10192
10401
  * the broker from it needs no other state to be warm. Iterating
10193
- * `enabledBrokerIds()` instead made this depend on a read-through cache,
10402
+ * `enabledTargetIds()` instead made this depend on a read-through cache,
10194
10403
  * and when that cache was cold the loop matched nothing, `touched` stayed
10195
10404
  * false, and the method returned `{success: true}` HAVING DONE NOTHING.
10196
10405
  * The admin UI showed the switch flip and it silently did not persist;
@@ -10200,9 +10409,16 @@ var HaExportAddon = class extends BaseAddon {
10200
10409
  const prefix = `haExport:${deviceId}:`;
10201
10410
  for (const [key, value] of Object.entries(patch)) {
10202
10411
  if (!key.startsWith(prefix)) continue;
10203
- const brokerId = key.slice(prefix.length);
10204
- if (brokerId.length === 0) continue;
10205
- next = setExposed(next, brokerId, idStr, value === true);
10412
+ const targetId = key.slice(prefix.length);
10413
+ if (targetId.length === 0) continue;
10414
+ if (!this.book().has(targetId)) {
10415
+ this.ctx.logger.warn("ha-export: a device patch names an export target that does not exist", {
10416
+ tags: { deviceId },
10417
+ meta: { targetId }
10418
+ });
10419
+ continue;
10420
+ }
10421
+ next = setExposed(next, targetId, idStr, value === true);
10206
10422
  touched = true;
10207
10423
  }
10208
10424
  if (!touched) {
@@ -10213,9 +10429,9 @@ var HaExportAddon = class extends BaseAddon {
10213
10429
  return { success: true };
10214
10430
  }
10215
10431
  await this.persistMembership(next);
10216
- this.ctx.logger.info("ha-export: per-broker export membership changed", {
10432
+ this.ctx.logger.info("ha-export: per-target export membership changed", {
10217
10433
  tags: { deviceId },
10218
- meta: { brokers: brokersExposing(next, idStr) }
10434
+ meta: { targets: brokersExposing(next, idStr) }
10219
10435
  });
10220
10436
  await this.reconcile("device-panel");
10221
10437
  return { success: true };
@@ -10224,15 +10440,17 @@ var HaExportAddon = class extends BaseAddon {
10224
10440
  const enabled = scope;
10225
10441
  const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
10226
10442
  const healthy = links.filter((link) => link.client.healthy).length;
10227
- const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
10443
+ const failures = links.map((link) => link.client.failure).filter((failure) => failure !== null);
10444
+ const unconfigured = scope.filter((id) => this.book().isEnabled(id) && !this.connectionExists(this.book().connectionId(id)));
10445
+ 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);
10228
10446
  return {
10229
- linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10447
+ linkState: links.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10230
10448
  exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
10231
10449
  ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
10232
10450
  setup: {
10233
10451
  fields: [
10234
10452
  {
10235
- label: "Home Assistant instances",
10453
+ label: "Export targets",
10236
10454
  value: `${healthy} / ${links.length} reachable`
10237
10455
  },
10238
10456
  {
@@ -10258,7 +10476,7 @@ var HaExportAddon = class extends BaseAddon {
10258
10476
  }
10259
10477
  startTimer() {
10260
10478
  this.stopTimer();
10261
- const ms = Math.max(30, this.config.reconcileIntervalSec) * 1e3;
10479
+ const ms = this.book().reconcileIntervalSec() * 1e3;
10262
10480
  this.reconcileTimer = setInterval(() => void this.reconcile("periodic"), ms);
10263
10481
  }
10264
10482
  stopTimer() {