@camstack/addon-provider-homeassistant 1.2.653 → 1.2.654

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 { B as consumableFormOf, Gt as defineAddonMigrations, M as buildAddonRouteProvider, Ot as sendToExportedTarget, Qt as array, St as runExportedAction, Ut as baseUnitOf, V as consumableKindOf, Vt as BaseAddon, Xt as nodePin, Yt as isReadingQuantity, _ as PLUG_APPLIANCE_TYPES, an as string, c as COCO_TO_MACRO, d as ConsumableItemSchema, dt as oauthIntegrationCapability, f as ConsumableStatusSchema, g as MediaPlayerRepeatSchema, h as HvacModeSchema, i as AlarmArmModeSchema, l as CameraSwitchIdSchema, m as FanDirectionSchema, mt as preconditionOf, pn as EventCategory, q as deviceExportCapability, r as listHomeAssistantBrokers, rn as object, s as CAMERA_SWITCH_ORDER, sn as unknown, u as ConsumableFormSchema, vt as pruneDeletedEntities, x as addonRoutesCapability } from "../ha-broker-source-CKdH9XHn.mjs";
1
+ import { $t as nodePin, F as buildAddonRouteProvider, Jt as defineAddonMigrations, Kt as baseUnitOf, Qt as isReadingQuantity, Tt as runExportedAction, U as consumableFormOf, W as consumableKindOf, Wt as BaseAddon, X as deviceExportCapability, _ as FanDirectionSchema, _t as preconditionOf, b as PLUG_APPLIANCE_TYPES, cn as string, d as COCO_TO_MACRO, f as CameraSwitchIdSchema, gn as EventCategory, h as ConsumableStatusSchema, jt as sendToExportedTarget, m as ConsumableItemSchema, mt as oauthIntegrationCapability, n as listInstancesOrNothing, o as listHomeAssistantBrokers, on as object, p as ConsumableFormSchema, r as settingsByBroker, s as AlarmArmModeSchema, t as haInstanceSettingsPortsOver, tn as array, u as CAMERA_SWITCH_ORDER, un as unknown, v as HvacModeSchema, w as addonRoutesCapability, xt as pruneDeletedEntities, y as MediaPlayerRepeatSchema } from "../ha-instance-settings-g5pwb3Fi.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -4973,25 +4973,6 @@ async function planDropPublicBaseUrl(ports) {
4973
4973
  }])))
4974
4974
  }] };
4975
4975
  }
4976
- //#endregion
4977
- //#region src/ha-export/migrations/index.ts
4978
- /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
4979
- var ADDON_MIGRATIONS = [{
4980
- meta: {
4981
- id: "homeassistant-export/2026-11-02-drop-public-base-url",
4982
- owner: "homeassistant-export",
4983
- level: "addon-state",
4984
- seq: 1,
4985
- dependsOn: [],
4986
- store: "settings",
4987
- scope: "cluster",
4988
- onFailure: "tolerate",
4989
- bodyKind: "plan",
4990
- description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
4991
- fixtureMark: "the reconcile interval the migration does not know"
4992
- },
4993
- plan: planDropPublicBaseUrl
4994
- }];
4995
4976
  process.platform, process.env["SystemRoot"];
