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