@camstack/addon-provider-homeassistant 1.2.654 → 1.2.656

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-Bn_Trz6p.js");
27
+ const require_ha_instance_settings = require("../ha-instance-settings-Dm0tU8iv.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -5063,133 +5063,6 @@ function verifyExpiringUrl(input) {
5063
5063
  return (0, node_crypto.timingSafeEqual)(a, b);
5064
5064
  }
5065
5065
  //#endregion
5066
- //#region src/ha-export/membership.ts
5067
- function devicesForBroker(membership, brokerId) {
5068
- return membership[brokerId] ?? [];
5069
- }
5070
- function isExposed(membership, brokerId, deviceId) {
5071
- return devicesForBroker(membership, brokerId).includes(deviceId);
5072
- }
5073
- /** Every broker a device reaches. Drives the N switches in the panel. */
5074
- function brokersExposing(membership, deviceId) {
5075
- return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
5076
- }
5077
- /** Every device exported to at least one of the given brokers. */
5078
- function exposedDeviceIds(membership, brokerIds) {
5079
- const ids = /* @__PURE__ */ new Set();
5080
- for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
5081
- return [...ids].sort();
5082
- }
5083
- function setExposed(membership, brokerId, deviceId, exposed) {
5084
- const current = devicesForBroker(membership, brokerId);
5085
- if (exposed === current.includes(deviceId)) return membership;
5086
- const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
5087
- return {
5088
- ...membership,
5089
- [brokerId]: next
5090
- };
5091
- }
5092
- /**
5093
- * Forget brokers that no longer exist.
5094
- *
5095
- * A removed HA instance must not keep a membership list that reappears if
5096
- * a broker id is ever reused — and a stale key would also inflate
5097
- * `exposedDeviceCount` with devices going nowhere.
5098
- */
5099
- function pruneBrokers(membership, knownBrokerIds) {
5100
- const known = new Set(knownBrokerIds);
5101
- const kept = Object.keys(membership).filter((brokerId) => known.has(brokerId));
5102
- if (kept.length === Object.keys(membership).length) return membership;
5103
- const next = {};
5104
- for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
5105
- return next;
5106
- }
5107
- /**
5108
- * The brokers the operator has switched ON, read from the RAW addon store.
5109
- *
5110
- * `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
5111
- * hub happens to know about — so it can never appear in the addon's
5112
- * `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
5113
- * present in the defaults ("the store can contain extra keys … without
5114
- * polluting the typed config"), so every `exportTo:*` key is dropped on the
5115
- * way into `this.config` and reading one there yields `undefined` for ever.
5116
- *
5117
- * That is not theoretical. On 2026-08-09 the switch was on
5118
- * (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
5119
- * settings FORM reads the store) and every reconcile still logged
5120
- * `brokers=0`: nothing was ever exported, the per-device Export tab rendered
5121
- * its "Not configured" card, and no operator action could have changed
5122
- * either. The store is the single authority — read it, do not mirror it into
5123
- * a typed field that would then be a second one (D62).
5124
- *
5125
- * A broker that is no longer known is not enabled, whatever the store says:
5126
- * a stale `exportTo:` key for a deleted instance must not resurrect it.
5127
- *
5128
- * The switch is a setting of the INSTANCE (D985): `perBroker` carries each broker's integration
5129
- * settings, and a broker whose integration has an `exportTo` answers from it. A broker whose
5130
- * integration has none yet — the migration has not copied it — falls back to the legacy
5131
- * `exportTo:<brokerId>` key, so the move never flips a switch mid-way.
5132
- */
5133
- function enabledBrokerIds(store, knownBrokerIds, perBroker = NO_INSTANCE_SETTINGS) {
5134
- return knownBrokerIds.filter((brokerId) => {
5135
- const own = perBroker.get(brokerId)?.["exportTo"];
5136
- return typeof own === "boolean" ? own : store[`exportTo:${brokerId}`] === true;
5137
- });
5138
- }
5139
- var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
5140
- /** Same brokers, same order, same names — nothing for the form to re-render. */
5141
- function sameBrokers(a, b) {
5142
- if (a.length !== b.length) return false;
5143
- return a.every((broker, index) => {
5144
- const other = b[index];
5145
- return other !== void 0 && other.id === broker.id && other.name === broker.name;
5146
- });
5147
- }
5148
- /**
5149
- * What a broker refresh persists — and, above all, what it must NOT.
5150
- *
5151
- * **A patch never names a key it did not change.** That reads like tidiness
5152
- * and is not: `updateGlobalSettings` is a read-modify-write over the addon
5153
- * store, so naming `membership` writes whatever `this.config.membership`
5154
- * currently holds — and `this.config` is not always the store.
5155
- * `BaseAddon.resolveConfig` falls back to the CONSTRUCTOR DEFAULTS whenever
5156
- * the settings read answers empty, and the kernel's `readAddonStore` answers
5157
- * empty for a FAILED read exactly as it does for a missing one, silently. A
5158
- * boot-window read that misses therefore gives the addon `membership: {}`,
5159
- * and the first refresh that merely re-records `knownBrokers` hands that
5160
- * empty object to the store as if the operator had unexported everything.
5161
- *
5162
- * That is not a hypothetical. On 2026-08-24 the hub restarted at 21:49; the
5163
- * export runner came up while the cap resolver was still answering
5164
- * "no provider registered" for hub-resident caps; at 21:55:13 a refresh
5165
- * found `knownBrokers` stale (`[]` vs `[ha_001]`) and wrote BOTH keys. The
5166
- * two exported cameras (104 hall, 101 front door) were erased from the
5167
- * store, `devices` fell from 4 to 2 (the two synthetic devices), and Home
5168
- * Assistant kept every camera entity as a restored/unavailable orphan for
5169
- * three days: no stream ("the hub does not export this camera"), no
5170
- * doorbell, no camera state. Nothing logged it, because from the store's
5171
- * point of view the addon simply asked for `{}`.
5172
- *
5173
- * With the keys split, that pass writes `knownBrokers` alone, the
5174
- * `resolveConfig` that follows the write re-reads a store that still holds
5175
- * the membership, and the next reconcile exports both cameras again.
5176
- *
5177
- * Identity, not deep equality, decides `membership`: `pruneBrokers` returns
5178
- * the SAME reference when it removed nothing, which is precisely the
5179
- * "nothing changed" signal this needs.
5180
- *
5181
- * @returns the patch to persist, or `null` when there is nothing to write.
5182
- */
5183
- function brokerRefreshPatch(current, next) {
5184
- const brokersChanged = !sameBrokers(next.knownBrokers, current.knownBrokers);
5185
- const membershipChanged = next.membership !== current.membership;
5186
- if (!brokersChanged && !membershipChanged) return null;
5187
- return {
5188
- ...brokersChanged ? { knownBrokers: next.knownBrokers } : {},
5189
- ...membershipChanged ? { membership: next.membership } : {}
5190
- };
5191
- }
5192
- //#endregion
5193
5066
  //#region src/ha-export/media-url.ts