4996
4977
  /**
4997
4978
  * The ONE derivation of "a signed, expiring URL".
@@ -5047,65 +5028,6 @@ function verifyExpiringUrl(input) {
5047
5028
  return timingSafeEqual(a, b);
5048
5029
  }
5049
5030
  //#endregion
5050
- //#region src/ha-export/media-url.ts
5051
- /**
5052
- * Signed, expiring media links.
5053
- *
5054
- * Images never cross the push transport. Home Assistant is handed a URL
5055
- * and fetches the bytes itself, on demand, which is what keeps a camera
5056
- * with a `last_image` entity from turning every detection into a
5057
- * multi-megabyte POST.
5058
- *
5059
- * The link is signed rather than protected by a session, because the
5060
- * fetcher is HA's own frontend or a phone — neither of which holds a
5061
- * camstack token.
5062
- *
5063
- * The HMAC itself is NOT restated here. It used to be — this module and the
5064
- * notification artifact plane each carried their own copy, because addons never
5065
- * import each other and neither could reach the other's. That reasoning was
5066
- * right and the conclusion was wrong: both now delegate to the single
5067
- * derivation in `@camstack/types/node`, which every addon may depend on. What
5068
- * stays local is policy — the TTL below, and the id this plane signs.
5069
- *
5070
- * Expiry is checked BEFORE the constant-time compare (in the shared verifier):
5071
- * an expired link is refused whether or not its signature is valid, so a leaked
5072
- * URL stops working on its own.
5073
- */
5074
- /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5075
- function mediaRoleKey(brokerId) {
5076
- return `mediaRole:${brokerId}`;
5077
- }
5078
- /** A broker's chosen role; anything stored that is not a known role is `internal`. */
5079
- function haMediaRoleOf(store, brokerId) {
5080
- return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
5081
- }
5082
- /**
5083
- * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5084
- * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5085
- * when the role resolves to nothing): a link Home Assistant cannot open is worse
5086
- * than no image.
5087
- */
5088
- function haMediaBaseUrl(book, role = "internal") {
5089
- return book.roles[role].resolved;
5090
- }
5091
- /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5092
- function mediaBasesByBroker(store, brokerIds, book) {
5093
- return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
5094
- }
5095
- /** How long a minted link stays valid. Long enough for HA to render it. */
5096
- var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
5097
- function signMedia(secret, id, expMs) {
5098
- return signExpiringUrl(secret, id, expMs);
5099
- }
5100
- function buildMediaUrl(input) {
5101
- const base = input.baseUrl.replace(/\/+$/, "");
5102
- const sig = signMedia(input.secret, input.id, input.expMs);
5103
- return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
5104
- }
5105
- function verifyMediaSignature(input) {
5106
- return verifyExpiringUrl(input);
5107
- }
5108
- //#endregion
5109
5031
  //#region src/ha-export/membership.ts
5110
5032
  function devicesForBroker(membership, brokerId) {
5111
5033
  return membership[brokerId] ?? [];
@@ -5167,10 +5089,19 @@ function pruneBrokers(membership, knownBrokerIds) {
5167
5089
  *
5168
5090
  * A broker that is no longer known is not enabled, whatever the store says:
5169
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.
5170
5097
  */
5171
- function enabledBrokerIds(store, knownBrokerIds) {
5172
- return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
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
+ });
5173
5103
  }
5104
+ var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
5174
5105
  /** Same brokers, same order, same names — nothing for the form to re-render. */
5175
5106
  function sameBrokers(a, b) {
5176
5107
  if (a.length !== b.length) return false;
@@ -5224,6 +5155,282 @@ function brokerRefreshPatch(current, next) {
5224
5155
  };
5225
5156
  }
5226
5157
  //#endregion
