@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.
@@ -1,4 +1,4 @@
1
- import { $t as nodePin, F as buildAddonRouteProvider, Jt as defineAddonMigrations, Kt as baseUnitOf, Qt as isReadingQuantity, Tt as runExportedAction, U as consumableFormOf, W as consumableKindOf, Wt as BaseAddon, X as deviceExportCapability, _ as FanDirectionSchema, _t as preconditionOf, b as PLUG_APPLIANCE_TYPES, cn as string, d as COCO_TO_MACRO, f as CameraSwitchIdSchema, gn as EventCategory, h as ConsumableStatusSchema, jt as sendToExportedTarget, m as ConsumableItemSchema, mt as oauthIntegrationCapability, n as listInstancesOrNothing, o as listHomeAssistantBrokers, on as object, p as ConsumableFormSchema, r as settingsByBroker, s as AlarmArmModeSchema, t as haInstanceSettingsPortsOver, tn as array, u as CAMERA_SWITCH_ORDER, un as unknown, v as HvacModeSchema, w as addonRoutesCapability, xt as pruneDeletedEntities, y as MediaPlayerRepeatSchema } from "../ha-instance-settings-g5pwb3Fi.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, 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-EQ5Ey3Q-.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -5028,133 +5028,6 @@ function verifyExpiringUrl(input) {
5028
5028
  return timingSafeEqual(a, b);
5029
5029
  }
5030
5030
  //#endregion
5031
- //#region src/ha-export/membership.ts
5032
- function devicesForBroker(membership, brokerId) {
5033
- return membership[brokerId] ?? [];
5034
- }
5035
- function isExposed(membership, brokerId, deviceId) {
5036
- return devicesForBroker(membership, brokerId).includes(deviceId);
5037
- }
5038
- /** Every broker a device reaches. Drives the N switches in the panel. */
5039
- function brokersExposing(membership, deviceId) {
5040
- return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
5041
- }
5042
- /** Every device exported to at least one of the given brokers. */
5043
- function exposedDeviceIds(membership, brokerIds) {
5044
- const ids = /* @__PURE__ */ new Set();
5045
- for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
5046
- return [...ids].sort();
5047
- }
5048
- function setExposed(membership, brokerId, deviceId, exposed) {
5049
- const current = devicesForBroker(membership, brokerId);
5050
- if (exposed === current.includes(deviceId)) return membership;
5051
- const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
5052
- return {
5053
- ...membership,
5054
- [brokerId]: next
5055
- };
5056
- }
5057
- /**
5058
- * Forget brokers that no longer exist.
5059
- *
5060
- * A removed HA instance must not keep a membership list that reappears if
5061
- * a broker id is ever reused — and a stale key would also inflate
5062
- * `exposedDeviceCount` with devices going nowhere.
5063
- */
5064
- function pruneBrokers(membership, knownBrokerIds) {
5065
- const known = new Set(knownBrokerIds);
5066
- const kept = Object.keys(membership).filter((brokerId) => known.has(brokerId));
5067
- if (kept.length === Object.keys(membership).length) return membership;
5068
- const next = {};
5069
- for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
5070
- return next;
5071
- }
5072
- /**
5073
- * The brokers the operator has switched ON, read from the RAW addon store.
5074
- *
5075
- * `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
5076
- * hub happens to know about — so it can never appear in the addon's
5077
- * `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
5078
- * present in the defaults ("the store can contain extra keys … without
5079
- * polluting the typed config"), so every `exportTo:*` key is dropped on the
5080
- * way into `this.config` and reading one there yields `undefined` for ever.
5081
- *
5082
- * That is not theoretical. On 2026-08-09 the switch was on
5083
- * (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
5084
- * settings FORM reads the store) and every reconcile still logged
5085
- * `brokers=0`: nothing was ever exported, the per-device Export tab rendered
5086
- * its "Not configured" card, and no operator action could have changed
5087
- * either. The store is the single authority — read it, do not mirror it into
5088
- * a typed field that would then be a second one (D62).
5089
- *
5090
- * A broker that is no longer known is not enabled, whatever the store says:
5091
- * a stale `exportTo:` key for a deleted instance must not resurrect it.
5092
- *
5093
- * The switch is a setting of the INSTANCE (D985): `perBroker` carries each broker's integration
5094
- * settings, and a broker whose integration has an `exportTo` answers from it. A broker whose
5095
- * integration has none yet — the migration has not copied it — falls back to the legacy
5096
- * `exportTo:<brokerId>` key, so the move never flips a switch mid-way.
5097
- */
5098
- function enabledBrokerIds(store, knownBrokerIds, perBroker = NO_INSTANCE_SETTINGS) {
5099
- return knownBrokerIds.filter((brokerId) => {
5100
- const own = perBroker.get(brokerId)?.["exportTo"];
5101
- return typeof own === "boolean" ? own : store[`exportTo:${brokerId}`] === true;
5102
- });
5103
- }
5104
- var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
5105
- /** Same brokers, same order, same names — nothing for the form to re-render. */
5106
- function sameBrokers(a, b) {
5107
- if (a.length !== b.length) return false;
5108
- return a.every((broker, index) => {
5109
- const other = b[index];
5110
- return other !== void 0 && other.id === broker.id && other.name === broker.name;
5111
- });
5112
- }
5113
- /**
5114
- * What a broker refresh persists — and, above all, what it must NOT.
5115
- *
5116
- * **A patch never names a key it did not change.** That reads like tidiness
5117
- * and is not: `updateGlobalSettings` is a read-modify-write over the addon
5118
- * store, so naming `membership` writes whatever `this.config.membership`
5119
- * currently holds — and `this.config` is not always the store.
5120
- * `BaseAddon.resolveConfig` falls back to the CONSTRUCTOR DEFAULTS whenever
5121
- * the settings read answers empty, and the kernel's `readAddonStore` answers
5122
- * empty for a FAILED read exactly as it does for a missing one, silently. A
5123
- * boot-window read that misses therefore gives the addon `membership: {}`,
5124
- * and the first refresh that merely re-records `knownBrokers` hands that
5125
- * empty object to the store as if the operator had unexported everything.
5126
- *
5127
- * That is not a hypothetical. On 2026-08-24 the hub restarted at 21:49; the
5128
- * export runner came up while the cap resolver was still answering
5129
- * "no provider registered" for hub-resident caps; at 21:55:13 a refresh
5130
- * found `knownBrokers` stale (`[]` vs `[ha_001]`) and wrote BOTH keys. The
5131
- * two exported cameras (104 hall, 101 front door) were erased from the
5132
- * store, `devices` fell from 4 to 2 (the two synthetic devices), and Home
5133
- * Assistant kept every camera entity as a restored/unavailable orphan for
5134
- * three days: no stream ("the hub does not export this camera"), no
5135
- * doorbell, no camera state. Nothing logged it, because from the store's
5136
- * point of view the addon simply asked for `{}`.
5137
- *
5138
- * With the keys split, that pass writes `knownBrokers` alone, the
5139
- * `resolveConfig` that follows the write re-reads a store that still holds
5140
- * the membership, and the next reconcile exports both cameras again.
5141
- *
5142
- * Identity, not deep equality, decides `membership`: `pruneBrokers` returns
5143
- * the SAME reference when it removed nothing, which is precisely the
5144
- * "nothing changed" signal this needs.
5145
- *
5146
- * @returns the patch to persist, or `null` when there is nothing to write.
5147
- */
5148
- function brokerRefreshPatch(current, next) {
5149
- const brokersChanged = !sameBrokers(next.knownBrokers, current.knownBrokers);
5150
- const membershipChanged = next.membership !== current.membership;
5151
- if (!brokersChanged && !membershipChanged) return null;
5152
- return {
5153
- ...brokersChanged ? { knownBrokers: next.knownBrokers } : {},
5154
- ...membershipChanged ? { membership: next.membership } : {}
5155
- };
5156
- }
5157
- //#endregion
5158
5031
  //#region src/ha-export/media-url.ts