5194
5067
  /**
5195
5068
  * Signed, expiring media links.
@@ -5216,19 +5089,6 @@ function brokerRefreshPatch(current, next) {
5216
5089
  */
5217
5090
  /** The hub address roles a Home Assistant media link may be minted on. */
5218
5091
  var HA_MEDIA_ROLES = ["internal", "publicEndpoint"];
5219
- /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5220
- function mediaRoleKey(brokerId) {
5221
- return `mediaRole:${brokerId}`;
5222
- }
5223
- /**
5224
- * A broker's chosen role; anything stored that is not a known role is `internal`. The role is a
5225
- * setting of the INSTANCE (D985): the integration's `mediaRole` answers when it has one, and a
5226
- * broker the migration has not reached falls back to the legacy `mediaRole:<brokerId>` key.
5227
- */
5228
- function haMediaRoleOf(store, brokerId, perBroker = NO_INSTANCE_SETTINGS) {
5229
- const own = perBroker.get(brokerId)?.["mediaRole"];
5230
- return (own !== void 0 ? own : store[mediaRoleKey(brokerId)]) === "publicEndpoint" ? "publicEndpoint" : "internal";
5231
- }
5232
5092
  /**
5233
5093
  * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5234
5094
  * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
@@ -5239,8 +5099,8 @@ function haMediaBaseUrl(book, role = "internal") {
5239
5099
  return book.roles[role].resolved;
5240
5100
  }
5241
5101
  /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5242
- function mediaBasesByBroker(store, brokerIds, book, perBroker = NO_INSTANCE_SETTINGS) {
5243
- return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId, perBroker))]));
5102
+ function mediaBasesByBroker(brokerIds, book, roleOf) {
5103
+ return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, roleOf(brokerId))]));
5244
5104
  }
5245
5105
  /** How long a minted link stays valid. Long enough for HA to render it. */
5246
5106
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
@@ -5371,7 +5231,139 @@ async function planDropPerBrokerFlags(ports) {
5371
5231
  }] };
5372
5232
  }
5373
5233
  //#endregion