5158
+ //#region src/ha-export/media-url.ts
5159
+ /**
5160
+ * Signed, expiring media links.
5161
+ *
5162
+ * Images never cross the push transport. Home Assistant is handed a URL
5163
+ * and fetches the bytes itself, on demand, which is what keeps a camera
5164
+ * with a `last_image` entity from turning every detection into a
5165
+ * multi-megabyte POST.
5166
+ *
5167
+ * The link is signed rather than protected by a session, because the
5168
+ * fetcher is HA's own frontend or a phone — neither of which holds a
5169
+ * camstack token.
5170
+ *
5171
+ * The HMAC itself is NOT restated here. It used to be — this module and the
5172
+ * notification artifact plane each carried their own copy, because addons never
5173
+ * import each other and neither could reach the other's. That reasoning was
5174
+ * right and the conclusion was wrong: both now delegate to the single
5175
+ * derivation in `@camstack/types/node`, which every addon may depend on. What
5176
+ * stays local is policy — the TTL below, and the id this plane signs.
5177
+ *
5178
+ * Expiry is checked BEFORE the constant-time compare (in the shared verifier):
5179
+ * an expired link is refused whether or not its signature is valid, so a leaked
5180
+ * URL stops working on its own.
5181
+ */
5182
+ /** The hub address roles a Home Assistant media link may be minted on. */
5183
+ 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
+ /**
5198
+ * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5199
+ * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5200
+ * when the role resolves to nothing): a link Home Assistant cannot open is worse
5201
+ * than no image.
5202
+ */
5203
+ function haMediaBaseUrl(book, role = "internal") {
5204
+ return book.roles[role].resolved;
5205
+ }
5206
+ /** 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))]));
5209
+ }
5210
+ /** How long a minted link stays valid. Long enough for HA to render it. */
5211
+ var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
5212
+ function signMedia(secret, id, expMs) {
5213
+ return signExpiringUrl(secret, id, expMs);
5214
+ }
5215
+ function buildMediaUrl(input) {
5216
+ const base = input.baseUrl.replace(/\/+$/, "");
5217
+ const sig = signMedia(input.secret, input.id, input.expMs);
5218
+ return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
5219
+ }
5220
+ function verifyMediaSignature(input) {
5221
+ return verifyExpiringUrl(input);
5222
+ }
5223
+ //#endregion
5224
+ //#region src/ha-export/migrations/per-broker-flags.ts
5225
+ /**
5226
+ * Whether the export reaches a Home Assistant instance, and which hub address role its image
5227
+ * links use, are facts of THAT instance (D985): they live in the instance's integration settings
5228
+ * as `exportTo` and `mediaRole`, not in the addon store as one dynamic key per broker
5229
+ * (`exportTo:<brokerId>`, `mediaRole:<brokerId>`).
5230
+ *
5231
+ * Two migrations:
5232
+ *
5233
+ * 1. `per-broker-flags-to-integration` (procedure) copies each legacy key to the integration that
5234
+ * links its broker. A flag the integration already has is never overwritten; a key whose
5235
+ * broker has no integration any more (a deleted instance) is skipped, never recreated; two
5236
+ * integrations linking one broker are an ambiguity and throw; a value that is not a boolean /
5237
+ * a known role is skipped (the reader already treats it as off / `internal`).
5238
+ * 2. `drop-per-broker-flags` (plan) deletes a legacy key only after a read-back: the integration
5239
+ * must show the same flag. A key the integration disagrees with (its own value was there
5240
+ * first) or whose broker has no integration stays PARKED in place, never deleted.
5241
+ *
5242
+ * Both are idempotent.
5243
+ */
5244
+ var PREFIX = {
5245
+ exportTo: "exportTo:",
5246
+ mediaRole: "mediaRole:"
5247
+ };
5248
+ var validValue = (flag, value) => flag === "exportTo" ? typeof value === "boolean" : typeof value === "string" && HA_MEDIA_ROLES.some((role) => role === value);
5249
+ /** Every legacy key with a value the reader understands, per broker. */
5250
+ function legacyFlags(stored) {
5251
+ const out = [];
5252
+ for (const [storeKey, value] of Object.entries(stored)) for (const flag of ["exportTo", "mediaRole"]) {
5253
+ const prefix = PREFIX[flag];
5254
+ if (!storeKey.startsWith(prefix) || storeKey.length === prefix.length) continue;
5255
+ if (validValue(flag, value)) out.push({
5256
+ storeKey,
5257
+ brokerId: storeKey.slice(prefix.length),
5258
+ flag,
5259
+ value
5260
+ });
5261
+ }
5262
+ return out;
5263
+ }
5264
+ /** The one integration per broker; two integrations linking one broker are an ambiguity. */
5265
+ function instanceByBroker(rows) {
5266
+ const out = /* @__PURE__ */ new Map();
5267
+ for (const row of rows) {
5268
+ if (row.brokerId === null) continue;
5269
+ const other = out.get(row.brokerId);
5270
+ if (other !== void 0) throw new Error(`homeassistant-export: integrations ${other.integrationId} and ${row.integrationId} both link broker ${row.brokerId} — nothing copied`);
5271
+ out.set(row.brokerId, row);
5272
+ }
5273
+ return out;
5274
+ }
5275
+ async function copyFlagsToIntegrations(ports) {
5276
+ const legacy = legacyFlags(await ports.readStored());
5277
+ if (legacy.length === 0) return {
5278
+ outcome: "already-done",
5279
+ summary: { reason: "no-legacy-flags" }
5280
+ };
5281
+ const byBroker = instanceByBroker(await ports.listInstances());
5282
+ const pending = /* @__PURE__ */ new Map();
5283
+ let kept = 0;
5284
+ let skipped = 0;
5285
+ for (const entry of legacy) {
5286
+ const instance = byBroker.get(entry.brokerId);
5287
+ if (instance === void 0) {
5288
+ skipped += 1;
5289
+ continue;
5290
+ }
5291
+ if (entry.flag in instance.settings) {
5292
+ kept += 1;
5293
+ continue;
5294
+ }
5295
+ pending.set(instance.integrationId, {
5296
+ ...pending.get(instance.integrationId),
5297
+ [entry.flag]: entry.value
5298
+ });
5299
+ }
5300
+ let copied = 0;
5301
+ let gone = 0;
5302
+ for (const [integrationId, patch] of pending) if (await ports.patchInstanceSettings(integrationId, patch)) copied += Object.keys(patch).length;
5303
+ else gone += 1;
5304
+ return {
5305
+ outcome: copied === 0 ? "already-done" : "done",
5306
+ summary: {
5307
+ copied,
5308
+ kept,
5309
+ skipped,
5310
+ gone
5311
+ }
5312
+ };
5313
+ }
5314
+ async function planDropPerBrokerFlags(ports) {
5315
+ const legacy = legacyFlags(await ports.readStored());
5316
+ if (legacy.length === 0) return { edits: [] };
5317
+ const byBroker = instanceByBroker(await ports.listInstances());
5318
+ const rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
5319
+ const drop = [];
5320
+ for (const entry of legacy) {
5321
+ const instance = byBroker.get(entry.brokerId);
5322
+ if (instance === void 0) continue;
5323
+ if (!(entry.flag in instance.settings)) throw new Error(`homeassistant-export: ${entry.storeKey} is not dropped, integration ${instance.integrationId} does not show ${entry.flag}`);
5324
+ if (instance.settings[entry.flag] === entry.value) drop.push(entry);
5325
+ }
5326
+ if (drop.length === 0) return { edits: [] };
5327
+ return { edits: [{
5328
+ op: "deleteKeys",
5329
+ scope: ADDON_SETTINGS_TABLE,
5330
+ keys: drop.map((entry) => rowId(entry.storeKey)),
5331
+ precondition: preconditionOf(Object.fromEntries(drop.map((entry) => [rowId(entry.storeKey), {
5332
+ addonId: HA_EXPORT_ADDON_ID,
5333
+ key: entry.storeKey,
5334
+ value: entry.value
5335
+ }])))
5336
+ }] };
5337
+ }
5338
+ //#endregion
5339
+ //#region src/ha-export/migrations/index.ts
5340
+ /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5341
+ var ADDON_MIGRATIONS = [
5342
+ {
5343
+ meta: {
5344
+ id: "homeassistant-export/2026-11-02-drop-public-base-url",
5345
+ owner: "homeassistant-export",
5346
+ level: "addon-state",
5347
+ seq: 1,
5348
+ dependsOn: [],
5349
+ store: "settings",
5350
+ scope: "cluster",
5351
+ onFailure: "tolerate",
5352
+ bodyKind: "plan",
5353
+ description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
5354
+ fixtureMark: "the reconcile interval the migration does not know"
5355
+ },
5356
+ plan: planDropPublicBaseUrl
5357
+ },
5358
+ {
5359
+ meta: {
5360
+ id: "homeassistant-export/2026-11-03-per-broker-flags-to-integration",
5361
+ owner: "homeassistant-export",
5362
+ level: "addon-state",
5363
+ seq: 2,
5364
+ dependsOn: [],
5365
+ store: "settings",
5366
+ scope: "cluster",
5367
+ onFailure: "tolerate",
5368
+ bodyKind: "procedure",
5369
+ description: "Copy each exportTo:<broker> and mediaRole:<broker> key to the settings of the integration that links the broker",
5370
+ fixtureMark: "the broker link stored beside the flags of an integration"
5371
+ },
5372
+ procedure: copyFlagsToIntegrations
5373
+ },
5374
+ {
5375
+ meta: {
5376
+ id: "homeassistant-export/2026-11-03-drop-per-broker-flags",
5377
+ owner: "homeassistant-export",
5378
+ level: "addon-state",
5379
+ seq: 3,
5380
+ dependsOn: ["homeassistant-export/2026-11-03-per-broker-flags-to-integration"],
5381
+ store: "settings",
5382
+ scope: "cluster",
5383
+ onFailure: "tolerate",
5384
+ bodyKind: "plan",
5385
+ description: "Delete the per-broker keys once the integration shows the same flag",
5386
+ fixtureMark: "a legacy flag parked when the integration holds a different one"
5387
+ },
5388
+ plan: planDropPerBrokerFlags
5389
+ }
5390
+ ];
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]
5413
+ });
5414
+ }
5415
+ }
5416
+ return {
5417
+ toInstances,
5418
+ rest: Object.fromEntries(Object.entries(patch).filter(([key]) => !claimed.has(key)))
5419
+ };
5420
+ }
5421
+ /** The form's dynamic keys carrying each instance's own value. */
5422
+ function instanceOverlay(instances) {
5423
+ 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
+ }
5430
+ }
5431
+ return overlay;
5432
+ }
5433
+ //#endregion
5227
5434
  //#region src/ha-export/node-reads.ts