5159
5032
  /**
5160
5033
  * Signed, expiring media links.
@@ -5181,19 +5054,6 @@ function brokerRefreshPatch(current, next) {
5181
5054
  */
5182
5055
  /** The hub address roles a Home Assistant media link may be minted on. */
5183
5056
  var HA_MEDIA_ROLES = ["internal", "publicEndpoint"];
5184
- /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5185
- function mediaRoleKey(brokerId) {
5186
- return `mediaRole:${brokerId}`;
5187
- }
5188
- /**
5189
- * A broker's chosen role; anything stored that is not a known role is `internal`. The role is a
5190
- * setting of the INSTANCE (D985): the integration's `mediaRole` answers when it has one, and a
5191
- * broker the migration has not reached falls back to the legacy `mediaRole:<brokerId>` key.
5192
- */
5193
- function haMediaRoleOf(store, brokerId, perBroker = NO_INSTANCE_SETTINGS) {
5194
- const own = perBroker.get(brokerId)?.["mediaRole"];
5195
- return (own !== void 0 ? own : store[mediaRoleKey(brokerId)]) === "publicEndpoint" ? "publicEndpoint" : "internal";
5196
- }
5197
5057
  /**
5198
5058
  * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5199
5059
  * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
@@ -5204,8 +5064,8 @@ function haMediaBaseUrl(book, role = "internal") {
5204
5064
  return book.roles[role].resolved;
5205
5065
  }
5206
5066
  /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5207
- function mediaBasesByBroker(store, brokerIds, book, perBroker = NO_INSTANCE_SETTINGS) {
5208
- return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId, perBroker))]));
5067
+ function mediaBasesByBroker(brokerIds, book, roleOf) {
5068
+ return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, roleOf(brokerId))]));
5209
5069
  }
5210
5070
  /** How long a minted link stays valid. Long enough for HA to render it. */
5211
5071
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
@@ -5336,7 +5196,139 @@ async function planDropPerBrokerFlags(ports) {
5336
5196
  }] };
5337
5197
  }
5338
5198
  //#endregion