5234
+ //#region src/ha-export/migrations/links-to-record.ts
5235
+ /**
5236
+ * What the export carries to a Home Assistant instance moves into the instance's
5237
+ * `ha-export-links` record (planning#408 P4). Two migrations, both `block-owner`: the addon must
5238
+ * not serve a membership it cannot trust (an empty list unexposes every device on the next
5239
+ * reconcile), and after them nothing reads the old shape.
5240
+ *
5241
+ * The old shape, per broker: the `membership` setting (device ids), and the two flags, each kept
5242
+ * on the instance's integration (`exportTo`, `mediaRole`) or, for a broker the 2026-11-03
5243
+ * migration had not reached, in the dynamic addon keys `exportTo:<brokerId>` /
5244
+ * `mediaRole:<brokerId>`. The integration's value won over the key; so it does here.
5245
+ *
5246
+ * 1. `moveLinksToRecords` (procedure): one record per broker, id = broker id, written with
5247
+ * `ifRev: 0` (an existing record is never overwritten), then read BACK and compared; a
5248
+ * mismatch throws and nothing is deleted.
5249
+ * 2. `planDropMovedLinks` (plan): the addon keys are deleted only while every record holds what
5250
+ * they carry; a missing or short record refuses, a read that fails leaves them in place. The
5251
+ * `exportTo` / `mediaRole` values on the integrations are the provider's instance data and
5252
+ * are not touched here; nothing in this addon reads them any more.
5253
+ */
5254
+ var MEMBERSHIP_KEY = "membership";
5255
+ var EXPORT_PREFIX = "exportTo:";
5256
+ var ROLE_PREFIX = "mediaRole:";
5257
+ var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
5258
+ function membershipOf$1(value) {
5259
+ const out = /* @__PURE__ */ new Map();
5260
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
5261
+ for (const [brokerId, devices] of Object.entries(value)) {
5262
+ if (!Array.isArray(devices)) continue;
5263
+ out.set(brokerId, devices.filter((device) => typeof device === "string"));
5264
+ }
5265
+ return out;
5266
+ }
5267
+ function integrationByBroker(rows, warn) {
5268
+ const out = /* @__PURE__ */ new Map();
5269
+ for (const row of [...rows].sort((a, b) => a.integrationId.localeCompare(b.integrationId))) {
5270
+ if (row.brokerId === null) continue;
5271
+ if (out.has(row.brokerId)) {
5272
+ warn(`homeassistant-export: integrations ${out.get(row.brokerId)?.integrationId} and ${row.integrationId} both link broker ${row.brokerId}; the first one answers`);
5273
+ continue;
5274
+ }
5275
+ out.set(row.brokerId, row);
5276
+ }
5277
+ return out;
5278
+ }
5279
+ /** Every broker the old shape has something for, with the value the old reader would have used. */
5280
+ function legacyLinks(stored, rows, warn) {
5281
+ const membership = membershipOf$1(stored[MEMBERSHIP_KEY]);
5282
+ const integrations = integrationByBroker(rows, warn);
5283
+ const brokers = new Set(membership.keys());
5284
+ for (const key of Object.keys(stored)) for (const prefix of [EXPORT_PREFIX, ROLE_PREFIX]) if (key.startsWith(prefix) && key.length > prefix.length) brokers.add(key.slice(prefix.length));
5285
+ for (const [brokerId, row] of integrations) if ("exportTo" in row.settings || "mediaRole" in row.settings) brokers.add(brokerId);
5286
+ return [...brokers].sort().map((brokerId) => {
5287
+ const own = integrations.get(brokerId)?.settings;
5288
+ const ownExport = own?.["exportTo"];
5289
+ const ownRole = own?.["mediaRole"];
5290
+ return {
5291
+ brokerId,
5292
+ exportTo: typeof ownExport === "boolean" ? ownExport : stored[`${EXPORT_PREFIX}${brokerId}`] === true,
5293
+ mediaRole: roleOf(ownRole !== void 0 ? ownRole : stored[`${ROLE_PREFIX}${brokerId}`]) ?? "internal",
5294
+ devices: membership.get(brokerId) ?? []
5295
+ };
5296
+ });
5297
+ }
5298
+ /** The addon keys the old shape lives in. */
5299
+ function legacyKeys(stored) {
5300
+ return Object.keys(stored).filter((key) => key === "membership" || key.startsWith(EXPORT_PREFIX) && key.length > 9 || key.startsWith(ROLE_PREFIX) && key.length > 10);
5301
+ }
5302
+ var sameDevices = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
5303
+ async function moveLinksToRecords(ports) {
5304
+ const links = legacyLinks(await ports.readStored(), await ports.listInstances(), ports.warn);
5305
+ if (links.length === 0) return {
5306
+ outcome: "already-done",
5307
+ summary: { moved: 0 }
5308
+ };
5309
+ const created = [];
5310
+ for (const link of links) {
5311
+ const body = {
5312
+ name: link.brokerId,
5313
+ exportTo: link.exportTo,
5314
+ mediaRole: link.mediaRole,
5315
+ devices: link.devices
5316
+ };
5317
+ if (await ports.putLinks(link.brokerId, body) === "created") created.push(link);
5318
+ }
5319
+ if (created.length === 0) return {
5320
+ outcome: "already-done",
5321
+ summary: { moved: 0 }
5322
+ };
5323
+ const held = await ports.readLinks();
5324
+ for (const link of created) {
5325
+ const row = held.get(link.brokerId);
5326
+ if (row === void 0 || row.exportTo !== link.exportTo || row.mediaRole !== link.mediaRole || !sameDevices(row.devices, link.devices)) throw new Error(`the export-links record of ${link.brokerId} does not match the legacy settings; nothing was cleared`);
5327
+ }
5328
+ return {
5329
+ outcome: "done",
5330
+ summary: {
5331
+ moved: created.length,
5332
+ devices: created.reduce((sum, link) => sum + link.devices.length, 0)
5333
+ }
5334
+ };
5335
+ }
5336
+ var rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
5337
+ async function planDropMovedLinks(ports) {
5338
+ const stored = await ports.readStored();
5339
+ const present = legacyKeys(stored);
5340
+ if (present.length === 0) return { edits: [] };
5341
+ const refuse = (reason) => {
5342
+ ports.warn(`homeassistant-export: the legacy export keys are kept: ${reason}`);
5343
+ throw new Error(`the legacy export keys are not deleted: ${reason}`);
5344
+ };
5345
+ const links = legacyLinks(stored, await ports.listInstances(), ports.warn);
5346
+ const held = await ports.readLinks();
5347
+ for (const link of links) {
5348
+ const row = held.get(link.brokerId);
5349
+ if (row === void 0) return refuse(`no export-links record exists for ${link.brokerId}`);
5350
+ for (const device of link.devices) if (!row.devices.includes(device)) return refuse(`the record of ${link.brokerId} lacks the device ${device}`);
5351
+ if (row.exportTo !== link.exportTo || row.mediaRole !== link.mediaRole) return refuse(`the record of ${link.brokerId} disagrees on the switch or the address role`);
5352
+ }
5353
+ return { edits: [{
5354
+ op: "deleteKeys",
5355
+ scope: ADDON_SETTINGS_TABLE,
5356
+ keys: present.map(rowId),
5357
+ precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5358
+ addonId: HA_EXPORT_ADDON_ID,
5359
+ key,
5360
+ value: stored[key]
5361
+ }])))
5362
+ }] };
5363
+ }
5364
+ //#endregion
5374
5365
  //#region src/ha-export/migrations/index.ts
5366
+ var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
5375
5367
  /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5376
5368
  var ADDON_MIGRATIONS = [
5377
5369
  {
@@ -5421,51 +5413,263 @@ var ADDON_MIGRATIONS = [
5421
5413
  fixtureMark: "a legacy flag parked when the integration holds a different one"
5422
5414
  },
5423
5415
  plan: planDropPerBrokerFlags
5416
+ },
5417
+ {
5418
+ meta: {
5419
+ id: LINKS_TO_RECORD_ID,
5420
+ owner: "homeassistant-export",
5421
+ level: "addon-state",
5422
+ seq: 4,
5423
+ dependsOn: [],
5424
+ store: "settings",
5425
+ scope: "cluster",
5426
+ onFailure: "block-owner",
5427
+ bodyKind: "procedure",
5428
+ description: "Move each instance export switch, media role and exported devices into its ha-export-links record; the record is read back before anything is deleted",
5429
+ fixtureMark: "the reconcile interval the migration does not know"
5430
+ },
5431
+ procedure: moveLinksToRecords
5432
+ },
5433
+ {
5434
+ meta: {
5435
+ id: "homeassistant-export/2026-11-04-drop-moved-links",
5436
+ owner: "homeassistant-export",
5437
+ level: "addon-state",
5438
+ seq: 5,
5439
+ dependsOn: [LINKS_TO_RECORD_ID],
5440
+ store: "settings",
5441
+ scope: "cluster",
5442
+ onFailure: "block-owner",
5443
+ bodyKind: "plan",
5444
+ description: "Delete the membership, exportTo and mediaRole keys once the records hold what they carried",
5445
+ fixtureMark: "the reconcile interval the migration does not know"
5446
+ },
5447
+ plan: planDropMovedLinks
5424
5448
  }
5425
5449
  ];
5426
5450
  //#endregion