5228
5435
  var UNREAD = {
5229
5436
  footageUsedBytes: null,
@@ -7391,7 +7598,10 @@ var HaExportAddon = class extends BaseAddon {
7391
7598
  super({ ...DEFAULT_CONFIG });
7392
7599
  }
7393
7600
  async onInitialize() {
7394
- await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, { readStored: async () => this.resolveGlobalStore() }));
7601
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, {
7602
+ readStored: async () => this.resolveGlobalStore(),
7603
+ ...this.instancePorts()
7604
+ }));
7395
7605
  const secret = this.state("haExportMediaSecret", string(), "");
7396
7606
  const existing = await secret.get();
7397
7607
  if (existing.length === 0) {
@@ -7475,6 +7685,35 @@ var HaExportAddon = class extends BaseAddon {
7475
7685
  }
7476
7686
  ];
7477
7687
  }
7688
+ /**
7689
+ * The form of the addon shows each instance's own switch and media role: the values sit on its
7690
+ * integration (D985), overlaid on the legacy keys the store may still hold.
7691
+ */
7692
+ async getGlobalSettings(overlay, cap, nodeId) {
7693
+ const rows = await listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
7694
+ return super.getGlobalSettings({
7695
+ ...overlay,
7696
+ ...instanceOverlay(rows)
7697
+ }, cap, nodeId);
7698
+ }
7699
+ /**
7700
+ * An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
7701
+ * (D985), everything else to the addon store as before. A key whose broker has no integration
7702
+ * stays in the store.
7703
+ */
7704
+ async updateGlobalSettingsAsOperator(patch, nodeId) {
7705
+ const ports = this.instancePorts();
7706
+ const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
7707
+ for (const [integrationId, flags] of toInstances) {
7708
+ if (await ports.patchInstanceSettings(integrationId, flags)) continue;
7709
+ throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
7710
+ }
7711
+ if (Object.keys(rest).length > 0 || toInstances.size === 0) {
7712
+ await super.updateGlobalSettingsAsOperator(rest, nodeId);
7713
+ return;
7714
+ }
7715
+ await this.onConfigChanged();
7716
+ }
7478
7717
  async onConfigChanged() {
7479
7718
  for (const link of this.links.values()) link.client.resetBackoff();
7480
7719
  await this.reconcile("config-changed");
@@ -9114,7 +9353,20 @@ var HaExportAddon = class extends BaseAddon {
9114
9353
  enabled = [];
9115
9354
  async refreshEnabledBrokers(known) {
9116
9355
  const store = await this.resolveGlobalStore();
9117
- this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
9356
+ this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
9357
+ }
9358
+ /** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
9359
+ instancePorts() {
9360
+ return haInstanceSettingsPortsOver(this.ctx.api.integrations);
9361
+ }
9362
+ /**
9363
+ * Each broker's integration settings (D985): the switch and the media role of an instance.
9364
+ * A read that fails answers nothing, so every broker falls back to its legacy key and the
9365
+ * failure is logged — never read as "everything off".
9366
+ */
9367
+ async readInstanceSettings() {
9368
+ const rows = await listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read — the legacy per-broker keys answer", { meta: { error } }));
9369
+ return rows.length === 0 ? NO_INSTANCE_SETTINGS : settingsByBroker(rows);
9118
9370
  }
9119
9371
  enabledBrokerIds() {
9120
9372
  return this.enabled;
@@ -9515,7 +9767,7 @@ var HaExportAddon = class extends BaseAddon {
9515
9767
  async mediaBasesFor(brokerIds, deviceId) {
9516
9768
  let bases;
9517
9769
  try {
9518
- bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
9770
+ bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
9519
9771
  } catch (err) {
9520
9772
  this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9521
9773
  tags: { deviceId },