@camstack/addon-provider-homeassistant 1.2.655 → 1.2.657

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 baseUnitOf, At as runExportedAction, Cn as EventCategory, E as addonRoutesCapability, Et as pruneDeletedEntities, G as consumableFormOf, It as sendToExportedTarget, K as consumableKindOf, L as buildAddonRouteProvider, Q as deviceExportCapability, Rt as statusInstanceOf, St as preconditionOf, Xt as BaseAddon, _ as ExportInstanceUnknownError, _n as unknown, an as isReadingQuantity, b as MediaPlayerRepeatSchema, cn as array, d as COCO_TO_MACRO, f as CameraSwitchIdSchema, h as ConsumableStatusSchema, hn as string, m as ConsumableItemSchema, n as listInstancesOrNothing, o as listHomeAssistantBrokers, on as nodePin, p as ConsumableFormSchema, pn as object, r as settingsByBroker, s as AlarmArmModeSchema, t as haInstanceSettingsPortsOver, tn as defineAddonMigrations, u as CAMERA_SWITCH_ORDER, v as FanDirectionSchema, x as PLUG_APPLIANCE_TYPES, y as HvacModeSchema, yt as oauthIntegrationCapability } from "../ha-instance-settings-DLDNOmgL.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-CxalgfRa.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) {
@@ -7701,29 +7897,43 @@ var HaExportAddon = class extends BaseAddon {
7701
7897
  ];
7702
7898
  }
7703
7899
  /**
7704
- * The form of the addon shows each instance's own switch and media role: the values sit on its
7705
- * 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.
7706
7902
  */
7707
7903
  async getGlobalSettings(overlay, cap, nodeId) {
7708
- 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 } }));
7709
7904
  return super.getGlobalSettings({
7710
7905
  ...overlay,
7711
- ...instanceOverlay(rows)
7906
+ ...linkFormOverlay(this.book())
7712
7907
  }, cap, nodeId);
7713
7908
  }
7714
7909
  /**
7715
- * An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
7716
- * (D985), everything else to the addon store as before. A key whose broker has no integration
7717
- * 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.
7718
7913
  */
7719
7914
  async updateGlobalSettingsAsOperator(patch, nodeId) {
7720
- const ports = this.instancePorts();
7721
- const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
7722
- for (const [integrationId, flags] of toInstances) {
7723
- if (await ports.patchInstanceSettings(integrationId, flags)) continue;
7724
- 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);
7725
7934
  }
7726
- 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) {
7727
7937
  await super.updateGlobalSettingsAsOperator(rest, nodeId);
7728
7938
  return;
7729
7939
  }