5199
+ //#region src/ha-export/migrations/links-to-record.ts
5200
+ /**
5201
+ * What the export carries to a Home Assistant instance moves into the instance's
5202
+ * `ha-export-links` record (planning#408 P4). Two migrations, both `block-owner`: the addon must
5203
+ * not serve a membership it cannot trust (an empty list unexposes every device on the next
5204
+ * reconcile), and after them nothing reads the old shape.
5205
+ *
5206
+ * The old shape, per broker: the `membership` setting (device ids), and the two flags, each kept
5207
+ * on the instance's integration (`exportTo`, `mediaRole`) or, for a broker the 2026-11-03
5208
+ * migration had not reached, in the dynamic addon keys `exportTo:<brokerId>` /
5209
+ * `mediaRole:<brokerId>`. The integration's value won over the key; so it does here.
5210
+ *
5211
+ * 1. `moveLinksToRecords` (procedure): one record per broker, id = broker id, written with
5212
+ * `ifRev: 0` (an existing record is never overwritten), then read BACK and compared; a
5213
+ * mismatch throws and nothing is deleted.
5214
+ * 2. `planDropMovedLinks` (plan): the addon keys are deleted only while every record holds what
5215
+ * they carry; a missing or short record refuses, a read that fails leaves them in place. The
5216
+ * `exportTo` / `mediaRole` values on the integrations are the provider's instance data and
5217
+ * are not touched here; nothing in this addon reads them any more.
5218
+ */
5219
+ var MEMBERSHIP_KEY = "membership";
5220
+ var EXPORT_PREFIX = "exportTo:";
5221
+ var ROLE_PREFIX = "mediaRole:";
5222
+ var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
5223
+ function membershipOf$1(value) {
5224
+ const out = /* @__PURE__ */ new Map();
5225
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
5226
+ for (const [brokerId, devices] of Object.entries(value)) {
5227
+ if (!Array.isArray(devices)) continue;
5228
+ out.set(brokerId, devices.filter((device) => typeof device === "string"));
5229
+ }
5230
+ return out;
5231
+ }
5232
+ function integrationByBroker(rows, warn) {
5233
+ const out = /* @__PURE__ */ new Map();
5234
+ for (const row of [...rows].sort((a, b) => a.integrationId.localeCompare(b.integrationId))) {
5235
+ if (row.brokerId === null) continue;
5236
+ if (out.has(row.brokerId)) {
5237
+ warn(`homeassistant-export: integrations ${out.get(row.brokerId)?.integrationId} and ${row.integrationId} both link broker ${row.brokerId}; the first one answers`);
5238
+ continue;
5239
+ }
5240
+ out.set(row.brokerId, row);
5241
+ }
5242
+ return out;
5243
+ }
5244
+ /** Every broker the old shape has something for, with the value the old reader would have used. */
5245
+ function legacyLinks(stored, rows, warn) {
5246
+ const membership = membershipOf$1(stored[MEMBERSHIP_KEY]);
5247
+ const integrations = integrationByBroker(rows, warn);
5248
+ const brokers = new Set(membership.keys());
5249
+ 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));
5250
+ for (const [brokerId, row] of integrations) if ("exportTo" in row.settings || "mediaRole" in row.settings) brokers.add(brokerId);
5251
+ return [...brokers].sort().map((brokerId) => {
5252
+ const own = integrations.get(brokerId)?.settings;
5253
+ const ownExport = own?.["exportTo"];
5254
+ const ownRole = own?.["mediaRole"];
5255
+ return {
5256
+ brokerId,
5257
+ exportTo: typeof ownExport === "boolean" ? ownExport : stored[`${EXPORT_PREFIX}${brokerId}`] === true,
5258
+ mediaRole: roleOf(ownRole !== void 0 ? ownRole : stored[`${ROLE_PREFIX}${brokerId}`]) ?? "internal",
5259
+ devices: membership.get(brokerId) ?? []
5260
+ };
5261
+ });
5262
+ }
5263
+ /** The addon keys the old shape lives in. */
5264
+ function legacyKeys(stored) {
5265
+ return Object.keys(stored).filter((key) => key === "membership" || key.startsWith(EXPORT_PREFIX) && key.length > 9 || key.startsWith(ROLE_PREFIX) && key.length > 10);
5266
+ }
5267
+ var sameDevices = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
5268
+ async function moveLinksToRecords(ports) {
5269
+ const links = legacyLinks(await ports.readStored(), await ports.listInstances(), ports.warn);
5270
+ if (links.length === 0) return {
5271
+ outcome: "already-done",
5272
+ summary: { moved: 0 }
5273
+ };
5274
+ const created = [];
5275
+ for (const link of links) {
5276
+ const body = {
5277
+ name: link.brokerId,
5278
+ exportTo: link.exportTo,
5279
+ mediaRole: link.mediaRole,
5280
+ devices: link.devices
5281
+ };
5282
+ if (await ports.putLinks(link.brokerId, body) === "created") created.push(link);
5283
+ }
5284
+ if (created.length === 0) return {
5285
+ outcome: "already-done",
5286
+ summary: { moved: 0 }
5287
+ };
5288
+ const held = await ports.readLinks();
5289
+ for (const link of created) {
5290
+ const row = held.get(link.brokerId);
5291
+ 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`);
5292
+ }
5293
+ return {
5294
+ outcome: "done",
5295
+ summary: {
5296
+ moved: created.length,
5297
+ devices: created.reduce((sum, link) => sum + link.devices.length, 0)
5298
+ }
5299
+ };
5300
+ }
5301
+ var rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
5302
+ async function planDropMovedLinks(ports) {
5303
+ const stored = await ports.readStored();
5304
+ const present = legacyKeys(stored);
5305
+ if (present.length === 0) return { edits: [] };
5306
+ const refuse = (reason) => {
5307
+ ports.warn(`homeassistant-export: the legacy export keys are kept: ${reason}`);
5308
+ throw new Error(`the legacy export keys are not deleted: ${reason}`);
5309
+ };
5310
+ const links = legacyLinks(stored, await ports.listInstances(), ports.warn);
5311
+ const held = await ports.readLinks();
5312
+ for (const link of links) {
5313
+ const row = held.get(link.brokerId);
5314
+ if (row === void 0) return refuse(`no export-links record exists for ${link.brokerId}`);
5315
+ for (const device of link.devices) if (!row.devices.includes(device)) return refuse(`the record of ${link.brokerId} lacks the device ${device}`);
5316
+ if (row.exportTo !== link.exportTo || row.mediaRole !== link.mediaRole) return refuse(`the record of ${link.brokerId} disagrees on the switch or the address role`);
5317
+ }
5318
+ return { edits: [{
5319
+ op: "deleteKeys",
5320
+ scope: ADDON_SETTINGS_TABLE,
5321
+ keys: present.map(rowId),
5322
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5323
+ addonId: HA_EXPORT_ADDON_ID,
5324
+ key,
5325
+ value: stored[key]
5326
+ }])))
5327
+ }] };
5328
+ }
5329
+ //#endregion
5339
5330
  //#region src/ha-export/migrations/index.ts
5331
+ var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
5340
5332
  /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5341
5333
  var ADDON_MIGRATIONS = [
5342
5334
  {
@@ -5386,51 +5378,249 @@ var ADDON_MIGRATIONS = [
5386
5378
  fixtureMark: "a legacy flag parked when the integration holds a different one"
5387
5379
  },
5388
5380
  plan: planDropPerBrokerFlags
5381
+ },
5382
+ {
5383
+ meta: {
5384
+ id: LINKS_TO_RECORD_ID,
5385
+ owner: "homeassistant-export",
5386
+ level: "addon-state",
5387
+ seq: 4,
5388
+ dependsOn: [],
5389
+ store: "settings",
5390
+ scope: "cluster",
5391
+ onFailure: "block-owner",
5392
+ bodyKind: "procedure",
5393
+ 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",
5394
+ fixtureMark: "the reconcile interval the migration does not know"
5395
+ },
5396
+ procedure: moveLinksToRecords
5397
+ },
5398
+ {
5399
+ meta: {
5400
+ id: "homeassistant-export/2026-11-04-drop-moved-links",
5401
+ owner: "homeassistant-export",
5402
+ level: "addon-state",
5403
+ seq: 5,
5404
+ dependsOn: [LINKS_TO_RECORD_ID],
5405
+ store: "settings",
5406
+ scope: "cluster",
5407
+ onFailure: "block-owner",
5408
+ bodyKind: "plan",
5409
+ description: "Delete the membership, exportTo and mediaRole keys once the records hold what they carried",
5410
+ fixtureMark: "the reconcile interval the migration does not know"
5411
+ },
5412
+ plan: planDropMovedLinks
5389
5413
  }
5390
5414
  ];
5391
- //#endregion
5392
- //#region src/ha-export/instance-flags.ts
5393
- var exportKey = (brokerId) => `exportTo:${brokerId}`;
5394
- var KEYS = [{
5395
- flag: "exportTo",
5396
- keyOf: exportKey
5397
- }, {
5398
- flag: "mediaRole",
5399
- keyOf: mediaRoleKey
5400
- }];
5401
- function splitInstanceKeys(patch, instances) {
5402
- const claimed = /* @__PURE__ */ new Set();
5403
- const toInstances = /* @__PURE__ */ new Map();
5404
- for (const row of instances) {
5405
- if (row.brokerId === null) continue;
5406
- for (const { flag, keyOf } of KEYS) {
5407
- const key = keyOf(row.brokerId);
5408
- if (!(key in patch)) continue;
5409
- claimed.add(key);
5410
- toInstances.set(row.integrationId, {
5411
- ...toInstances.get(row.integrationId),
5412
- [flag]: patch[key]
5415
+ var haExportLinksKind = defineEntryKind("ha-export-links", {
5416
+ title: "Home Assistant export links",
5417
+ nameField: "name",
5418
+ schema: object({
5419
+ name: string().min(1),
5420
+ exportTo: boolean(),
5421
+ mediaRole: _enum(HA_MEDIA_ROLES),
5422
+ devices: array(string())
5423
+ }),
5424
+ writeTrigger: "an operator switches the export of a Home Assistant instance, picks its image-link address, or exports a device to it"
5425
+ });
5426
+ /** A link the operator has not touched: off, on the internal address, no device. */
5427
+ function defaultLinks(name) {
5428
+ return {
5429
+ name,
5430
+ exportTo: false,
5431
+ mediaRole: "internal",
5432
+ devices: []
5433
+ };
5434
+ }
5435
+ /** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
5436
+ async function readAllLinks(handle) {
5437
+ const rows = /* @__PURE__ */ new Map();
5438
+ let cursor;
5439
+ do {
5440
+ const page = await handle.query({
5441
+ limit: 1e3,
5442
+ ...cursor === void 0 ? {} : { cursor }
5443
+ });
5444
+ for (const row of page.records) rows.set(row.id, row.data);
5445
+ cursor = page.nextCursor ?? void 0;
5446
+ } while (cursor !== void 0);
5447
+ return rows;
5448
+ }
5449
+ function membershipOf(rows) {
5450
+ const out = {};
5451
+ for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
5452
+ return out;
5453
+ }
5454
+ /**
5455
+ * The in-memory copy of the records. This addon is their only writer, so the copy is the
5456
+ * synchronous read the event path needs; a write lands in the record first and in the copy
5457
+ * after it. `membership()` keeps its identity until a device list changes.
5458
+ */
5459
+ var ExportLinks = class ExportLinks {
5460
+ handle;
5461
+ rows;
5462
+ memberships;
5463
+ constructor(handle, rows) {
5464
+ this.handle = handle;
5465
+ this.rows = rows;
5466
+ this.memberships = membershipOf(rows);
5467
+ }
5468
+ static async load(handle) {
5469
+ return new ExportLinks(handle, await readAllLinks(handle));
5470
+ }
5471
+ isEnabled(brokerId) {
5472
+ return this.rows.get(brokerId)?.exportTo === true;
5473
+ }
5474
+ mediaRole(brokerId) {
5475
+ return this.rows.get(brokerId)?.mediaRole ?? "internal";
5476
+ }
5477
+ /** brokerId → exported device ids, for the brokers that export at least one. */
5478
+ membership() {
5479
+ return this.memberships;
5480
+ }
5481
+ brokerIds() {
5482
+ return [...this.rows.keys()];
5483
+ }
5484
+ /** Change the switch and / or the role of one instance; creates the record on first use. */
5485
+ async setFlags(brokerId, name, patch) {
5486
+ const current = this.rows.get(brokerId) ?? defaultLinks(name);
5487
+ const next = {
5488
+ ...current,
5489
+ exportTo: patch.exportTo ?? current.exportTo,
5490
+ mediaRole: patch.mediaRole ?? current.mediaRole
5491
+ };
5492
+ if (this.rows.has(brokerId) && sameLinks(current, next)) return;
5493
+ await this.write(brokerId, next);
5494
+ }
5495
+ /** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
5496
+ async setMembership(next, nameOf) {
5497
+ for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
5498
+ const devices = next[brokerId] ?? [];
5499
+ const current = this.rows.get(brokerId);
5500
+ if (sameList(current?.devices ?? [], devices)) continue;
5501
+ await this.write(brokerId, {
5502
+ ...current ?? defaultLinks(nameOf(brokerId)),
5503
+ devices: [...devices]
5413
5504
  });
5414
5505
  }
5415
5506
  }
5416
- return {
5417
- toInstances,
5418
- rest: Object.fromEntries(Object.entries(patch).filter(([key]) => !claimed.has(key)))
5419
- };
5507
+ /** Forget the instances that no longer exist; returns the ids it removed. */
5508
+ async prune(knownBrokerIds) {
5509
+ const known = new Set(knownBrokerIds);
5510
+ const gone = [...this.rows.keys()].filter((id) => !known.has(id));
5511
+ for (const id of gone) await this.handle.delete(id);
5512
+ if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
5513
+ return gone;
5514
+ }
5515
+ async write(brokerId, body) {
5516
+ await this.handle.put({
5517
+ id: brokerId,
5518
+ data: body
5519
+ });
5520
+ this.adopt(new Map(this.rows).set(brokerId, body));
5521
+ }
5522
+ adopt(rows) {
5523
+ const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
5524
+ this.rows = rows;
5525
+ if (devicesChanged) this.memberships = membershipOf(rows);
5526
+ }
5527
+ };
5528
+ function sameList(a, b) {
5529
+ return a.length === b.length && a.every((value, index) => value === b[index]);
5530
+ }
5531
+ function sameLinks(a, b) {
5532
+ return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
5533
+ }
5534
+ /**
5535
+ * The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
5536
+ * `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
5537
+ * records and a write is routed to the record of the broker they name.
5538
+ */
5539
+ var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
5540
+ var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
5541
+ function parseLinkFormKey(key) {
5542
+ for (const flag of ["exportTo", "mediaRole"]) {
5543
+ const prefix = `${flag}:`;
5544
+ if (key.startsWith(prefix) && key.length > prefix.length) return {
5545
+ flag,
5546
+ brokerId: key.slice(prefix.length)
5547
+ };
5548
+ }
5549
+ return null;
5420
5550
  }
5421
5551
  /** The form's dynamic keys carrying each instance's own value. */
5422
- function instanceOverlay(instances) {
5552
+ function linkFormOverlay(links) {
5423
5553
  const overlay = {};
5424
- for (const row of instances) {
5425
- if (row.brokerId === null) continue;
5426
- for (const { flag, keyOf } of KEYS) {
5427
- const value = row.settings[flag];
5428
- if (value !== void 0) overlay[keyOf(row.brokerId)] = value;
5429
- }
5554
+ for (const brokerId of links.brokerIds()) {
5555
+ overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
5556
+ overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
5430
5557
  }
5431
5558
  return overlay;
5432
5559
  }
5433
5560
  //#endregion
5561
+ //#region src/ha-export/migrations/bind-ports.ts
5562
+ /**
5563
+ * Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
5564
+ * `integrations` capability (the old home of the two flags, read by the migrations only) and the
5565
+ * `ha-export-links` kind.
5566
+ */
5567
+ function haExportMigrationPorts(deps) {
5568
+ return {
5569
+ readStored: deps.readStored,
5570
+ ...haInstanceSettingsPortsOver(deps.api.integrations),
5571
+ warn: deps.warn,
5572
+ putLinks: async (brokerId, body) => {
5573
+ if (await deps.links.get(brokerId) !== null) return "exists";
5574
+ try {
5575
+ await deps.links.put({
5576
+ id: brokerId,
5577
+ data: body
5578
+ }, { ifRev: 0 });
5579
+ } catch (err) {
5580
+ if (err instanceof RevConflictError) return "exists";
5581
+ throw err;
5582
+ }
5583
+ return "created";
5584
+ },
5585
+ readLinks: () => readAllLinks(deps.links)
5586
+ };
5587
+ }
5588
+ //#endregion
5589
+ //#region src/ha-export/membership.ts
5590
+ function devicesForBroker(membership, brokerId) {
5591
+ return membership[brokerId] ?? [];
5592
+ }
5593
+ function isExposed(membership, brokerId, deviceId) {
5594
+ return devicesForBroker(membership, brokerId).includes(deviceId);
5595
+ }
5596
+ /** Every broker a device reaches. Drives the N switches in the panel. */
5597
+ function brokersExposing(membership, deviceId) {
5598
+ return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
5599
+ }
5600
+ /** Every device exported to at least one of the given brokers. */
5601
+ function exposedDeviceIds(membership, brokerIds) {
5602
+ const ids = /* @__PURE__ */ new Set();
5603
+ for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
5604
+ return [...ids].sort();
5605
+ }
5606
+ function setExposed(membership, brokerId, deviceId, exposed) {
5607
+ const current = devicesForBroker(membership, brokerId);
5608
+ if (exposed === current.includes(deviceId)) return membership;
5609
+ const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
5610
+ return {
5611
+ ...membership,
5612
+ [brokerId]: next
5613
+ };
5614
+ }
5615
+ /** Same brokers, same order, same names — nothing for the form to re-render. */
5616
+ function sameBrokers(a, b) {
5617
+ if (a.length !== b.length) return false;
5618
+ return a.every((broker, index) => {
5619
+ const other = b[index];
5620
+ return other !== void 0 && other.id === broker.id && other.name === broker.name;
5621
+ });
5622
+ }
5623
+ //#endregion
5434
5624
  //#region src/ha-export/node-reads.ts
5435
5625
  var UNREAD = {
5436
5626
  footageUsedBytes: null,
@@ -7485,7 +7675,6 @@ var PTZ_MOVE_BY_DIRECTION = {
7485
7675
  };
7486
7676
  var DEFAULT_CONFIG = {
7487
7677
  reconcileIntervalSec: 300,
7488
- membership: {},
7489
7678
  knownBrokers: [],
7490
7679
  exportedEntities: {
7491
7680
  actions: [],
@@ -7598,10 +7787,17 @@ var HaExportAddon = class extends BaseAddon {
7598
7787
  super({ ...DEFAULT_CONFIG });
7599
7788
  }
7600
7789
  async onInitialize() {
7601
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, {
7790
+ const records = this.ctx.records;
7791
+ if (records === void 0) throw new Error("ha-export: records are not available");
7792
+ await records.declare({ "ha-export-links": haExportLinksKind });
7793
+ const linksHandle = records.kind(haExportLinksKind);
7794
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
7602
7795
  readStored: async () => this.resolveGlobalStore(),
7603
- ...this.instancePorts()
7604
- }));
7796
+ api: this.ctx.api,
7797
+ links: linksHandle,
7798
+ warn: (message) => this.ctx.logger.warn(message)
7799
+ })));
7800
+ this.linkBook = await ExportLinks.load(linksHandle);
7605
7801
  const secret = this.state("haExportMediaSecret", string(), "");
7606
7802
  const existing = await secret.get();
7607
7803
  if (existing.length === 0) {
@@ -7637,31 +7833,46 @@ var HaExportAddon = class extends BaseAddon {
7637
7833
  {
7638
7834
  capability: deviceExportCapability,
7639
7835
  provider: {
7640
- getStatus: async () => this.buildStatus(),
7641
- listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
7642
- listExposedDevices: async () => exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
7643
- deviceId,
7644
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
7836
+ getStatus: async (input) => this.buildStatus(this.brokerScope(statusInstanceOf(input))),
7837
+ listInstances: async () => this.config.knownBrokers.map((broker) => ({
7838
+ instanceId: broker.id,
7839
+ name: broker.name,
7840
+ enabled: this.enabledBrokerIds().includes(broker.id),
7841
+ kind: "home-assistant"
7645
7842
  })),
7646
- exposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, true),
7647
- unexposeDevice: async ({ deviceId }) => this.setMembershipEverywhere(deviceId, false),
7843
+ listSupportedDeviceKinds: async ({ instanceId }) => {
7844
+ this.brokerScope(instanceId);
7845
+ return [...SUPPORTED_DEVICE_KINDS];
7846
+ },
7847
+ listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
7848
+ exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
7849
+ unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
7648
7850
  listSupportedEntityKinds: async () => [
7649
7851
  "device",
7650
7852
  "action",
7651
7853
  "notifier"
7652
7854
  ],
7653
- listExposedEntities: async () => {
7855
+ listExposedEntities: async ({ instanceId }) => {
7856
+ const scope = this.brokerScope(instanceId);
7654
7857
  await exportedEntities.pruneDeleted();
7655
- return [...exposedDeviceIds(this.config.membership, this.enabledBrokerIds()).map((deviceId) => ({
7858
+ return [...this.exposedDeviceRows(scope).map((row) => ({
7656
7859
  entity: {
7657
7860
  kind: "device",
7658
- deviceId
7861
+ deviceId: row.deviceId
7659
7862
  },
7660
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
7863
+ exposedAs: row.exposedAs
7661
7864
  })), ...exportedEntities.list()];
7662
7865
  },
7663
- exposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, true) : exportedEntities.expose(entity),
7664
- unexposeEntity: async ({ entity }) => entity.kind === "device" ? this.setMembershipEverywhere(entity.deviceId, false) : exportedEntities.unexpose(entity),
7866
+ exposeEntity: async ({ instanceId, entity }) => {
7867
+ if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
7868
+ this.brokerScope(instanceId);
7869
+ return exportedEntities.expose(entity);
7870
+ },
7871
+ unexposeEntity: async ({ instanceId, entity }) => {
7872
+ if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
7873
+ this.brokerScope(instanceId);
7874
+ return exportedEntities.unexpose(entity);
7875
+ },
7665
7876
  getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
7666
7877
  getDeviceLiveContribution: async () => null,
7667
7878
  applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
@@ -7686,29 +7897,43 @@ var HaExportAddon = class extends BaseAddon {
7686
7897
  ];
7687
7898
  }
7688
7899
  /**
7689
- * The form of the addon shows each instance's own switch and media role: the values sit on its
7690
- * integration (D985), overlaid on the legacy keys the store may still hold.
7900
+ * The form of the addon shows each instance's own switch and media role. They are not settings
7901
+ * of this addon: the values come from the instance's `ha-export-links` record.
7691
7902
  */
7692
7903
  async getGlobalSettings(overlay, cap, nodeId) {
7693
- const rows = await listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
7694
7904
  return super.getGlobalSettings({
7695
7905
  ...overlay,
7696
- ...instanceOverlay(rows)
7906
+ ...linkFormOverlay(this.book())
7697
7907
  }, cap, nodeId);
7698
7908
  }
7699
7909
  /**
7700
- * An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
7701
- * (D985), everything else to the addon store as before. A key whose broker has no integration
7702
- * stays in the store.
7910
+ * An operator write: the `exportTo:` / `mediaRole:` keys go to the record of their broker,
7911
+ * everything else to the addon store as before. A key naming a broker this addon does not know
7912
+ * is refused, never parked.
7703
7913
  */
7704
7914
  async updateGlobalSettingsAsOperator(patch, nodeId) {
7705
- const ports = this.instancePorts();
7706
- const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
7707
- for (const [integrationId, flags] of toInstances) {
7708
- if (await ports.patchInstanceSettings(integrationId, flags)) continue;
7709
- throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
7915
+ const rest = {};
7916
+ const flags = /* @__PURE__ */ new Map();
7917
+ for (const [key, value] of Object.entries(patch)) {
7918
+ const field = parseLinkFormKey(key);
7919
+ if (field === null) {
7920
+ rest[key] = value;
7921
+ continue;
7922
+ }
7923
+ this.brokerScope(field.brokerId);
7924
+ const entry = flags.get(field.brokerId) ?? {};
7925
+ if (field.flag === "exportTo") {
7926
+ if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
7927
+ entry.exportTo = value;
7928
+ } else {
7929
+ const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
7930
+ if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
7931
+ entry.mediaRole = role;
7932
+ }
7933
+ flags.set(field.brokerId, entry);
7710
7934
  }
7711
- if (Object.keys(rest).length > 0 || toInstances.size === 0) {
7935
+ for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
7936
+ if (Object.keys(rest).length > 0 || flags.size === 0) {
7712
7937
  await super.updateGlobalSettingsAsOperator(rest, nodeId);
7713
7938
  return;
7714
7939
  }
@@ -8220,7 +8445,7 @@ var HaExportAddon = class extends BaseAddon {
8220
8445
  * asking per camera would be the fan-out this repo just finished
8221
8446
  * removing from a 300 s poll.
8222
8447
  */
8223
- const exposedIds = exposedDeviceIds(this.config.membership, enabled);
8448
+ const exposedIds = exposedDeviceIds(this.membership(), enabled);
8224
8449
  const cameraDeviceIds = [];
8225
8450
  for (const idStr of exposedIds) {
8226
8451
  const numericId = Number(idStr);
@@ -8241,7 +8466,7 @@ var HaExportAddon = class extends BaseAddon {
8241
8466
  const allCaps = /* @__PURE__ */ new Set();
8242
8467
  const exportedDeviceLog = [];
8243
8468
  let entityCount = 0;
8244
- for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
8469
+ for (const deviceIdStr of exposedDeviceIds(this.membership(), enabled)) {
8245
8470
  const numericId = Number(deviceIdStr);
8246
8471
  if (!Number.isFinite(numericId)) {
8247
8472
  this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
@@ -8286,7 +8511,7 @@ var HaExportAddon = class extends BaseAddon {
8286
8511
  const catalogDevice = built.device;
8287
8512
  if (catalogDevice === null) continue;
8288
8513
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
8289
- const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
8514
+ const brokerIds = brokersExposing(this.membership(), deviceIdStr).filter((id) => enabled.includes(id));
8290
8515
  /**
8291
8516
  * The plan is built ONCE and announced to every broker that
8292
8517
  * receives the device, so it can only use platforms ALL of them
@@ -9177,27 +9402,19 @@ var HaExportAddon = class extends BaseAddon {
9177
9402
  * is pruned on the next pass that returns a NON-empty list without it,
9178
9403
  * which costs one interval and cannot cost the operator their config.
9179
9404
  */
9180
- const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
9181
- 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) } });
9405
+ if (haBrokers.length === 0) {
9406
+ 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() } });
9407
+ } else {
9408
+ const gone = await this.book().prune(haBrokers.map((broker) => broker.id));
9409
+ if (gone.length > 0) this.ctx.logger.info("ha-export: forgot the export links of removed instances", { meta: { brokers: gone } });
9410
+ }
9182
9411
  /**
9183
- * ONE key per thing that actually changed — see `brokerRefreshPatch`.
9184
- *
9185
- * This used to write `knownBrokers` and `membership` together whenever
9186
- * EITHER differed. A boot-window settings read that answers empty leaves
9187
- * `this.config` on its constructor defaults, and that joint write then
9188
- * persisted `membership: {}` over the operator's real export list on a
9189
- * pass whose only news was the broker roster. It cost this hub three days
9190
- * of Home Assistant: 2026-08-24 21:55:13.
9412
+ * Only the roster is a setting; the membership is not, so a boot-window read that answers
9413
+ * empty can no longer erase it (2026-08-24: a refresh wrote `membership: {}` over the
9414
+ * operator's export list). The records are read at boot and a failed read stops it.
9191
9415
  */
9192
- const patch = brokerRefreshPatch({
9193
- knownBrokers: this.config.knownBrokers,
9194
- membership: this.config.membership
9195
- }, {
9196
- knownBrokers: known,
9197
- membership: pruned
9198
- });
9199
- if (patch !== null) await this.updateGlobalSettings(patch);
9200
- await this.refreshEnabledBrokers(known);
9416
+ if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
9417
+ this.refreshEnabledBrokers(known);
9201
9418
  const wanted = new Set(this.enabledBrokerIds());
9202
9419
  for (const [brokerId, link] of this.links) {
9203
9420
  if (wanted.has(brokerId)) continue;
@@ -9343,30 +9560,27 @@ var HaExportAddon = class extends BaseAddon {
9343
9560
  /**
9344
9561
  * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
9345
9562
  *
9346
- * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
9347
- * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
9348
- * it is `undefined` there for ever — see `enabledBrokerIds` in
9349
- * `membership.ts` for what that cost. The store is the authority; this is a
9350
- * read-through cache of it, refreshed at the top of every reconcile pass and
9351
- * before the per-device panel renders, never written to.
9563
+ * NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
9564
+ * record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
9565
+ * and before the per-device panel renders, never written to.
9352
9566
  */
9353
9567
  enabled = [];
9354
- async refreshEnabledBrokers(known) {
9355
- const store = await this.resolveGlobalStore();
9356
- this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
9568
+ /** The `ha-export-links` records, loaded at boot after the migrations. */
9569
+ linkBook = null;
9570
+ book() {
9571
+ if (this.linkBook === null) throw new Error("ha-export: the export links are not loaded yet");
9572
+ return this.linkBook;
9357
9573
  }
9358
- /** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
9359
- instancePorts() {
9360
- return haInstanceSettingsPortsOver(this.ctx.api.integrations);
9574
+ /** brokerId → exported device ids, from the records. */
9575
+ membership() {
9576
+ return this.book().membership();
9361
9577
  }
9362
- /**
9363
- * Each broker's integration settings (D985): the switch and the media role of an instance.
9364
- * A read that fails answers nothing, so every broker falls back to its legacy key and the
9365
- * failure is logged — never read as "everything off".
9366
- */
9367
- async readInstanceSettings() {
9368
- const rows = await 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 } }));
9369
- return rows.length === 0 ? NO_INSTANCE_SETTINGS : settingsByBroker(rows);
9578
+ /** Persist a membership change: each instance's record first, the in-memory copy after it. */
9579
+ async persistMembership(next) {
9580
+ await this.book().setMembership(next, (brokerId) => this.brokerName(brokerId));
9581
+ }
9582
+ refreshEnabledBrokers(known) {
9583
+ this.enabled = known.map((broker) => broker.id).filter((id) => this.book().isEnabled(id));
9370
9584
  }
9371
9585
  enabledBrokerIds() {
9372
9586
  return this.enabled;
@@ -9760,14 +9974,14 @@ var HaExportAddon = class extends BaseAddon {
9760
9974
  }
9761
9975
  /**
9762
9976
  * Each broker's media base, read per use: the palette's role the broker chose
9763
- * (`mediaRole:<brokerId>`, `internal` by default). A role that resolves to nothing
9977
+ * (the `ha-export-links` record's `mediaRole`, `internal` by default). A role that resolves to nothing
9764
9978
  * is `null` and says so — the image entities then carry no value, which reads as
9765
9979
  * empty rather than as a broken link.
9766
9980
  */
9767
9981
  async mediaBasesFor(brokerIds, deviceId) {
9768
9982
  let bases;
9769
9983
  try {
9770
- bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
9984
+ bases = mediaBasesByBroker(brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), (brokerId) => this.book().mediaRole(brokerId));
9771
9985
  } catch (err) {
9772
9986
  this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9773
9987
  tags: { deviceId },
@@ -9785,29 +9999,53 @@ var HaExportAddon = class extends BaseAddon {
9785
9999
  return bases;
9786
10000
  }
9787
10001
  /**
9788
- * The cap-level expose/unexpose applies to EVERY enabled broker.
9789
- *
9790
- * The `device-export` cap is the COMMON surface across export targets
9791
- * and has no notion of a broker; per-broker granularity lives in the
9792
- * derived Export panel, over the same store. There is exactly one
9793
- * authority either way.
10002
+ * Every broker this addon knows, enabled or not: a membership write may
10003
+ * target a broker that is switched off (its devices resume when it is turned
10004
+ * back on). A cold `knownBrokers` still counts the enabled ids.
10005
+ */
10006
+ knownBrokerIds() {
10007
+ return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
10008
+ }
10009
+ /**
10010
+ * The brokers a `device-export` read answers over: the named instance, or
10011
+ * (absent) every enabled broker. A broker this addon does not know is
10012
+ * refused, never answered empty.
10013
+ */
10014
+ brokerScope(instanceId) {
10015
+ if (instanceId === void 0) return this.enabledBrokerIds();
10016
+ if (!this.knownBrokerIds().has(instanceId)) throw new ExportInstanceUnknownError("provider-homeassistant", instanceId);
10017
+ return [instanceId];
10018
+ }
10019
+ exposedDeviceRows(scope) {
10020
+ return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
10021
+ deviceId,
10022
+ exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
10023
+ }));
10024
+ }
10025
+ /**
10026
+ * A cap-level expose/unexpose changes ONE broker's membership — the named
10027
+ * instance. There is no write without a target and no "everywhere": the
10028
+ * other brokers' membership is not touched.
9794
10029
  */
9795
- async setMembershipEverywhere(deviceId, exposed) {
9796
- let next = this.config.membership;
9797
- for (const brokerId of this.enabledBrokerIds()) next = setExposed(next, brokerId, deviceId, exposed);
9798
- if (next === this.config.membership) {
10030
+ async setBrokerMembership(instanceId, deviceId, exposed) {
10031
+ this.brokerScope(instanceId);
10032
+ const next = setExposed(this.membership(), instanceId, deviceId, exposed);
10033
+ if (next === this.membership()) {
9799
10034
  this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
9800
10035
  tags: { deviceId: Number(deviceId) },
9801
- meta: { exposed }
10036
+ meta: {
10037
+ exposed,
10038
+ brokerId: instanceId
10039
+ }
9802
10040
  });
9803
10041
  return;
9804
10042
  }
9805
- await this.updateGlobalSettings({ membership: next });
10043
+ await this.persistMembership(next);
9806
10044
  this.ctx.logger.info("ha-export: export membership changed", {
9807
10045
  tags: { deviceId: Number(deviceId) },
9808
10046
  meta: {
9809
10047
  exposed,
9810
- brokers: this.enabledBrokerIds()
10048
+ brokerId: instanceId
9811
10049
  }
9812
10050
  });
9813
10051
  await this.reconcile(exposed ? "expose" : "unexpose");
@@ -9815,7 +10053,7 @@ var HaExportAddon = class extends BaseAddon {
9815
10053
  globalSettingsSchema() {
9816
10054
  const brokerFields = this.config.knownBrokers.map((broker) => ({
9817
10055
  type: "boolean",
9818
- key: `exportTo:${broker.id}`,
10056
+ key: exportToFormKey(broker.id),
9819
10057
  label: broker.name,
9820
10058
  style: "switch",
9821
10059
  default: false
@@ -9844,7 +10082,7 @@ var HaExportAddon = class extends BaseAddon {
9844
10082
  columns: 1,
9845
10083
  fields: this.config.knownBrokers.map((broker) => ({
9846
10084
  type: "select",
9847
- key: mediaRoleKey(broker.id),
10085
+ key: mediaRoleFormKey(broker.id),
9848
10086
  label: `Image links for ${broker.name}`,
9849
10087
  options: [{
9850
10088
  value: "internal",
@@ -9885,7 +10123,7 @@ var HaExportAddon = class extends BaseAddon {
9885
10123
  * exporter's value in this one's toggle.
9886
10124
  */
9887
10125
  async buildContribution(deviceId) {
9888
- await this.refreshEnabledBrokers(this.config.knownBrokers);
10126
+ this.refreshEnabledBrokers(this.config.knownBrokers);
9889
10127
  const idStr = String(deviceId);
9890
10128
  const enabled = this.enabledBrokerIds();
9891
10129
  const fields = enabled.map((brokerId) => ({
@@ -9893,7 +10131,7 @@ var HaExportAddon = class extends BaseAddon {
9893
10131
  key: `haExport:${deviceId}:${brokerId}`,
9894
10132
  label: `Export to ${this.brokerName(brokerId)}`,
9895
10133
  style: "switch",
9896
- value: isExposed(this.config.membership, brokerId, idStr),
10134
+ value: isExposed(this.membership(), brokerId, idStr),
9897
10135
  immediate: true
9898
10136
  }));
9899
10137
  return {
@@ -9923,7 +10161,7 @@ var HaExportAddon = class extends BaseAddon {
9923
10161
  }
9924
10162
  async applyDeviceSettingsPatch(deviceId, patch) {
9925
10163
  const idStr = String(deviceId);
9926
- let next = this.config.membership;
10164
+ let next = this.membership();
9927
10165
  let touched = false;
9928
10166
  /**
9929
10167
  * Driven by the PATCH KEYS, not by the enabled-broker list.
@@ -9953,7 +10191,7 @@ var HaExportAddon = class extends BaseAddon {
9953
10191
  });
9954
10192
  return { success: true };
9955
10193
  }
9956
- await this.updateGlobalSettings({ membership: next });
10194
+ await this.persistMembership(next);
9957
10195
  this.ctx.logger.info("ha-export: per-broker export membership changed", {
9958
10196
  tags: { deviceId },
9959
10197
  meta: { brokers: brokersExposing(next, idStr) }
@@ -9961,19 +10199,20 @@ var HaExportAddon = class extends BaseAddon {
9961
10199
  await this.reconcile("device-panel");
9962
10200
  return { success: true };
9963
10201
  }
9964
- buildStatus() {
9965
- const enabled = this.enabledBrokerIds();
9966
- const healthy = [...this.links.values()].filter((link) => link.client.healthy).length;
9967
- const linkFailure = [...this.links.values()].map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
10202
+ buildStatus(scope) {
10203
+ const enabled = scope;
10204
+ const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
10205
+ const healthy = links.filter((link) => link.client.healthy).length;
10206
+ const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
9968
10207
  return {
9969
- linkState: enabled.length === 0 ? "unlinked" : healthy === this.links.size ? "linked" : "error",
9970
- exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
10208
+ linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10209
+ exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
9971
10210
  ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
9972
10211
  setup: {
9973
10212
  fields: [
9974
10213
  {
9975
10214
  label: "Home Assistant instances",
9976
- value: `${healthy} / ${this.links.size} reachable`
10215
+ value: `${healthy} / ${links.length} reachable`
9977
10216
  },
9978
10217
  {
9979
10218
  label: "Last push failure",