@camstack/addon-provider-homeassistant 1.2.652 → 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.
@@ -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_broker_source = require("../ha-broker-source-D3ScsO0T.js");
27
+ const require_ha_instance_settings = require("../ha-instance-settings-Bn_Trz6p.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -91,7 +91,7 @@ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
91
91
  await api.streamBroker.setDeviceAudioMute.mutate({
92
92
  deviceId,
93
93
  muted: write.muted
94
- }, require_ha_broker_source.nodePin(sourceNodeId));
94
+ }, require_ha_instance_settings.nodePin(sourceNodeId));
95
95
  return;
96
96
  }
97
97
  }
@@ -725,8 +725,8 @@ function consumableEntity(key, facet) {
725
725
  * rather than to an icon for nothing.
726
726
  */
727
727
  function consumableForm(item) {
728
- const declared = require_ha_broker_source.ConsumableFormSchema.safeParse(item.form);
729
- return require_ha_broker_source.consumableFormOf({
728
+ const declared = require_ha_instance_settings.ConsumableFormSchema.safeParse(item.form);
729
+ return require_ha_instance_settings.consumableFormOf({
730
730
  ...item.kind !== void 0 ? { kind: item.kind } : {},
731
731
  ...declared.success ? { form: declared.data } : {}
732
732
  });
@@ -739,7 +739,7 @@ function partIcon(key, label) {
739
739
  function formIcon(item, form) {
740
740
  switch (form) {
741
741
  case "part": return partIcon(item.key, item.label);
742
- case "tank": return require_ha_broker_source.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
742
+ case "tank": return require_ha_instance_settings.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
743
743
  case "product": return "mdi:shaker-outline";
744
744
  case "bin": return "mdi:delete";
745
745
  }
@@ -750,8 +750,8 @@ function formIcon(item, form) {
750
750
  * form this export does not know degrades one icon instead of refusing the
751
751
  * whole slice.
752
752
  */
753
- var ExportConsumableItemSchema = require_ha_broker_source.ConsumableItemSchema.extend({ form: require_ha_broker_source.unknown().optional() });
754
- var ExportConsumablesSchema = require_ha_broker_source.object({ items: require_ha_broker_source.array(ExportConsumableItemSchema) });
753
+ var ExportConsumableItemSchema = require_ha_instance_settings.ConsumableItemSchema.extend({ form: require_ha_instance_settings.unknown().optional() });
754
+ var ExportConsumablesSchema = require_ha_instance_settings.object({ items: require_ha_instance_settings.array(ExportConsumableItemSchema) });
755
755
  /**
756
756
  * A worn part's life is a diagnostic; a tank's fill is what the device page
757
757
  * is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
@@ -764,7 +764,7 @@ function consumableEntities(item) {
764
764
  capName: "consumables",
765
765
  itemKey: item.key
766
766
  };
767
- const kind = require_ha_broker_source.consumableKindOf(item);
767
+ const kind = require_ha_instance_settings.consumableKindOf(item);
768
768
  const form = consumableForm(item);
769
769
  const icon = formIcon(item, form);
770
770
  /** What the item IS, on the sensors that describe it (not on a counter or a button). */
@@ -804,7 +804,7 @@ function consumableEntities(item) {
804
804
  platform: "sensor",
805
805
  label: `${item.label} status`,
806
806
  deviceClass: "enum",
807
- options: require_ha_broker_source.ConsumableStatusSchema.options,
807
+ options: require_ha_instance_settings.ConsumableStatusSchema.options,
808
808
  icon,
809
809
  attributes,
810
810
  entityCategory: "diagnostic"
@@ -2405,7 +2405,7 @@ var CAP_ENTITY_MAP = {
2405
2405
  * failure. `fanMode` and `preset` get no such list at all: they are
2406
2406
  * free-form strings, so they stay sensors.
2407
2407
  */
2408
- options: require_ha_broker_source.HvacModeSchema.options,
2408
+ options: require_ha_instance_settings.HvacModeSchema.options,
2409
2409
  extras: {
2410
2410
  "current-temp": {
2411
2411
  platform: "sensor",
@@ -2555,7 +2555,7 @@ var CAP_ENTITY_MAP = {
2555
2555
  field: "direction",
2556
2556
  label: "Direction",
2557
2557
  writable: true,
2558
- options: require_ha_broker_source.FanDirectionSchema.options,
2558
+ options: require_ha_instance_settings.FanDirectionSchema.options,
2559
2559
  enabledByDefault: false
2560
2560
  }
2561
2561
  }
@@ -2985,7 +2985,7 @@ var CAP_ENTITY_MAP = {
2985
2985
  field: "repeat",
2986
2986
  label: "Repeat",
2987
2987
  writable: true,
2988
- options: require_ha_broker_source.MediaPlayerRepeatSchema.options,
2988
+ options: require_ha_instance_settings.MediaPlayerRepeatSchema.options,
2989
2989
  enabledByDefault: false
2990
2990
  },
2991
2991
  shuffle: {
@@ -3481,7 +3481,7 @@ function controlEntitySpecs(slice, quantity) {
3481
3481
  const min = readFiniteNumber(slice, "min") ?? PERCENT_RANGE.min;
3482
3482
  const max = readFiniteNumber(slice, "max") ?? PERCENT_RANGE.max;
3483
3483
  const step = readFiniteNumber(slice, "step") ?? PERCENT_RANGE.step;
3484
- const unit = quantity === void 0 ? void 0 : require_ha_broker_source.baseUnitOf(quantity);
3484
+ const unit = quantity === void 0 ? void 0 : require_ha_instance_settings.baseUnitOf(quantity);
3485
3485
  if (min < max && step > 0) return [{
3486
3486
  entity: primary,
3487
3487
  platform: "number",
@@ -3803,7 +3803,7 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3803
3803
  continue;
3804
3804
  }
3805
3805
  if (cap === "numeric-sensor") {
3806
- const unit = device.readingQuantity === void 0 ? void 0 : require_ha_broker_source.baseUnitOf(device.readingQuantity);
3806
+ const unit = device.readingQuantity === void 0 ? void 0 : require_ha_instance_settings.baseUnitOf(device.readingQuantity);
3807
3807
  specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(unit === void 0 ? spec : {
3808
3808
  ...spec,
3809
3809
  unit
@@ -3878,7 +3878,7 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
3878
3878
  * was.
3879
3879
  */
3880
3880
  /** The camera function ids, keyed by the entity slug the catalog emits. */
3881
- var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_ha_broker_source.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
3881
+ var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_ha_instance_settings.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
3882
3882
  var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
3883
3883
  function parseBool(value) {
3884
3884
  const lower = value.trim().toLowerCase();
@@ -3941,7 +3941,7 @@ function parseAlarmAction(value) {
3941
3941
  if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
3942
3942
  if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
3943
3943
  const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
3944
- const mode = require_ha_broker_source.AlarmArmModeSchema.safeParse(withoutPrefix);
3944
+ const mode = require_ha_instance_settings.AlarmArmModeSchema.safeParse(withoutPrefix);
3945
3945
  return mode.success ? {
3946
3946
  kind: "arm",
3947
3947
  mode: mode.data
@@ -4090,7 +4090,7 @@ var CAP_COMMAND_ROUTES = {
4090
4090
  },
4091
4091
  "climate-control": {
4092
4092
  primary: (deviceId, value) => {
4093
- const mode = parseEnum(require_ha_broker_source.HvacModeSchema, value);
4093
+ const mode = parseEnum(require_ha_instance_settings.HvacModeSchema, value);
4094
4094
  return mode === null ? null : {
4095
4095
  kind: "climate-mode",
4096
4096
  deviceId,
@@ -4205,7 +4205,7 @@ var CAP_COMMAND_ROUTES = {
4205
4205
  };
4206
4206
  },
4207
4207
  direction: (deviceId, value) => {
4208
- const direction = parseEnum(require_ha_broker_source.FanDirectionSchema, value);
4208
+ const direction = parseEnum(require_ha_instance_settings.FanDirectionSchema, value);
4209
4209
  return direction === null ? null : {
4210
4210
  kind: "fan-direction",
4211
4211
  deviceId,
@@ -4409,7 +4409,7 @@ var CAP_COMMAND_ROUTES = {
4409
4409
  };
4410
4410
  },
4411
4411
  repeat: (deviceId, value) => {
4412
- const repeat = parseEnum(require_ha_broker_source.MediaPlayerRepeatSchema, value);
4412
+ const repeat = parseEnum(require_ha_instance_settings.MediaPlayerRepeatSchema, value);
4413
4413
  return repeat === null ? null : {
4414
4414
  kind: "media-repeat",
4415
4415
  deviceId,
@@ -5001,32 +5001,13 @@ async function planDropPublicBaseUrl(ports) {
5001
5001
  op: "deleteKeys",
5002
5002
  scope: ADDON_SETTINGS_TABLE,
5003
5003
  keys: present.map(rowId),
5004
- precondition: require_ha_broker_source.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5004
+ precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
5005
5005
  addonId: HA_EXPORT_ADDON_ID,
5006
5006
  key,
5007
5007
  value: stored[key]
5008
5008
  }])))
5009
5009
  }] };
5010
5010
  }
5011
- //#endregion
5012
- //#region src/ha-export/migrations/index.ts
5013
- /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5014
- var ADDON_MIGRATIONS = [{
5015
- meta: {
5016
- id: "homeassistant-export/2026-11-02-drop-public-base-url",
5017
- owner: "homeassistant-export",
5018
- level: "addon-state",
5019
- seq: 1,
5020
- dependsOn: [],
5021
- store: "settings",
5022
- scope: "cluster",
5023
- onFailure: "tolerate",
5024
- bodyKind: "plan",
5025
- description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
5026
- fixtureMark: "the reconcile interval the migration does not know"
5027
- },
5028
- plan: planDropPublicBaseUrl
5029
- }];
5030
5011
  process.platform, process.env["SystemRoot"];
5031
5012
  /**
5032
5013
  * The ONE derivation of "a signed, expiring URL".
@@ -5082,65 +5063,6 @@ function verifyExpiringUrl(input) {
5082
5063
  return (0, node_crypto.timingSafeEqual)(a, b);
5083
5064
  }
5084
5065
  //#endregion
5085
- //#region src/ha-export/media-url.ts
5086
- /**
5087
- * Signed, expiring media links.
5088
- *
5089
- * Images never cross the push transport. Home Assistant is handed a URL
5090
- * and fetches the bytes itself, on demand, which is what keeps a camera
5091
- * with a `last_image` entity from turning every detection into a
5092
- * multi-megabyte POST.
5093
- *
5094
- * The link is signed rather than protected by a session, because the
5095
- * fetcher is HA's own frontend or a phone — neither of which holds a
5096
- * camstack token.
5097
- *
5098
- * The HMAC itself is NOT restated here. It used to be — this module and the
5099
- * notification artifact plane each carried their own copy, because addons never
5100
- * import each other and neither could reach the other's. That reasoning was
5101
- * right and the conclusion was wrong: both now delegate to the single
5102
- * derivation in `@camstack/types/node`, which every addon may depend on. What
5103
- * stays local is policy — the TTL below, and the id this plane signs.
5104
- *
5105
- * Expiry is checked BEFORE the constant-time compare (in the shared verifier):
5106
- * an expired link is refused whether or not its signature is valid, so a leaked
5107
- * URL stops working on its own.
5108
- */
5109
- /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5110
- function mediaRoleKey(brokerId) {
5111
- return `mediaRole:${brokerId}`;
5112
- }
5113
- /** A broker's chosen role; anything stored that is not a known role is `internal`. */
5114
- function haMediaRoleOf(store, brokerId) {
5115
- return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
5116
- }
5117
- /**
5118
- * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5119
- * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5120
- * when the role resolves to nothing): a link Home Assistant cannot open is worse
5121
- * than no image.
5122
- */
5123
- function haMediaBaseUrl(book, role = "internal") {
5124
- return book.roles[role].resolved;
5125
- }
5126
- /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5127
- function mediaBasesByBroker(store, brokerIds, book) {
5128
- return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
5129
- }
5130
- /** How long a minted link stays valid. Long enough for HA to render it. */
5131
- var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
5132
- function signMedia(secret, id, expMs) {
5133
- return signExpiringUrl(secret, id, expMs);
5134
- }
5135
- function buildMediaUrl(input) {
5136
- const base = input.baseUrl.replace(/\/+$/, "");
5137
- const sig = signMedia(input.secret, input.id, input.expMs);
5138
- return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
5139
- }
5140
- function verifyMediaSignature(input) {
5141
- return verifyExpiringUrl(input);
5142
- }
5143
- //#endregion
5144
5066
  //#region src/ha-export/membership.ts
5145
5067
  function devicesForBroker(membership, brokerId) {
5146
5068
  return membership[brokerId] ?? [];
@@ -5202,10 +5124,19 @@ function pruneBrokers(membership, knownBrokerIds) {
5202
5124
  *
5203
5125
  * A broker that is no longer known is not enabled, whatever the store says:
5204
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.
5205
5132
  */
5206
- function enabledBrokerIds(store, knownBrokerIds) {
5207
- return knownBrokerIds.filter((brokerId) => store[`exportTo:${brokerId}`] === true);
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
+ });
5208
5138
  }
5139
+ var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
5209
5140
  /** Same brokers, same order, same names — nothing for the form to re-render. */
5210
5141
  function sameBrokers(a, b) {
5211
5142
  if (a.length !== b.length) return false;
@@ -5259,6 +5190,282 @@ function brokerRefreshPatch(current, next) {
5259
5190
  };
5260
5191
  }
5261
5192
  //#endregion
5193
+ //#region src/ha-export/media-url.ts
5194
+ /**
5195
+ * Signed, expiring media links.
5196
+ *
5197
+ * Images never cross the push transport. Home Assistant is handed a URL
5198
+ * and fetches the bytes itself, on demand, which is what keeps a camera
5199
+ * with a `last_image` entity from turning every detection into a
5200
+ * multi-megabyte POST.
5201
+ *
5202
+ * The link is signed rather than protected by a session, because the
5203
+ * fetcher is HA's own frontend or a phone — neither of which holds a
5204
+ * camstack token.
5205
+ *
5206
+ * The HMAC itself is NOT restated here. It used to be — this module and the
5207
+ * notification artifact plane each carried their own copy, because addons never
5208
+ * import each other and neither could reach the other's. That reasoning was
5209
+ * right and the conclusion was wrong: both now delegate to the single
5210
+ * derivation in `@camstack/types/node`, which every addon may depend on. What
5211
+ * stays local is policy — the TTL below, and the id this plane signs.
5212
+ *
5213
+ * Expiry is checked BEFORE the constant-time compare (in the shared verifier):
5214
+ * an expired link is refused whether or not its signature is valid, so a leaked
5215
+ * URL stops working on its own.
5216
+ */
5217
+ /** The hub address roles a Home Assistant media link may be minted on. */
5218
+ 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
+ /**
5233
+ * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5234
+ * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5235
+ * when the role resolves to nothing): a link Home Assistant cannot open is worse
5236
+ * than no image.
5237
+ */
5238
+ function haMediaBaseUrl(book, role = "internal") {
5239
+ return book.roles[role].resolved;
5240
+ }
5241
+ /** 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))]));
5244
+ }
5245
+ /** How long a minted link stays valid. Long enough for HA to render it. */
5246
+ var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
5247
+ function signMedia(secret, id, expMs) {
5248
+ return signExpiringUrl(secret, id, expMs);
5249
+ }
5250
+ function buildMediaUrl(input) {
5251
+ const base = input.baseUrl.replace(/\/+$/, "");
5252
+ const sig = signMedia(input.secret, input.id, input.expMs);
5253
+ return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
5254
+ }
5255
+ function verifyMediaSignature(input) {
5256
+ return verifyExpiringUrl(input);
5257
+ }
5258
+ //#endregion
5259
+ //#region src/ha-export/migrations/per-broker-flags.ts
5260
+ /**
5261
+ * Whether the export reaches a Home Assistant instance, and which hub address role its image
5262
+ * links use, are facts of THAT instance (D985): they live in the instance's integration settings
5263
+ * as `exportTo` and `mediaRole`, not in the addon store as one dynamic key per broker
5264
+ * (`exportTo:<brokerId>`, `mediaRole:<brokerId>`).
5265
+ *
5266
+ * Two migrations:
5267
+ *
5268
+ * 1. `per-broker-flags-to-integration` (procedure) copies each legacy key to the integration that
5269
+ * links its broker. A flag the integration already has is never overwritten; a key whose
5270
+ * broker has no integration any more (a deleted instance) is skipped, never recreated; two
5271
+ * integrations linking one broker are an ambiguity and throw; a value that is not a boolean /
5272
+ * a known role is skipped (the reader already treats it as off / `internal`).
5273
+ * 2. `drop-per-broker-flags` (plan) deletes a legacy key only after a read-back: the integration
5274
+ * must show the same flag. A key the integration disagrees with (its own value was there
5275
+ * first) or whose broker has no integration stays PARKED in place, never deleted.
5276
+ *
5277
+ * Both are idempotent.
5278
+ */
5279
+ var PREFIX = {
5280
+ exportTo: "exportTo:",
5281
+ mediaRole: "mediaRole:"
5282
+ };
5283
+ var validValue = (flag, value) => flag === "exportTo" ? typeof value === "boolean" : typeof value === "string" && HA_MEDIA_ROLES.some((role) => role === value);
5284
+ /** Every legacy key with a value the reader understands, per broker. */
5285
+ function legacyFlags(stored) {
5286
+ const out = [];
5287
+ for (const [storeKey, value] of Object.entries(stored)) for (const flag of ["exportTo", "mediaRole"]) {
5288
+ const prefix = PREFIX[flag];
5289
+ if (!storeKey.startsWith(prefix) || storeKey.length === prefix.length) continue;
5290
+ if (validValue(flag, value)) out.push({
5291
+ storeKey,
5292
+ brokerId: storeKey.slice(prefix.length),
5293
+ flag,
5294
+ value
5295
+ });
5296
+ }
5297
+ return out;
5298
+ }
5299
+ /** The one integration per broker; two integrations linking one broker are an ambiguity. */
5300
+ function instanceByBroker(rows) {
5301
+ const out = /* @__PURE__ */ new Map();
5302
+ for (const row of rows) {
5303
+ if (row.brokerId === null) continue;
5304
+ const other = out.get(row.brokerId);
5305
+ if (other !== void 0) throw new Error(`homeassistant-export: integrations ${other.integrationId} and ${row.integrationId} both link broker ${row.brokerId} — nothing copied`);
5306
+ out.set(row.brokerId, row);
5307
+ }
5308
+ return out;
5309
+ }
5310
+ async function copyFlagsToIntegrations(ports) {
5311
+ const legacy = legacyFlags(await ports.readStored());
5312
+ if (legacy.length === 0) return {
5313
+ outcome: "already-done",
5314
+ summary: { reason: "no-legacy-flags" }
5315
+ };
5316
+ const byBroker = instanceByBroker(await ports.listInstances());
5317
+ const pending = /* @__PURE__ */ new Map();
5318
+ let kept = 0;
5319
+ let skipped = 0;
5320
+ for (const entry of legacy) {
5321
+ const instance = byBroker.get(entry.brokerId);
5322
+ if (instance === void 0) {
5323
+ skipped += 1;
5324
+ continue;
5325
+ }
5326
+ if (entry.flag in instance.settings) {
5327
+ kept += 1;
5328
+ continue;
5329
+ }
5330
+ pending.set(instance.integrationId, {
5331
+ ...pending.get(instance.integrationId),
5332
+ [entry.flag]: entry.value
5333
+ });
5334
+ }
5335
+ let copied = 0;
5336
+ let gone = 0;
5337
+ for (const [integrationId, patch] of pending) if (await ports.patchInstanceSettings(integrationId, patch)) copied += Object.keys(patch).length;
5338
+ else gone += 1;
5339
+ return {
5340
+ outcome: copied === 0 ? "already-done" : "done",
5341
+ summary: {
5342
+ copied,
5343
+ kept,
5344
+ skipped,
5345
+ gone
5346
+ }
5347
+ };
5348
+ }
5349
+ async function planDropPerBrokerFlags(ports) {
5350
+ const legacy = legacyFlags(await ports.readStored());
5351
+ if (legacy.length === 0) return { edits: [] };
5352
+ const byBroker = instanceByBroker(await ports.listInstances());
5353
+ const rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
5354
+ const drop = [];
5355
+ for (const entry of legacy) {
5356
+ const instance = byBroker.get(entry.brokerId);
5357
+ if (instance === void 0) continue;
5358
+ if (!(entry.flag in instance.settings)) throw new Error(`homeassistant-export: ${entry.storeKey} is not dropped, integration ${instance.integrationId} does not show ${entry.flag}`);
5359
+ if (instance.settings[entry.flag] === entry.value) drop.push(entry);
5360
+ }
5361
+ if (drop.length === 0) return { edits: [] };
5362
+ return { edits: [{
5363
+ op: "deleteKeys",
5364
+ scope: ADDON_SETTINGS_TABLE,
5365
+ keys: drop.map((entry) => rowId(entry.storeKey)),
5366
+ precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(drop.map((entry) => [rowId(entry.storeKey), {
5367
+ addonId: HA_EXPORT_ADDON_ID,
5368
+ key: entry.storeKey,
5369
+ value: entry.value
5370
+ }])))
5371
+ }] };
5372
+ }
5373
+ //#endregion
5374
+ //#region src/ha-export/migrations/index.ts
5375
+ /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
5376
+ var ADDON_MIGRATIONS = [
5377
+ {
5378
+ meta: {
5379
+ id: "homeassistant-export/2026-11-02-drop-public-base-url",
5380
+ owner: "homeassistant-export",
5381
+ level: "addon-state",
5382
+ seq: 1,
5383
+ dependsOn: [],
5384
+ store: "settings",
5385
+ scope: "cluster",
5386
+ onFailure: "tolerate",
5387
+ bodyKind: "plan",
5388
+ description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
5389
+ fixtureMark: "the reconcile interval the migration does not know"
5390
+ },
5391
+ plan: planDropPublicBaseUrl
5392
+ },
5393
+ {
5394
+ meta: {
5395
+ id: "homeassistant-export/2026-11-03-per-broker-flags-to-integration",
5396
+ owner: "homeassistant-export",
5397
+ level: "addon-state",
5398
+ seq: 2,
5399
+ dependsOn: [],
5400
+ store: "settings",
5401
+ scope: "cluster",
5402
+ onFailure: "tolerate",
5403
+ bodyKind: "procedure",
5404
+ description: "Copy each exportTo:<broker> and mediaRole:<broker> key to the settings of the integration that links the broker",
5405
+ fixtureMark: "the broker link stored beside the flags of an integration"
5406
+ },
5407
+ procedure: copyFlagsToIntegrations
5408
+ },
5409
+ {
5410
+ meta: {
5411
+ id: "homeassistant-export/2026-11-03-drop-per-broker-flags",
5412
+ owner: "homeassistant-export",
5413
+ level: "addon-state",
5414
+ seq: 3,
5415
+ dependsOn: ["homeassistant-export/2026-11-03-per-broker-flags-to-integration"],
5416
+ store: "settings",
5417
+ scope: "cluster",
5418
+ onFailure: "tolerate",
5419
+ bodyKind: "plan",
5420
+ description: "Delete the per-broker keys once the integration shows the same flag",
5421
+ fixtureMark: "a legacy flag parked when the integration holds a different one"
5422
+ },
5423
+ plan: planDropPerBrokerFlags
5424
+ }
5425
+ ];
5426
+ //#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]
5448
+ });
5449
+ }
5450
+ }
5451
+ return {
5452
+ toInstances,
5453
+ rest: Object.fromEntries(Object.entries(patch).filter(([key]) => !claimed.has(key)))
5454
+ };
5455
+ }
5456
+ /** The form's dynamic keys carrying each instance's own value. */
5457
+ function instanceOverlay(instances) {
5458
+ 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
+ }
5465
+ }
5466
+ return overlay;
5467
+ }
5468
+ //#endregion
5262
5469
  //#region src/ha-export/node-reads.ts
5263
5470
  var UNREAD = {
5264
5471
  footageUsedBytes: null,
@@ -5970,7 +6177,7 @@ function reduceLastTriggers(rows) {
5970
6177
  function macroOf(className) {
5971
6178
  const lower = className.toLowerCase();
5972
6179
  if (EXPORTED_MACROS.includes(lower)) return lower;
5973
- const mapped = require_ha_broker_source.COCO_TO_MACRO.mapping[lower];
6180
+ const mapped = require_ha_instance_settings.COCO_TO_MACRO.mapping[lower];
5974
6181
  return typeof mapped === "string" ? mapped : null;
5975
6182
  }
5976
6183
  function iso(timestamp) {
@@ -7119,7 +7326,7 @@ var ExportedEntities = class {
7119
7326
  this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
7120
7327
  return;
7121
7328
  }
7122
- const { dropped } = require_ha_broker_source.pruneDeletedEntities([...this.state.actions.map((e) => ({
7329
+ const { dropped } = require_ha_instance_settings.pruneDeletedEntities([...this.state.actions.map((e) => ({
7123
7330
  kind: "action",
7124
7331
  actionId: e.actionId
7125
7332
  })), ...this.state.targets.map((e) => ({
@@ -7191,7 +7398,7 @@ var ExportedEntities = class {
7191
7398
  const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
7192
7399
  if (action !== void 0) return {
7193
7400
  handled: true,
7194
- applied: (await require_ha_broker_source.runExportedAction(this.ports.api, this.ports.logger, {
7401
+ applied: (await require_ha_instance_settings.runExportedAction(this.ports.api, this.ports.logger, {
7195
7402
  exporter: EXPORTER,
7196
7403
  actionId: action.actionId
7197
7404
  })).status === "started"
@@ -7199,7 +7406,7 @@ var ExportedEntities = class {
7199
7406
  const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
7200
7407
  if (target !== void 0) return {
7201
7408
  handled: true,
7202
- applied: (await require_ha_broker_source.sendToExportedTarget(this.ports.api, this.ports.logger, {
7409
+ applied: (await require_ha_instance_settings.sendToExportedTarget(this.ports.api, this.ports.logger, {
7203
7410
  exporter: EXPORTER,
7204
7411
  targetId: target.targetId,
7205
7412
  notification: { body: value }
@@ -7250,7 +7457,7 @@ var SUPPORTED_DEVICE_KINDS = [
7250
7457
  "image",
7251
7458
  "pet-feeder",
7252
7459
  "hub",
7253
- ...require_ha_broker_source.PLUG_APPLIANCE_TYPES
7460
+ ...require_ha_instance_settings.PLUG_APPLIANCE_TYPES
7254
7461
  ];
7255
7462
  //#endregion
7256
7463
  //#region src/ha-export/ha-export.addon.ts
@@ -7320,7 +7527,7 @@ var DEFAULT_CONFIG = {
7320
7527
  targets: []
7321
7528
  }
7322
7529
  };
7323
- var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7530
+ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
7324
7531
  links = /* @__PURE__ */ new Map();
7325
7532
  /** deviceKey → the device it addresses. Rebuilt on every reconcile. */
7326
7533
  exported = /* @__PURE__ */ new Map();
@@ -7426,8 +7633,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7426
7633
  super({ ...DEFAULT_CONFIG });
7427
7634
  }
7428
7635
  async onInitialize() {
7429
- await this.runMigrations(require_ha_broker_source.defineAddonMigrations(ADDON_MIGRATIONS, { readStored: async () => this.resolveGlobalStore() }));
7430
- const secret = this.state("haExportMediaSecret", require_ha_broker_source.string(), "");
7636
+ await this.runMigrations(require_ha_instance_settings.defineAddonMigrations(ADDON_MIGRATIONS, {
7637
+ readStored: async () => this.resolveGlobalStore(),
7638
+ ...this.instancePorts()
7639
+ }));
7640
+ const secret = this.state("haExportMediaSecret", require_ha_instance_settings.string(), "");
7431
7641
  const existing = await secret.get();
7432
7642
  if (existing.length === 0) {
7433
7643
  const minted = randomSecret();
@@ -7460,7 +7670,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7460
7670
  this.exportedEntities = exportedEntities;
7461
7671
  return [
7462
7672
  {
7463
- capability: require_ha_broker_source.deviceExportCapability,
7673
+ capability: require_ha_instance_settings.deviceExportCapability,
7464
7674
  provider: {
7465
7675
  getStatus: async () => this.buildStatus(),
7466
7676
  listSupportedDeviceKinds: async () => [...SUPPORTED_DEVICE_KINDS],
@@ -7493,8 +7703,8 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7493
7703
  }
7494
7704
  },
7495
7705
  {
7496
- capability: require_ha_broker_source.addonRoutesCapability,
7497
- provider: require_ha_broker_source.buildAddonRouteProvider(ADDON_ID, [{
7706
+ capability: require_ha_instance_settings.addonRoutesCapability,
7707
+ provider: require_ha_instance_settings.buildAddonRouteProvider(ADDON_ID, [{
7498
7708
  method: "POST",
7499
7709
  path: "/command",
7500
7710
  access: "authenticated",
@@ -7505,27 +7715,56 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7505
7715
  }])
7506
7716
  },
7507
7717
  {
7508
- capability: require_ha_broker_source.oauthIntegrationCapability,
7718
+ capability: require_ha_instance_settings.oauthIntegrationCapability,
7509
7719
  provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
7510
7720
  }
7511
7721
  ];
7512
7722
  }
7723
+ /**
7724
+ * The form of the addon shows each instance's own switch and media role: the values sit on its
7725
+ * integration (D985), overlaid on the legacy keys the store may still hold.
7726
+ */
7727
+ async getGlobalSettings(overlay, cap, nodeId) {
7728
+ const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
7729
+ return super.getGlobalSettings({
7730
+ ...overlay,
7731
+ ...instanceOverlay(rows)
7732
+ }, cap, nodeId);
7733
+ }
7734
+ /**
7735
+ * An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
7736
+ * (D985), everything else to the addon store as before. A key whose broker has no integration
7737
+ * stays in the store.
7738
+ */
7739
+ async updateGlobalSettingsAsOperator(patch, nodeId) {
7740
+ const ports = this.instancePorts();
7741
+ const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
7742
+ for (const [integrationId, flags] of toInstances) {
7743
+ if (await ports.patchInstanceSettings(integrationId, flags)) continue;
7744
+ throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
7745
+ }
7746
+ if (Object.keys(rest).length > 0 || toInstances.size === 0) {
7747
+ await super.updateGlobalSettingsAsOperator(rest, nodeId);
7748
+ return;
7749
+ }
7750
+ await this.onConfigChanged();
7751
+ }
7513
7752
  async onConfigChanged() {
7514
7753
  for (const link of this.links.values()) link.client.resetBackoff();
7515
7754
  await this.reconcile("config-changed");
7516
7755
  this.startTimer();
7517
7756
  }
7518
7757
  wireListeners() {
7519
- this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceStateChanged }, (event) => {
7758
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceStateChanged }, (event) => {
7520
7759
  this.onSliceChanged(event.data);
7521
7760
  });
7522
- this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceOnline }, (event) => {
7761
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceOnline }, (event) => {
7523
7762
  this.onOnlineChanged(event.data, true);
7524
7763
  });
7525
- this.subscribe({ category: require_ha_broker_source.EventCategory.DeviceOffline }, (event) => {
7764
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceOffline }, (event) => {
7526
7765
  this.onOnlineChanged(event.data, false);
7527
7766
  });
7528
- this.subscribe({ category: require_ha_broker_source.EventCategory.MotionOnMotionChanged }, (event) => {
7767
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.MotionOnMotionChanged }, (event) => {
7529
7768
  this.onMotion(event.data);
7530
7769
  });
7531
7770
  /**
@@ -7534,13 +7773,13 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7534
7773
  * the entity existed and effectively never moved. It also carries the
7535
7774
  * window LEVEL, which nothing else on the export path does.
7536
7775
  */
7537
- this.subscribe({ category: require_ha_broker_source.EventCategory.PipelineAudioInferenceResult }, (event) => {
7776
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.PipelineAudioInferenceResult }, (event) => {
7538
7777
  this.onAudioWindow(event.data);
7539
7778
  });
7540
- this.subscribe({ category: require_ha_broker_source.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
7779
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
7541
7780
  this.onTrackLifecycle(event.data);
7542
7781
  });
7543
- this.subscribe({ category: require_ha_broker_source.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
7782
+ this.subscribe({ category: require_ha_instance_settings.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
7544
7783
  this.onZoneOccupancy(event.data);
7545
7784
  });
7546
7785
  /**
@@ -7549,11 +7788,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7549
7788
  * rather than a state push.
7550
7789
  */
7551
7790
  for (const category of [
7552
- require_ha_broker_source.EventCategory.DeviceRegistered,
7553
- require_ha_broker_source.EventCategory.DeviceUnregistered,
7554
- require_ha_broker_source.EventCategory.DeviceBindingsChanged,
7555
- require_ha_broker_source.EventCategory.DeviceMetaChanged,
7556
- require_ha_broker_source.EventCategory.BrokerStatusChanged
7791
+ require_ha_instance_settings.EventCategory.DeviceRegistered,
7792
+ require_ha_instance_settings.EventCategory.DeviceUnregistered,
7793
+ require_ha_instance_settings.EventCategory.DeviceBindingsChanged,
7794
+ require_ha_instance_settings.EventCategory.DeviceMetaChanged,
7795
+ require_ha_instance_settings.EventCategory.BrokerStatusChanged
7557
7796
  ]) this.subscribe({ category }, () => {
7558
7797
  this.reconcileSoon();
7559
7798
  });
@@ -8665,7 +8904,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8665
8904
  */
8666
8905
  capSlices: snapshot,
8667
8906
  itemEntities: items.entities,
8668
- ...require_ha_broker_source.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
8907
+ ...require_ha_instance_settings.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
8669
8908
  },
8670
8909
  heldBack
8671
8910
  };
@@ -8899,7 +9138,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8899
9138
  maskEnabled: privacySlice["enabled"] === true,
8900
9139
  audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
8901
9140
  };
8902
- const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_ha_broker_source.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
9141
+ const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_ha_instance_settings.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
8903
9142
  this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
8904
9143
  tags: { deviceId },
8905
9144
  meta: {
@@ -8950,7 +9189,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
8950
9189
  * reasoning.
8951
9190
  */
8952
9191
  async refreshBrokers() {
8953
- const haBrokers = await require_ha_broker_source.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
9192
+ const haBrokers = await require_ha_instance_settings.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
8954
9193
  const known = haBrokers.map((broker) => ({
8955
9194
  id: broker.id,
8956
9195
  name: broker.name
@@ -9149,7 +9388,20 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9149
9388
  enabled = [];
9150
9389
  async refreshEnabledBrokers(known) {
9151
9390
  const store = await this.resolveGlobalStore();
9152
- this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
9391
+ this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
9392
+ }
9393
+ /** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
9394
+ instancePorts() {
9395
+ return require_ha_instance_settings.haInstanceSettingsPortsOver(this.ctx.api.integrations);
9396
+ }
9397
+ /**
9398
+ * Each broker's integration settings (D985): the switch and the media role of an instance.
9399
+ * A read that fails answers nothing, so every broker falls back to its legacy key and the
9400
+ * failure is logged — never read as "everything off".
9401
+ */
9402
+ async readInstanceSettings() {
9403
+ const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read — the legacy per-broker keys answer", { meta: { error } }));
9404
+ return rows.length === 0 ? NO_INSTANCE_SETTINGS : require_ha_instance_settings.settingsByBroker(rows);
9153
9405
  }
9154
9406
  enabledBrokerIds() {
9155
9407
  return this.enabled;
@@ -9310,7 +9562,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9310
9562
  }
9311
9563
  switch (command.kind) {
9312
9564
  case "camera-function": {
9313
- const parsed = require_ha_broker_source.CameraSwitchIdSchema.safeParse(command.switchId);
9565
+ const parsed = require_ha_instance_settings.CameraSwitchIdSchema.safeParse(command.switchId);
9314
9566
  if (!parsed.success) {
9315
9567
  this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
9316
9568
  tags: { deviceId: command.deviceId },
@@ -9550,7 +9802,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9550
9802
  async mediaBasesFor(brokerIds, deviceId) {
9551
9803
  let bases;
9552
9804
  try {
9553
- bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
9805
+ bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
9554
9806
  } catch (err) {
9555
9807
  this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9556
9808
  tags: { deviceId },