5427
- //#region src/ha-export/instance-flags.ts
5428
- var exportKey = (brokerId) => `exportTo:${brokerId}`;
5429
- var KEYS = [{
5430
- flag: "exportTo",
5431
- keyOf: exportKey
5432
- }, {
5433
- flag: "mediaRole",
5434
- keyOf: mediaRoleKey
5435
- }];
5436
- function splitInstanceKeys(patch, instances) {
5437
- const claimed = /* @__PURE__ */ new Set();
5438
- const toInstances = /* @__PURE__ */ new Map();
5439
- for (const row of instances) {
5440
- if (row.brokerId === null) continue;
5441
- for (const { flag, keyOf } of KEYS) {
5442
- const key = keyOf(row.brokerId);
5443
- if (!(key in patch)) continue;
5444
- claimed.add(key);
5445
- toInstances.set(row.integrationId, {
5446
- ...toInstances.get(row.integrationId),
5447
- [flag]: patch[key]
5451
+ //#region src/ha-export/link-records.ts
5452
+ /**
5453
+ * What the export carries to one Home Assistant instance (planning#408 P4): an `ha-export-links`
5454
+ * record per instance, under the id of the Home Assistant broker it belongs to. It holds the
5455
+ * three facts of the link: whether the export reaches the instance, which hub address role its
5456
+ * image links use, and the devices exported to it.
5457
+ *
5458
+ * It replaces the `membership` setting, the `exportTo:<brokerId>` / `mediaRole:<brokerId>` keys
5459
+ * and the `exportTo` / `mediaRole` values of the instance's integration. The migration moved
5460
+ * them; the addon reads this record and nothing else.
5461
+ */
5462
+ var HA_EXPORT_LINKS_KIND_NAME = "ha-export-links";
5463
+ var HaExportLinksBodySchema = require_ha_instance_settings.object({
5464
+ name: require_ha_instance_settings.string().min(1),
5465
+ exportTo: require_ha_instance_settings.boolean(),
5466
+ mediaRole: require_ha_instance_settings._enum(HA_MEDIA_ROLES),
5467
+ devices: require_ha_instance_settings.array(require_ha_instance_settings.string())
5468
+ });
5469
+ var haExportLinksKind = require_ha_instance_settings.defineEntryKind(HA_EXPORT_LINKS_KIND_NAME, {
5470
+ title: "Home Assistant export links",
5471
+ nameField: "name",
5472
+ schema: HaExportLinksBodySchema,
5473
+ writeTrigger: "an operator switches the export of a Home Assistant instance, picks its image-link address, or exports a device to it"
5474
+ });
5475
+ /** A link the operator has not touched: off, on the internal address, no device. */
5476
+ function defaultLinks(name) {
5477
+ return {
5478
+ name,
5479
+ exportTo: false,
5480
+ mediaRole: "internal",
5481
+ devices: []
5482
+ };
5483
+ }
5484
+ /** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
5485
+ async function readAllLinks(handle) {
5486
+ const rows = /* @__PURE__ */ new Map();
5487
+ let cursor;
5488
+ do {
5489
+ const page = await handle.query({
5490
+ limit: 1e3,
5491
+ ...cursor === void 0 ? {} : { cursor }
5492
+ });
5493
+ for (const row of page.records) rows.set(row.id, row.data);
5494
+ cursor = page.nextCursor ?? void 0;
5495
+ } while (cursor !== void 0);
5496
+ return rows;
5497
+ }
5498
+ function membershipOf(rows) {
5499
+ const out = {};
5500
+ for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
5501
+ return out;
5502
+ }
5503
+ /**
5504
+ * The in-memory copy of the records. This addon is their only writer, so the copy is the
5505
+ * synchronous read the event path needs; a write lands in the record first and in the copy
5506
+ * after it. `membership()` keeps its identity until a device list changes.
5507
+ */
5508
+ var ExportLinks = class ExportLinks {
5509
+ handle;
5510
+ rows;
5511
+ memberships;
5512
+ constructor(handle, rows) {
5513
+ this.handle = handle;
5514
+ this.rows = rows;
5515
+ this.memberships = membershipOf(rows);
5516
+ }
5517
+ static async load(handle) {
5518
+ return new ExportLinks(handle, await readAllLinks(handle));
5519
+ }
5520
+ isEnabled(brokerId) {
5521
+ return this.rows.get(brokerId)?.exportTo === true;
5522
+ }
5523
+ mediaRole(brokerId) {
5524
+ return this.rows.get(brokerId)?.mediaRole ?? "internal";
5525
+ }
5526
+ /** brokerId → exported device ids, for the brokers that export at least one. */
5527
+ membership() {
5528
+ return this.memberships;
5529
+ }
5530
+ brokerIds() {
5531
+ return [...this.rows.keys()];
5532
+ }
5533
+ /** Change the switch and / or the role of one instance; creates the record on first use. */
5534
+ async setFlags(brokerId, name, patch) {
5535
+ const current = this.rows.get(brokerId) ?? defaultLinks(name);
5536
+ const next = {
5537
+ ...current,
5538
+ exportTo: patch.exportTo ?? current.exportTo,
5539
+ mediaRole: patch.mediaRole ?? current.mediaRole
5540
+ };
5541
+ if (this.rows.has(brokerId) && sameLinks(current, next)) return;
5542
+ await this.write(brokerId, next);
5543
+ }
5544
+ /** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
5545
+ async setMembership(next, nameOf) {
5546
+ for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
5547
+ const devices = next[brokerId] ?? [];
5548
+ const current = this.rows.get(brokerId);
5549
+ if (sameList(current?.devices ?? [], devices)) continue;
5550
+ await this.write(brokerId, {
5551
+ ...current ?? defaultLinks(nameOf(brokerId)),
5552
+ devices: [...devices]
5448
5553
  });
5449
5554
  }
5450
5555
  }
5451
- return {
5452
- toInstances,
5453
- rest: Object.fromEntries(Object.entries(patch).filter(([key]) => !claimed.has(key)))
5454
- };
5556
+ /** Forget the instances that no longer exist; returns the ids it removed. */
5557
+ async prune(knownBrokerIds) {
5558
+ const known = new Set(knownBrokerIds);
5559
+ const gone = [...this.rows.keys()].filter((id) => !known.has(id));
5560
+ for (const id of gone) await this.handle.delete(id);
5561
+ if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
5562
+ return gone;
5563
+ }
5564
+ async write(brokerId, body) {
5565
+ await this.handle.put({
5566
+ id: brokerId,
5567
+ data: body
5568
+ });
5569
+ this.adopt(new Map(this.rows).set(brokerId, body));
5570
+ }
5571
+ adopt(rows) {
5572
+ const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
5573
+ this.rows = rows;
5574
+ if (devicesChanged) this.memberships = membershipOf(rows);
5575
+ }
5576
+ };
5577
+ function sameList(a, b) {
5578
+ return a.length === b.length && a.every((value, index) => value === b[index]);
5579
+ }
5580
+ function sameLinks(a, b) {
5581
+ return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
5582
+ }
5583
+ /**
5584
+ * The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
5585
+ * `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
5586
+ * records and a write is routed to the record of the broker they name.
5587
+ */
5588
+ var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
5589
+ var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
5590
+ function parseLinkFormKey(key) {
5591
+ for (const flag of ["exportTo", "mediaRole"]) {
5592
+ const prefix = `${flag}:`;
5593
+ if (key.startsWith(prefix) && key.length > prefix.length) return {
5594
+ flag,
5595
+ brokerId: key.slice(prefix.length)
5596
+ };
5597
+ }
5598
+ return null;
5455
5599
  }
5456
5600
  /** The form's dynamic keys carrying each instance's own value. */
5457
- function instanceOverlay(instances) {
5601
+ function linkFormOverlay(links) {
5458
5602
  const overlay = {};
5459
- for (const row of instances) {
5460
- if (row.brokerId === null) continue;
5461
- for (const { flag, keyOf } of KEYS) {
5462
- const value = row.settings[flag];
5463
- if (value !== void 0) overlay[keyOf(row.brokerId)] = value;
5464
- }
5603
+ for (const brokerId of links.brokerIds()) {
5604
+ overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
5605
+ overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
5465
5606
  }
5466
5607
  return overlay;
5467
5608
  }
5468
5609
  //#endregion
5610
+ //#region src/ha-export/migrations/bind-ports.ts
5611
+ /**
5612
+ * Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
5613
+ * `integrations` capability (the old home of the two flags, read by the migrations only) and the
5614
+ * `ha-export-links` kind.
5615
+ */
5616
+ function haExportMigrationPorts(deps) {
5617
+ return {
5618
+ readStored: deps.readStored,
5619
+ ...require_ha_instance_settings.haInstanceSettingsPortsOver(deps.api.integrations),
5620
+ warn: deps.warn,
5621
+ putLinks: async (brokerId, body) => {
5622
+ if (await deps.links.get(brokerId) !== null) return "exists";
5623
+ try {
5624
+ await deps.links.put({
5625
+ id: brokerId,
5626
+ data: body
5627
+ }, { ifRev: 0 });
5628
+ } catch (err) {
5629
+ if (err instanceof require_ha_instance_settings.RevConflictError) return "exists";
5630
+ throw err;
5631
+ }
5632
+ return "created";
5633
+ },
5634
+ readLinks: () => readAllLinks(deps.links)
5635
+ };
5636
+ }
5637
+ //#endregion
5638
+ //#region src/ha-export/membership.ts
5639
+ function devicesForBroker(membership, brokerId) {
5640
+ return membership[brokerId] ?? [];
5641
+ }
5642
+ function isExposed(membership, brokerId, deviceId) {
5643
+ return devicesForBroker(membership, brokerId).includes(deviceId);
5644
+ }
5645
+ /** Every broker a device reaches. Drives the N switches in the panel. */
5646
+ function brokersExposing(membership, deviceId) {
5647
+ return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
5648
+ }
5649
+ /** Every device exported to at least one of the given brokers. */
5650
+ function exposedDeviceIds(membership, brokerIds) {
5651
+ const ids = /* @__PURE__ */ new Set();
5652
+ for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
5653
+ return [...ids].sort();
5654
+ }
5655
+ function setExposed(membership, brokerId, deviceId, exposed) {
5656
+ const current = devicesForBroker(membership, brokerId);
5657
+ if (exposed === current.includes(deviceId)) return membership;
5658
+ const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
5659
+ return {
5660
+ ...membership,
5661
+ [brokerId]: next
5662
+ };
5663
+ }
5664
+ /** Same brokers, same order, same names — nothing for the form to re-render. */
5665
+ function sameBrokers(a, b) {
5666
+ if (a.length !== b.length) return false;
5667
+ return a.every((broker, index) => {
5668
+ const other = b[index];
5669
+ return other !== void 0 && other.id === broker.id && other.name === broker.name;
5670
+ });
5671
+ }
5672
+ //#endregion
5469
5673
  //#region src/ha-export/node-reads.ts
5470
5674
  var UNREAD = {
5471
5675
  footageUsedBytes: null,
@@ -7520,7 +7724,6 @@ var PTZ_MOVE_BY_DIRECTION = {
7520
7724
  };
7521
7725
  var DEFAULT_CONFIG = {
7522
7726
  reconcileIntervalSec: 300,
7523
- membership: {},
7524
7727
  knownBrokers: [],
7525
7728
  exportedEntities: {
7526
7729
  actions: [],
@@ -7633,10 +7836,17 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7633
7836
  super({ ...DEFAULT_CONFIG });
7634
7837
  }
7635
7838
  async onInitialize() {
7636
- await this.runMigrations(require_ha_instance_settings.defineAddonMigrations(ADDON_MIGRATIONS, {
7839
+ const records = this.ctx.records;
7840
+ if (records === void 0) throw new Error("ha-export: records are not available");
7841
+ await records.declare({ "ha-export-links": haExportLinksKind });
7842
+ const linksHandle = records.kind(haExportLinksKind);
7843
+ await this.runMigrations(require_ha_instance_settings.defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
7637
7844
  readStored: async () => this.resolveGlobalStore(),
7638
- ...this.instancePorts()
7639
- }));
7845
+ api: this.ctx.api,
7846
+ links: linksHandle,
7847
+ warn: (message) => this.ctx.logger.warn(message)
7848
+ })));
7849
+ this.linkBook = await ExportLinks.load(linksHandle);
7640
7850
  const secret = this.state("haExportMediaSecret", require_ha_instance_settings.string(), "");
7641
7851
  const existing = await secret.get();
7642
7852
  if (existing.length === 0) {
@@ -7672,31 +7882,46 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7672
7882
  {
7673
7883
  capability: require_ha_instance_settings.deviceExportCapability,
7674
7884
  provider: {
7675
- getStatus: async () => this.buildStatus(),
7676
- listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
7677
- listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
7678
- deviceId,
7679
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
7885
+ getStatus: async (input) => this.buildStatus(this.brokerScope(require_ha_instance_settings.statusInstanceOf(input))),
7886
+ listInstances: async () => this.config.knownBrokers.map((broker) => ({
7887
+ instanceId: broker.id,
7888
+ name: broker.name,
7889
+ enabled: this.enabledBrokerIds().includes(broker.id),
7890
+ kind: "home-assistant"
7680
7891
  })),
7681
- exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
7682
- unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
7892
+ listSupportedDeviceKinds: async ({ instanceId }) => {
7893
+ this.brokerScope(instanceId);
7894
+ return [...SUPPORTED_DEVICE_KINDS];
7895
+ },
7896
+ listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
7897
+ exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
7898
+ unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
7683
7899
  listSupportedEntityKinds: async () => [
7684
7900
  "device",
7685
7901
  "action",
7686
7902
  "notifier"
7687
7903
  ],
7688
- listExposedEntities: async () => {
7904
+ listExposedEntities: async ({ instanceId }) => {
7905
+ const scope = this.brokerScope(instanceId);
7689
7906
  await exportedEntities.pruneDeleted();
7690
- return [...exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
7907
+ return [...this.exposedDeviceRows(scope).map((row) => ({
7691
7908
  entity: {
7692
7909
  kind: "device",
7693
- deviceId
7910
+ deviceId: row.deviceId
7694
7911
  },
7695
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
7912
+ exposedAs: row.exposedAs
7696
7913
  })), ...exportedEntities.list()];
7697
7914
  },
7698
- exposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, true) : exportedEntities.expose(entity),
7699
- unexposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, false) : exportedEntities.unexpose(entity),
7915
+ exposeEntity: async ({ instanceId, entity }) => {
7916
+ if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
7917
+ this.brokerScope(instanceId);
7918
+ return exportedEntities.expose(entity);
7919
+ },
7920
+ unexposeEntity: async ({ instanceId, entity }) => {
7921
+ if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
7922
+ this.brokerScope(instanceId);
7923
+ return exportedEntities.unexpose(entity);
7924
+ },
7700
7925
  getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
7701
7926
  getDeviceLiveContribution: async () => null,
7702
7927
  applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
@@ -7721,29 +7946,43 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7721
7946
  ];
7722
7947
  }
7723
7948
  /**
7724
- * The form of the addon shows each instance's own switch and media role: the values sit on its
7725
- * integration (D985), overlaid on the legacy keys the store may still hold.
7949
+ * The form of the addon shows each instance's own switch and media role. They are not settings
7950
+ * of this addon: the values come from the instance's `ha-export-links` record.
7726
7951
  */
7727
7952
  async getGlobalSettings(overlay, cap, nodeId) {
7728
- const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
7729
7953
  return super.getGlobalSettings({
7730
7954
  ...overlay,
7731
- ...instanceOverlay(rows)
7955
+ ...linkFormOverlay(this.book())
7732
7956
  }, cap, nodeId);
7733
7957
  }
7734
7958
  /**
7735
- * An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
7736
- * (D985), everything else to the addon store as before. A key whose broker has no integration
7737
- * stays in the store.
7959
+ * An operator write: the `exportTo:` / `mediaRole:` keys go to the record of their broker,
7960
+ * everything else to the addon store as before. A key naming a broker this addon does not know
7961
+ * is refused, never parked.
7738
7962
  */
7739
7963
  async updateGlobalSettingsAsOperator(patch, nodeId) {
7740
- const ports = this.instancePorts();
7741
- const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
7742
- for (const [integrationId, flags] of toInstances) {
7743
- if (await ports.patchInstanceSettings(integrationId, flags)) continue;
7744
- throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
7964
+ const rest = {};
7965
+ const flags = /* @__PURE__ */ new Map();
7966
+ for (const [key, value] of Object.entries(patch)) {
7967
+ const field = parseLinkFormKey(key);
7968
+ if (field === null) {
7969
+ rest[key] = value;
7970
+ continue;
7971
+ }
7972
+ this.brokerScope(field.brokerId);
7973
+ const entry = flags.get(field.brokerId) ?? {};
7974
+ if (field.flag === "exportTo") {
7975
+ if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
7976
+ entry.exportTo = value;
7977
+ } else {
7978
+ const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
7979
+ if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
7980
+ entry.mediaRole = role;
7981
+ }
7982
+ flags.set(field.brokerId, entry);
7745
7983
  }
7746
- if (Object.keys(rest).length > 0 || toInstances.size === 0) {
7984
+ for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
7985
+ if (Object.keys(rest).length > 0 || flags.size === 0) {
7747
7986
  await super.updateGlobalSettingsAsOperator(rest, nodeId);
7748
7987
  return;
7749
7988
  }
@@ -8255,7 +8494,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
8255
8494
  * asking per camera would be the fan-out this repo just finished
8256
8495
  * removing from a 300 s poll.
8257
8496
  */
8258
- const exposedIds = exposedDeviceIds(this.config.membership, enabled);
8497
+ const exposedIds = exposedDeviceIds(this.membership(), enabled);
8259
8498
  const cameraDeviceIds = [];
8260
8499
  for (const idStr of exposedIds) {
8261
8500
  const numericId = Number(idStr);
@@ -8276,7 +8515,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
8276
8515
  const allCaps = /* @__PURE__ */ new Set();
8277
8516
  const exportedDeviceLog = [];
8278
8517
  let entityCount = 0;
8279
- for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
8518
+ for (const deviceIdStr of exposedDeviceIds(this.membership(), enabled)) {
8280
8519
  const numericId = Number(deviceIdStr);
8281
8520
  if (!Number.isFinite(numericId)) {
8282
8521
  this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
@@ -8321,7 +8560,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
8321
8560
  const catalogDevice = built.device;
8322
8561
  if (catalogDevice === null) continue;
8323
8562
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
8324
- const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
8563
+ const brokerIds = brokersExposing(this.membership(), deviceIdStr).filter((id) => enabled.includes(id));
8325
8564
  /**
8326
8565
  * The plan is built ONCE and announced to every broker that
8327
8566
  * receives the device, so it can only use platforms ALL of them
@@ -9212,27 +9451,19 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9212
9451
  * is pruned on the next pass that returns a NON-empty list without it,
9213
9452
  * which costs one interval and cannot cost the operator their config.
9214
9453
  */
9215
- const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
9216
- if (haBrokers.length === 0 && Object.keys(this.config.membership).length > 0) this.ctx.logger.warn("ha-export: no Home Assistant broker answered — keeping the export membership rather than pruning it", { meta: { brokers: Object.keys(this.config.membership) } });
9454
+ if (haBrokers.length === 0) {
9455
+ 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() } });
9456
+ } else {
9457
+ const gone = await this.book().prune(haBrokers.map((broker) => broker.id));
9458
+ if (gone.length > 0) this.ctx.logger.info("ha-export: forgot the export links of removed instances", { meta: { brokers: gone } });
9459
+ }
9217
9460
  /**
9218
- * ONE key per thing that actually changed — see `brokerRefreshPatch`.
9219
- *
9220
- * This used to write `knownBrokers` and `membership` together whenever
9221
- * EITHER differed. A boot-window settings read that answers empty leaves
9222
- * `this.config` on its constructor defaults, and that joint write then
9223
- * persisted `membership: {}` over the operator's real export list on a
9224
- * pass whose only news was the broker roster. It cost this hub three days
9225
- * of Home Assistant: 2026-08-24 21:55:13.
9461
+ * Only the roster is a setting; the membership is not, so a boot-window read that answers
9462
+ * empty can no longer erase it (2026-08-24: a refresh wrote `membership: {}` over the
9463
+ * operator's export list). The records are read at boot and a failed read stops it.
9226
9464
  */
9227
- const patch = brokerRefreshPatch({
9228
- knownBrokers: this.config.knownBrokers,
9229
- membership: this.config.membership
9230
- }, {
9231
- knownBrokers: known,
9232
- membership: pruned
9233
- });
9234
- if (patch !== null) await this.updateGlobalSettings(patch);
9235
- await this.refreshEnabledBrokers(known);
9465
+ if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
9466
+ this.refreshEnabledBrokers(known);
9236
9467
  const wanted = new Set(this.enabledBrokerIds());
9237
9468
  for (const [brokerId, link] of this.links) {
9238
9469
  if (wanted.has(brokerId)) continue;
@@ -9378,30 +9609,27 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9378
9609
  /**
9379
9610
  * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
9380
9611
  *
9381
- * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
9382
- * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
9383
- * it is `undefined` there for ever — see `enabledBrokerIds` in
9384
- * `membership.ts` for what that cost. The store is the authority; this is a
9385
- * read-through cache of it, refreshed at the top of every reconcile pass and
9386
- * before the per-device panel renders, never written to.
9612
+ * NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
9613
+ * record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
9614
+ * and before the per-device panel renders, never written to.
9387
9615
  */
9388
9616
  enabled = [];
9389
- async refreshEnabledBrokers(known) {
9390
- const store = await this.resolveGlobalStore();
9391
- this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
9617
+ /** The `ha-export-links` records, loaded at boot after the migrations. */
9618
+ linkBook = null;
9619
+ book() {
9620
+ if (this.linkBook === null) throw new Error("ha-export: the export links are not loaded yet");
9621
+ return this.linkBook;
9392
9622
  }
9393
- /** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
9394
- instancePorts() {
9395
- return require_ha_instance_settings.haInstanceSettingsPortsOver(this.ctx.api.integrations);
9623
+ /** brokerId → exported device ids, from the records. */
9624
+ membership() {
9625
+ return this.book().membership();
9396
9626
  }
9397
- /**
9398
- * Each broker's integration settings (D985): the switch and the media role of an instance.
9399
- * A read that fails answers nothing, so every broker falls back to its legacy key and the
9400
- * failure is logged — never read as "everything off".
9401
- */
9402
- async readInstanceSettings() {
9403
- const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read — the legacy per-broker keys answer", { meta: { error } }));
9404
- return rows.length === 0 ? NO_INSTANCE_SETTINGS : require_ha_instance_settings.settingsByBroker(rows);
9627
+ /** Persist a membership change: each instance's record first, the in-memory copy after it. */
9628
+ async persistMembership(next) {
9629
+ await this.book().setMembership(next, (brokerId) => this.brokerName(brokerId));
9630
+ }
9631
+ refreshEnabledBrokers(known) {
9632
+ this.enabled = known.map((broker) => broker.id).filter((id) => this.book().isEnabled(id));
9405
9633
  }
9406
9634
  enabledBrokerIds() {
9407
9635
  return this.enabled;
@@ -9795,14 +10023,14 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9795
10023
  }
9796
10024
  /**
9797
10025
  * Each broker's media base, read per use: the palette's role the broker chose
9798
- * (`mediaRole:<brokerId>`, `internal` by default). A role that resolves to nothing
10026
+ * (the `ha-export-links` record's `mediaRole`, `internal` by default). A role that resolves to nothing
9799
10027
  * is `null` and says so — the image entities then carry no value, which reads as
9800
10028
  * empty rather than as a broken link.
9801
10029
  */
9802
10030
  async mediaBasesFor(brokerIds, deviceId) {
9803
10031
  let bases;
9804
10032
  try {
9805
- bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
10033
+ bases = mediaBasesByBroker(brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), (brokerId) => this.book().mediaRole(brokerId));
9806
10034
  } catch (err) {
9807
10035
  this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9808
10036
  tags: { deviceId },
@@ -9820,29 +10048,53 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9820
10048
  return bases;
9821
10049
  }
9822
10050
  /**
9823
- * The cap-level expose/unexpose applies to EVERY enabled broker.
9824
- *
9825
- * The `device-export` cap is the COMMON surface across export targets
9826
- * and has no notion of a broker; per-broker granularity lives in the
9827
- * derived Export panel, over the same store. There is exactly one
9828
- * authority either way.
10051
+ * Every broker this addon knows, enabled or not: a membership write may
10052
+ * target a broker that is switched off (its devices resume when it is turned
10053
+ * back on). A cold `knownBrokers` still counts the enabled ids.
10054
+ */
10055
+ knownBrokerIds() {
10056
+ return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
10057
+ }
10058
+ /**
10059
+ * The brokers a `device-export` read answers over: the named instance, or
10060
+ * (absent) every enabled broker. A broker this addon does not know is
10061
+ * refused, never answered empty.
10062
+ */
10063
+ brokerScope(instanceId) {
10064
+ if (instanceId === void 0) return this.enabledBrokerIds();
10065
+ if (!this.knownBrokerIds().has(instanceId)) throw new require_ha_instance_settings.ExportInstanceUnknownError("provider-homeassistant", instanceId);
10066
+ return [instanceId];
10067
+ }
10068
+ exposedDeviceRows(scope) {
10069
+ return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
10070
+ deviceId,
10071
+ exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
10072
+ }));
10073
+ }
10074
+ /**
10075
+ * A cap-level expose/unexpose changes ONE broker's membership — the named
10076
+ * instance. There is no write without a target and no "everywhere": the
10077
+ * other brokers' membership is not touched.
9829
10078
  */
9830
- async setMembershipEverywhere(deviceId, exposed) {
9831
- let next = this.config.membership;
9832
- for (const brokerId of this.enabledBrokerIds()) next = setExposed(next, brokerId, deviceId, exposed);
9833
- if (next === this.config.membership) {
10079
+ async setBrokerMembership(instanceId, deviceId, exposed) {
10080
+ this.brokerScope(instanceId);
10081
+ const next = setExposed(this.membership(), instanceId, deviceId, exposed);
10082
+ if (next === this.membership()) {
9834
10083
  this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
9835
10084
  tags: { deviceId: Number(deviceId) },
9836
- meta: { exposed }
10085
+ meta: {
10086
+ exposed,
10087
+ brokerId: instanceId
10088
+ }
9837
10089
  });
9838
10090
  return;
9839
10091
  }
9840
- await this.updateGlobalSettings({ membership: next });
10092
+ await this.persistMembership(next);
9841
10093
  this.ctx.logger.info("ha-export: export membership changed", {
9842
10094
  tags: { deviceId: Number(deviceId) },
9843
10095
  meta: {
9844
10096
  exposed,
9845
- brokers: this.enabledBrokerIds()
10097
+ brokerId: instanceId
9846
10098
  }
9847
10099
  });
9848
10100
  await this.reconcile(exposed ? "expose" : "unexpose");
@@ -9850,7 +10102,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9850
10102
  globalSettingsSchema() {
9851
10103
  const brokerFields = this.config.knownBrokers.map((broker) => ({
9852
10104
  type: "boolean",
9853
- key: `exportTo:${broker.id}`,
10105
+ key: exportToFormKey(broker.id),
9854
10106
  label: broker.name,
9855
10107
  style: "switch",
9856
10108
  default: false
@@ -9879,7 +10131,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9879
10131
  columns: 1,
9880
10132
  fields: this.config.knownBrokers.map((broker) => ({
9881
10133
  type: "select",
9882
- key: mediaRoleKey(broker.id),
10134
+ key: mediaRoleFormKey(broker.id),
9883
10135
  label: `Image links for ${broker.name}`,
9884
10136
  options: [{
9885
10137
  value: "internal",
@@ -9920,7 +10172,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9920
10172
  * exporter's value in this one's toggle.
9921
10173
  */
9922
10174
  async buildContribution(deviceId) {
9923
- await this.refreshEnabledBrokers(this.config.knownBrokers);
10175
+ this.refreshEnabledBrokers(this.config.knownBrokers);
9924
10176
  const idStr = String(deviceId);
9925
10177
  const enabled = this.enabledBrokerIds();
9926
10178
  const fields = enabled.map((brokerId) => ({
@@ -9928,7 +10180,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9928
10180
  key: `haExport:${deviceId}:${brokerId}`,
9929
10181
  label: `Export to ${this.brokerName(brokerId)}`,
9930
10182
  style: "switch",
9931
- value: isExposed(this.config.membership, brokerId, idStr),
10183
+ value: isExposed(this.membership(), brokerId, idStr),
9932
10184
  immediate: true
9933
10185
  }));
9934
10186
  return {
@@ -9958,7 +10210,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9958
10210
  }
9959
10211
  async applyDeviceSettingsPatch(deviceId, patch) {
9960
10212
  const idStr = String(deviceId);
9961
- let next = this.config.membership;
10213
+ let next = this.membership();
9962
10214
  let touched = false;
9963
10215
  /**
9964
10216
  * Driven by the PATCH KEYS, not by the enabled-broker list.
@@ -9988,7 +10240,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9988
10240
  });
9989
10241
  return { success: true };
9990
10242
  }
9991
- await this.updateGlobalSettings({ membership: next });
10243
+ await this.persistMembership(next);
9992
10244
  this.ctx.logger.info("ha-export: per-broker export membership changed", {
9993
10245
  tags: { deviceId },
9994
10246
  meta: { brokers: brokersExposing(next, idStr) }
@@ -9996,19 +10248,20 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
9996
10248
  await this.reconcile("device-panel");
9997
10249
  return { success: true };
9998
10250
  }
9999
- buildStatus() {
10000
- const enabled = this.enabledBrokerIds();
10001
- const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
10002
- const linkFailure = [...this.links.values()].map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
10251
+ buildStatus(scope) {
10252
+ const enabled = scope;
10253
+ const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
10254
+ const healthy = links.filter((link) => link.client.healthy).length;
10255
+ const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
10003
10256
  return {
10004
- linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
10005
- exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
10257
+ linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10258
+ exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
10006
10259
  ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
10007
10260
  setup: {
10008
10261
  fields: [
10009
10262
  {
10010
10263
  label: "Home Assistant instances",
10011
- value: `${healthy} / ${this.links.size} reachable`
10264
+ value: `${healthy} / ${links.length} reachable`
10012
10265
  },
10013
10266
  {
10014
10267
  label: "Last push failure",