@@ -8235,7 +8445,7 @@ var HaExportAddon = class extends BaseAddon {
8235
8445
  * asking per camera would be the fan-out this repo just finished
8236
8446
  * removing from a 300 s poll.
8237
8447
  */
8238
- const exposedIds = exposedDeviceIds(this.config.membership, enabled);
8448
+ const exposedIds = exposedDeviceIds(this.membership(), enabled);
8239
8449
  const cameraDeviceIds = [];
8240
8450
  for (const idStr of exposedIds) {
8241
8451
  const numericId = Number(idStr);
@@ -8256,7 +8466,7 @@ var HaExportAddon = class extends BaseAddon {
8256
8466
  const allCaps = /* @__PURE__ */ new Set();
8257
8467
  const exportedDeviceLog = [];
8258
8468
  let entityCount = 0;
8259
- for (const deviceIdStr of exposedDeviceIds(this.config.membership, enabled)) {
8469
+ for (const deviceIdStr of exposedDeviceIds(this.membership(), enabled)) {
8260
8470
  const numericId = Number(deviceIdStr);
8261
8471
  if (!Number.isFinite(numericId)) {
8262
8472
  this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
@@ -8301,7 +8511,7 @@ var HaExportAddon = class extends BaseAddon {
8301
8511
  const catalogDevice = built.device;
8302
8512
  if (catalogDevice === null) continue;
8303
8513
  for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
8304
- const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
8514
+ const brokerIds = brokersExposing(this.membership(), deviceIdStr).filter((id) => enabled.includes(id));
8305
8515
  /**
8306
8516
  * The plan is built ONCE and announced to every broker that
8307
8517
  * receives the device, so it can only use platforms ALL of them
@@ -9192,27 +9402,19 @@ var HaExportAddon = class extends BaseAddon {
9192
9402
  * is pruned on the next pass that returns a NON-empty list without it,
9193
9403
  * which costs one interval and cannot cost the operator their config.
9194
9404
  */
9195
- const pruned = haBrokers.length === 0 ? this.config.membership : pruneBrokers(this.config.membership, haBrokers.map((broker) => broker.id));
9196
- 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
+ }
9197
9411
  /**
9198
- * ONE key per thing that actually changed — see `brokerRefreshPatch`.
9199
- *
9200
- * This used to write `knownBrokers` and `membership` together whenever
9201
- * EITHER differed. A boot-window settings read that answers empty leaves
9202
- * `this.config` on its constructor defaults, and that joint write then
9203
- * persisted `membership: {}` over the operator's real export list on a
9204
- * pass whose only news was the broker roster. It cost this hub three days
9205
- * 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.
9206
9415
  */
9207
- const patch = brokerRefreshPatch({
9208
- knownBrokers: this.config.knownBrokers,
9209
- membership: this.config.membership
9210
- }, {
9211
- knownBrokers: known,
9212
- membership: pruned
9213
- });
9214
- if (patch !== null) await this.updateGlobalSettings(patch);
9215
- await this.refreshEnabledBrokers(known);
9416
+ if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
9417
+ this.refreshEnabledBrokers(known);
9216
9418
  const wanted = new Set(this.enabledBrokerIds());
9217
9419
  for (const [brokerId, link] of this.links) {
9218
9420
  if (wanted.has(brokerId)) continue;
@@ -9358,30 +9560,27 @@ var HaExportAddon = class extends BaseAddon {
9358
9560
  /**
9359
9561
  * The brokers switched on, as of the last {@link refreshEnabledBrokers}.
9360
9562
  *
9361
- * NOT read from `this.config`: `exportTo:<brokerId>` is a dynamic key and
9362
- * `BaseAddon.resolveConfig` copies only keys present in `DEFAULT_CONFIG`, so
9363
- * it is `undefined` there for ever — see `enabledBrokerIds` in
9364
- * `membership.ts` for what that cost. The store is the authority; this is a
9365
- * read-through cache of it, refreshed at the top of every reconcile pass and
9366
- * 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.
9367
9566
  */
9368
9567
  enabled = [];
9369
- async refreshEnabledBrokers(known) {
9370
- const store = await this.resolveGlobalStore();
9371
- 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;
9372
9573
  }
9373
- /** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
9374
- instancePorts() {
9375
- return haInstanceSettingsPortsOver(this.ctx.api.integrations);
9574
+ /** brokerId → exported device ids, from the records. */
9575
+ membership() {
9576
+ return this.book().membership();
9376
9577
  }
9377
- /**
9378
- * Each broker's integration settings (D985): the switch and the media role of an instance.
9379
- * A read that fails answers nothing, so every broker falls back to its legacy key and the
9380
- * failure is logged — never read as "everything off".
9381
- */
9382
- async readInstanceSettings() {
9383
- 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 } }));
9384
- 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));
9385
9584
  }
9386
9585
  enabledBrokerIds() {
9387
9586
  return this.enabled;
@@ -9775,14 +9974,14 @@ var HaExportAddon = class extends BaseAddon {
9775
9974
  }
9776
9975
  /**
9777
9976
  * Each broker's media base, read per use: the palette's role the broker chose
9778
- * (`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
9779
9978
  * is `null` and says so — the image entities then carry no value, which reads as
9780
9979
  * empty rather than as a broken link.
9781
9980
  */
9782
9981
  async mediaBasesFor(brokerIds, deviceId) {
9783
9982
  let bases;
9784
9983
  try {
9785
- 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));
9786
9985
  } catch (err) {
9787
9986
  this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9788
9987
  tags: { deviceId },
@@ -9818,9 +10017,9 @@ var HaExportAddon = class extends BaseAddon {
9818
10017
  return [instanceId];
9819
10018
  }
9820
10019
  exposedDeviceRows(scope) {
9821
- return exposedDeviceIds(this.config.membership, scope).map((deviceId) => ({
10020
+ return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
9822
10021
  deviceId,
9823
- exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
10022
+ exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
9824
10023
  }));
9825
10024
  }
9826
10025
  /**
@@ -9830,8 +10029,8 @@ var HaExportAddon = class extends BaseAddon {
9830
10029
  */
9831
10030
  async setBrokerMembership(instanceId, deviceId, exposed) {
9832
10031
  this.brokerScope(instanceId);
9833
- const next = setExposed(this.config.membership, instanceId, deviceId, exposed);
9834
- if (next === this.config.membership) {
10032
+ const next = setExposed(this.membership(), instanceId, deviceId, exposed);
10033
+ if (next === this.membership()) {
9835
10034
  this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
9836
10035
  tags: { deviceId: Number(deviceId) },
9837
10036
  meta: {
@@ -9841,7 +10040,7 @@ var HaExportAddon = class extends BaseAddon {
9841
10040
  });
9842
10041
  return;
9843
10042
  }
9844
- await this.updateGlobalSettings({ membership: next });
10043
+ await this.persistMembership(next);
9845
10044
  this.ctx.logger.info("ha-export: export membership changed", {
9846
10045
  tags: { deviceId: Number(deviceId) },
9847
10046
  meta: {
@@ -9854,7 +10053,7 @@ var HaExportAddon = class extends BaseAddon {
9854
10053
  globalSettingsSchema() {
9855
10054
  const brokerFields = this.config.knownBrokers.map((broker) => ({
9856
10055
  type: "boolean",
9857
- key: `exportTo:${broker.id}`,
10056
+ key: exportToFormKey(broker.id),
9858
10057
  label: broker.name,
9859
10058
  style: "switch",
9860
10059
  default: false
@@ -9883,7 +10082,7 @@ var HaExportAddon = class extends BaseAddon {
9883
10082
  columns: 1,
9884
10083
  fields: this.config.knownBrokers.map((broker) => ({
9885
10084
  type: "select",
9886
- key: mediaRoleKey(broker.id),
10085
+ key: mediaRoleFormKey(broker.id),
9887
10086
  label: `Image links for ${broker.name}`,
9888
10087
  options: [{
9889
10088
  value: "internal",
@@ -9924,7 +10123,7 @@ var HaExportAddon = class extends BaseAddon {
9924
10123
  * exporter's value in this one's toggle.
9925
10124
  */
9926
10125
  async buildContribution(deviceId) {
9927
- await this.refreshEnabledBrokers(this.config.knownBrokers);
10126
+ this.refreshEnabledBrokers(this.config.knownBrokers);
9928
10127
  const idStr = String(deviceId);
9929
10128
  const enabled = this.enabledBrokerIds();
9930
10129
  const fields = enabled.map((brokerId) => ({
@@ -9932,7 +10131,7 @@ var HaExportAddon = class extends BaseAddon {
9932
10131
  key: `haExport:${deviceId}:${brokerId}`,
9933
10132
  label: `Export to ${this.brokerName(brokerId)}`,
9934
10133
  style: "switch",
9935
- value: isExposed(this.config.membership, brokerId, idStr),
10134
+ value: isExposed(this.membership(), brokerId, idStr),
9936
10135
  immediate: true
9937
10136
  }));
9938
10137
  return {
@@ -9962,7 +10161,7 @@ var HaExportAddon = class extends BaseAddon {
9962
10161
  }
9963
10162
  async applyDeviceSettingsPatch(deviceId, patch) {
9964
10163
  const idStr = String(deviceId);
9965
- let next = this.config.membership;
10164
+ let next = this.membership();
9966
10165
  let touched = false;
9967
10166
  /**
9968
10167
  * Driven by the PATCH KEYS, not by the enabled-broker list.
@@ -9992,7 +10191,7 @@ var HaExportAddon = class extends BaseAddon {
9992
10191
  });
9993
10192
  return { success: true };
9994
10193
  }
9995
- await this.updateGlobalSettings({ membership: next });
10194
+ await this.persistMembership(next);
9996
10195
  this.ctx.logger.info("ha-export: per-broker export membership changed", {
9997
10196
  tags: { deviceId },
9998
10197
  meta: { brokers: brokersExposing(next, idStr) }
@@ -10007,7 +10206,7 @@ var HaExportAddon = class extends BaseAddon {
10007
10206
  const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
10008
10207
  return {
10009
10208
  linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
10010
- exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
10209
+ exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
10011
10210
  ...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
10012
10211
  setup: {
10013
10212
  fields: [