@camstack/addon-provider-homeassistant 1.2.646 → 1.2.648

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-gQYTcasw.js");
27
+ const require_ha_broker_source = require("../ha-broker-source-Ba20mhmb.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -3485,6 +3485,10 @@ function controlEntitySpecs(slice, quantity) {
3485
3485
  }, kindExtra];
3486
3486
  }
3487
3487
  var CAPS_NOT_EXPORTED = [
3488
+ {
3489
+ cap: "do-not-disturb",
3490
+ reason: "A vendor switch of a speaker, provided so far only by an imported Echo: its media player is the entity a home-automation front end needs, and no entity kind has been chosen for the Do Not Disturb switch yet."
3491
+ },
3488
3492
  {
3489
3493
  cap: "timed-run",
3490
3494
  reason: "A hub-side timer for a device that has none: a \"run for N minutes\" command and the deadline of the pending switch-off. The switch or pump it drives is already exported through `switch`, and the deadline is a countdown, not a value an entity can hold."
@@ -4936,6 +4940,49 @@ async function call(slice, capName, make) {
4936
4940
  }
4937
4941
  return { ok: true };
4938
4942
  }
4943
+ //#endregion
4944
+ //#region src/ha-export/migrations/drop-public-base-url.ts
4945
+ /**
4946
+ * The media base is no longer a setting of this addon (D986 §5): each Home Assistant
4947
+ * instance chooses a hub address ROLE (`mediaRole:<brokerId>`) and the address is read from
4948
+ * the palette on every use. The `publicBaseUrl` key older versions persisted is deleted.
4949
+ * Every other key is left exactly as stored.
4950
+ *
4951
+ * Idempotent: a store without the key yields an empty plan, which writes nothing.
4952
+ */
4953
+ /** The settings scope of this addon (`<addonId>:addon-settings`). */
4954
+ var HA_EXPORT_SETTINGS_SCOPE = "homeassistant-export:addon-settings";
4955
+ var RETIRED_URL_KEYS = ["publicBaseUrl"];
4956
+ async function planDropPublicBaseUrl(ports) {
4957
+ const stored = await ports.readStored();
4958
+ const present = RETIRED_URL_KEYS.filter((key) => key in stored);
4959
+ if (present.length === 0) return { edits: [] };
4960
+ return { edits: [{
4961
+ op: "deleteKeys",
4962
+ scope: HA_EXPORT_SETTINGS_SCOPE,
4963
+ keys: [...present],
4964
+ precondition: require_ha_broker_source.preconditionOf(Object.fromEntries(present.map((key) => [key, stored[key]])))
4965
+ }] };
4966
+ }
4967
+ //#endregion
4968
+ //#region src/ha-export/migrations/index.ts
4969
+ /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
4970
+ var ADDON_MIGRATIONS = [{
4971
+ meta: {
4972
+ id: "homeassistant-export/2026-11-02-drop-public-base-url",
4973
+ owner: "homeassistant-export",
4974
+ level: "addon-state",
4975
+ seq: 1,
4976
+ dependsOn: [],
4977
+ store: "settings",
4978
+ scope: "cluster",
4979
+ onFailure: "tolerate",
4980
+ bodyKind: "plan",
4981
+ description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
4982
+ fixtureMark: "the reconcile interval the migration does not know"
4983
+ },
4984
+ plan: planDropPublicBaseUrl
4985
+ }];
4939
4986
  process.platform, process.env["SystemRoot"];
4940
4987
  /**
4941
4988
  * The ONE derivation of "a signed, expiring URL".
@@ -5015,15 +5062,26 @@ function verifyExpiringUrl(input) {
5015
5062
  * an expired link is refused whether or not its signature is valid, so a leaked
5016
5063
  * URL stops working on its own.
5017
5064
  */
5065
+ /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5066
+ function mediaRoleKey(brokerId) {
5067
+ return `mediaRole:${brokerId}`;
5068
+ }
5069
+ /** A broker's chosen role; anything stored that is not a known role is `internal`. */
5070
+ function haMediaRoleOf(store, brokerId) {
5071
+ return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
5072
+ }
5018
5073
  /**
5019
- * The base a media link is minted on when the operator left the addon's own
5020
- * field empty: the hub's `internal` role (D734) — the address flagged
5021
- * "Internal" on the Network page, else the detected LAN address. Home
5022
- * Assistant is a LAN device pulling from the hub, so never the public
5023
- * endpoint, and never loopback (`null` when nothing qualifies).
5074
+ * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5075
+ * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5076
+ * when the role resolves to nothing): a link Home Assistant cannot open is worse
5077
+ * than no image.
5024
5078
  */
5025
- function haMediaBaseUrl(book) {
5026
- return book.roles.internal.resolved;
5079
+ function haMediaBaseUrl(book, role = "internal") {
5080
+ return book.roles[role].resolved;
5081
+ }
5082
+ /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5083
+ function mediaBasesByBroker(store, brokerIds, book) {
5084
+ return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
5027
5085
  }
5028
5086
  /** How long a minted link stays valid. Long enough for HA to render it. */
5029
5087
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
@@ -7164,8 +7222,7 @@ var DEFAULT_CONFIG = {
7164
7222
  exportedEntities: {
7165
7223
  actions: [],
7166
7224
  targets: []
7167
- },
7168
- publicBaseUrl: ""
7225
+ }
7169
7226
  };
7170
7227
  var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7171
7228
  links = /* @__PURE__ */ new Map();
@@ -7174,7 +7231,6 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7174
7231
  /** numeric deviceId → deviceKey, for the event path. */
7175
7232
  keyByDeviceId = /* @__PURE__ */ new Map();
7176
7233
  mediaSecret = "";
7177
- mediaBaseUrl = null;
7178
7234
  reconcileTimer = null;
7179
7235
  reconcileInFlight = false;
7180
7236
  /** A reason asked for while one was running. Re-run, never dropped. */
@@ -7274,6 +7330,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7274
7330
  super({ ...DEFAULT_CONFIG });
7275
7331
  }
7276
7332
  async onInitialize() {
7333
+ await this.runMigrations(require_ha_broker_source.defineAddonMigrations(ADDON_MIGRATIONS, { readStored: async () => this.resolveGlobalStore() }));
7277
7334
  const secret = this.state("haExportMediaSecret", require_ha_broker_source.string(), "");
7278
7335
  const existing = await secret.get();
7279
7336
  if (existing.length === 0) {
@@ -7674,32 +7731,39 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7674
7731
  const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
7675
7732
  const zonesVisited = readStringArray(data, "zonesVisited");
7676
7733
  const fusedIntoTrackId = readString(data, "fusedIntoTrackId");
7677
- const mediaUrl = this.mintTrackMediaUrl(trackId, deviceId);
7678
- const values = projectTrackLifecycle(deviceKey, {
7679
- deviceId,
7680
- trackId,
7681
- phase,
7682
- classes,
7683
- ...bestClassName !== null ? { bestClassName } : {},
7684
- ...label !== null ? { label } : {},
7685
- ...plateText !== null ? { plateText } : {},
7686
- lastSeen,
7687
- zonesVisited,
7688
- ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7689
- }, mediaUrl);
7690
- if (values.length === 0) {
7691
- this.ctx.logger.debug("ha-export: track produced no exportable state", {
7692
- tags: { deviceId },
7693
- meta: {
7694
- trackId,
7695
- bestClassName,
7696
- phase,
7697
- fusedIntoTrackId
7698
- }
7699
- });
7700
- return;
7734
+ const exported = this.exported.get(deviceKey);
7735
+ if (exported === void 0) return;
7736
+ const bases = await this.mediaBasesFor(exported.brokerIds, deviceId);
7737
+ let produced = false;
7738
+ for (const brokerId of exported.brokerIds) {
7739
+ const link = this.links.get(brokerId);
7740
+ if (link === void 0) continue;
7741
+ const base = bases.get(brokerId) ?? null;
7742
+ const values = projectTrackLifecycle(deviceKey, {
7743
+ deviceId,
7744
+ trackId,
7745
+ phase,
7746
+ classes,
7747
+ ...bestClassName !== null ? { bestClassName } : {},
7748
+ ...label !== null ? { label } : {},
7749
+ ...plateText !== null ? { plateText } : {},
7750
+ lastSeen,
7751
+ zonesVisited,
7752
+ ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7753
+ }, base === null ? null : this.mintTrackMediaUrl(trackId, deviceId, base));
7754
+ if (values.length === 0) continue;
7755
+ produced = true;
7756
+ for (const value of values) link.client.publishState(value.topic, value.value);
7701
7757
  }
7702
- this.push(deviceId, values);
7758
+ if (!produced) this.ctx.logger.debug("ha-export: track produced no exportable state", {
7759
+ tags: { deviceId },
7760
+ meta: {
7761
+ trackId,
7762
+ bestClassName,
7763
+ phase,
7764
+ fusedIntoTrackId
7765
+ }
7766
+ });
7703
7767
  }
7704
7768
  onAudioWindow(data) {
7705
7769
  const deviceId = readNumber(data, "deviceId");
@@ -7844,7 +7908,6 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7844
7908
  await this.refreshBrokers();
7845
7909
  const fullResync = this.resync.take(reason);
7846
7910
  let readsHeld = false;
7847
- await this.refreshMediaBaseUrl();
7848
7911
  await this.refreshComponentSupport();
7849
7912
  const enabled = this.enabledBrokerIds();
7850
7913
  const devices = await this.ctx.api.deviceManager.listAll.query({});
@@ -9321,28 +9384,41 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9321
9384
  * cannot open is worse than no image, because it renders as a broken
9322
9385
  * entity rather than an empty one.
9323
9386
  */
9324
- mintTrackMediaUrl(trackId, deviceId) {
9325
- if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
9387
+ mintTrackMediaUrl(trackId, deviceId, baseUrl) {
9388
+ if (this.mediaSecret.length === 0) return null;
9326
9389
  return buildMediaUrl({
9327
- baseUrl: this.mediaBaseUrl,
9390
+ baseUrl,
9328
9391
  routePrefix: MEDIA_ROUTE_PREFIX,
9329
9392
  id: `${String(deviceId)}:${trackId}`,
9330
9393
  secret: this.mediaSecret,
9331
9394
  expMs: Date.now() + MEDIA_URL_TTL_MS
9332
9395
  });
9333
9396
  }
9334
- async refreshMediaBaseUrl() {
9335
- if (this.config.publicBaseUrl.length > 0) {
9336
- this.mediaBaseUrl = this.config.publicBaseUrl;
9337
- return;
9338
- }
9397
+ /**
9398
+ * Each broker's media base, read per use: the palette's role the broker chose
9399
+ * (`mediaRole:<brokerId>`, `internal` by default). A role that resolves to nothing
9400
+ * is `null` and says so — the image entities then carry no value, which reads as
9401
+ * empty rather than as a broken link.
9402
+ */
9403
+ async mediaBasesFor(brokerIds, deviceId) {
9404
+ let bases;
9339
9405
  try {
9340
- this.mediaBaseUrl = haMediaBaseUrl(await this.ctx.api.localNetwork.getAddresses.query());
9406
+ bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
9341
9407
  } catch (err) {
9342
- this.mediaBaseUrl = null;
9343
- this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });
9408
+ this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9409
+ tags: { deviceId },
9410
+ meta: { error: errMsg(err) }
9411
+ });
9412
+ return new Map(brokerIds.map((brokerId) => [brokerId, null]));
9413
+ }
9414
+ for (const brokerId of brokerIds) {
9415
+ if (bases.get(brokerId) !== null) continue;
9416
+ this.ctx.logger.warn("ha-export: the broker media role resolves to no hub address — image entities carry no value until the Network page names one", {
9417
+ tags: { deviceId },
9418
+ meta: { brokerId }
9419
+ });
9344
9420
  }
9345
- if (this.mediaBaseUrl === null) this.ctx.logger.warn("ha-export: no reachable hub URL — image entities will carry no value until one is set");
9421
+ return bases;
9346
9422
  }
9347
9423
  /**
9348
9424
  * The cap-level expose/unexpose applies to EVERY enabled broker.
@@ -9396,21 +9472,26 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9396
9472
  variant: "warning"
9397
9473
  }]
9398
9474
  },
9399
- {
9475
+ ...this.config.knownBrokers.length > 0 ? [{
9400
9476
  id: "ha-export-transport",
9401
9477
  title: "Connection",
9402
9478
  titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
9479
+ description: "The hub address each Home Assistant instance fetches image links from. Internal is the address flagged Internal on the Network page; choose Public endpoint for an instance outside your network.",
9403
9480
  columns: 1,
9404
- fields: [{
9405
- type: "text",
9406
- key: "publicBaseUrl",
9407
- label: "Hub URL Home Assistant can reach",
9408
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.label",
9409
- description: "Used for the signed image links Home Assistant fetches; leave empty to derive one automatically.",
9410
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.description",
9411
- default: ""
9412
- }]
9413
- },
9481
+ fields: this.config.knownBrokers.map((broker) => ({
9482
+ type: "select",
9483
+ key: mediaRoleKey(broker.id),
9484
+ label: `Image links for ${broker.name}`,
9485
+ options: [{
9486
+ value: "internal",
9487
+ label: "Internal (LAN address)"
9488
+ }, {
9489
+ value: "publicEndpoint",
9490
+ label: "Public endpoint (reachable from outside)"
9491
+ }],
9492
+ default: "internal"
9493
+ }))
9494
+ }] : [],
9414
9495
  {
9415
9496
  id: "ha-export-advanced",
9416
9497
  title: "Advanced",
@@ -1,4 +1,4 @@
1
- import { B as consumableKindOf, Et as sendToExportedTarget, K as deviceExportCapability, Kt as isReadingQuantity, Vt as baseUnitOf, Yt as array, _ as PLUG_APPLIANCE_TYPES, bt as runExportedAction, c as COCO_TO_MACRO, d as ConsumableItemSchema, en as object, f as ConsumableStatusSchema, g as MediaPlayerRepeatSchema, gt as pruneDeletedEntities, h as HvacModeSchema, i as AlarmArmModeSchema, in as unknown, j as buildAddonRouteProvider, l as CameraSwitchIdSchema, m as FanDirectionSchema, nn as string, qt as nodePin, r as listHomeAssistantBrokers, s as CAMERA_SWITCH_ORDER, u as ConsumableFormSchema, un as EventCategory, ut as oauthIntegrationCapability, x as addonRoutesCapability, z as consumableFormOf, zt as BaseAddon } from "../ha-broker-source-DRIYmtMd.mjs";
1
+ import { B as consumableKindOf, Bt as BaseAddon, Dt as sendToExportedTarget, Ht as baseUnitOf, Jt as isReadingQuantity, K as deviceExportCapability, Wt as defineAddonMigrations, Yt as nodePin, Zt as array, _ as PLUG_APPLIANCE_TYPES, _t as pruneDeletedEntities, c as COCO_TO_MACRO, d as ConsumableItemSchema, f as ConsumableStatusSchema, fn as EventCategory, g as MediaPlayerRepeatSchema, h as HvacModeSchema, i as AlarmArmModeSchema, in as string, j as buildAddonRouteProvider, l as CameraSwitchIdSchema, m as FanDirectionSchema, nn as object, on as unknown, pt as preconditionOf, r as listHomeAssistantBrokers, s as CAMERA_SWITCH_ORDER, u as ConsumableFormSchema, ut as oauthIntegrationCapability, x as addonRoutesCapability, xt as runExportedAction, z as consumableFormOf } from "../ha-broker-source-iLA_DsW8.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -3450,6 +3450,10 @@ function controlEntitySpecs(slice, quantity) {
3450
3450
  }, kindExtra];
3451
3451
  }
3452
3452
  var CAPS_NOT_EXPORTED = [
3453
+ {
3454
+ cap: "do-not-disturb",
3455
+ reason: "A vendor switch of a speaker, provided so far only by an imported Echo: its media player is the entity a home-automation front end needs, and no entity kind has been chosen for the Do Not Disturb switch yet."
3456
+ },
3453
3457
  {
3454
3458
  cap: "timed-run",
3455
3459
  reason: "A hub-side timer for a device that has none: a \"run for N minutes\" command and the deadline of the pending switch-off. The switch or pump it drives is already exported through `switch`, and the deadline is a countdown, not a value an entity can hold."
@@ -4901,6 +4905,49 @@ async function call(slice, capName, make) {
4901
4905
  }
4902
4906
  return { ok: true };
4903
4907
  }
4908
+ //#endregion
4909
+ //#region src/ha-export/migrations/drop-public-base-url.ts
4910
+ /**
4911
+ * The media base is no longer a setting of this addon (D986 §5): each Home Assistant
4912
+ * instance chooses a hub address ROLE (`mediaRole:<brokerId>`) and the address is read from
4913
+ * the palette on every use. The `publicBaseUrl` key older versions persisted is deleted.
4914
+ * Every other key is left exactly as stored.
4915
+ *
4916
+ * Idempotent: a store without the key yields an empty plan, which writes nothing.
4917
+ */
4918
+ /** The settings scope of this addon (`<addonId>:addon-settings`). */
4919
+ var HA_EXPORT_SETTINGS_SCOPE = "homeassistant-export:addon-settings";
4920
+ var RETIRED_URL_KEYS = ["publicBaseUrl"];
4921
+ async function planDropPublicBaseUrl(ports) {
4922
+ const stored = await ports.readStored();
4923
+ const present = RETIRED_URL_KEYS.filter((key) => key in stored);
4924
+ if (present.length === 0) return { edits: [] };
4925
+ return { edits: [{
4926
+ op: "deleteKeys",
4927
+ scope: HA_EXPORT_SETTINGS_SCOPE,
4928
+ keys: [...present],
4929
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [key, stored[key]])))
4930
+ }] };
4931
+ }
4932
+ //#endregion
4933
+ //#region src/ha-export/migrations/index.ts
4934
+ /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
4935
+ var ADDON_MIGRATIONS = [{
4936
+ meta: {
4937
+ id: "homeassistant-export/2026-11-02-drop-public-base-url",
4938
+ owner: "homeassistant-export",
4939
+ level: "addon-state",
4940
+ seq: 1,
4941
+ dependsOn: [],
4942
+ store: "settings",
4943
+ scope: "cluster",
4944
+ onFailure: "tolerate",
4945
+ bodyKind: "plan",
4946
+ description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
4947
+ fixtureMark: "the reconcile interval the migration does not know"
4948
+ },
4949
+ plan: planDropPublicBaseUrl
4950
+ }];
4904
4951
  process.platform, process.env["SystemRoot"];
4905
4952
  /**
4906
4953
  * The ONE derivation of "a signed, expiring URL".
@@ -4980,15 +5027,26 @@ function verifyExpiringUrl(input) {
4980
5027
  * an expired link is refused whether or not its signature is valid, so a leaked
4981
5028
  * URL stops working on its own.
4982
5029
  */
5030
+ /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5031
+ function mediaRoleKey(brokerId) {
5032
+ return `mediaRole:${brokerId}`;
5033
+ }
5034
+ /** A broker's chosen role; anything stored that is not a known role is `internal`. */
5035
+ function haMediaRoleOf(store, brokerId) {
5036
+ return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
5037
+ }
4983
5038
  /**
4984
- * The base a media link is minted on when the operator left the addon's own
4985
- * field empty: the hub's `internal` role (D734) — the address flagged
4986
- * "Internal" on the Network page, else the detected LAN address. Home
4987
- * Assistant is a LAN device pulling from the hub, so never the public
4988
- * endpoint, and never loopback (`null` when nothing qualifies).
5039
+ * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5040
+ * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5041
+ * when the role resolves to nothing): a link Home Assistant cannot open is worse
5042
+ * than no image.
4989
5043
  */
4990
- function haMediaBaseUrl(book) {
4991
- return book.roles.internal.resolved;
5044
+ function haMediaBaseUrl(book, role = "internal") {
5045
+ return book.roles[role].resolved;
5046
+ }
5047
+ /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5048
+ function mediaBasesByBroker(store, brokerIds, book) {
5049
+ return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
4992
5050
  }
4993
5051
  /** How long a minted link stays valid. Long enough for HA to render it. */
4994
5052
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
@@ -7129,8 +7187,7 @@ var DEFAULT_CONFIG = {
7129
7187
  exportedEntities: {
7130
7188
  actions: [],
7131
7189
  targets: []
7132
- },
7133
- publicBaseUrl: ""
7190
+ }
7134
7191
  };
7135
7192
  var HaExportAddon = class extends BaseAddon {
7136
7193
  links = /* @__PURE__ */ new Map();
@@ -7139,7 +7196,6 @@ var HaExportAddon = class extends BaseAddon {
7139
7196
  /** numeric deviceId → deviceKey, for the event path. */
7140
7197
  keyByDeviceId = /* @__PURE__ */ new Map();
7141
7198
  mediaSecret = "";
7142
- mediaBaseUrl = null;
7143
7199
  reconcileTimer = null;
7144
7200
  reconcileInFlight = false;
7145
7201
  /** A reason asked for while one was running. Re-run, never dropped. */
@@ -7239,6 +7295,7 @@ var HaExportAddon = class extends BaseAddon {
7239
7295
  super({ ...DEFAULT_CONFIG });
7240
7296
  }
7241
7297
  async onInitialize() {
7298
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, { readStored: async () => this.resolveGlobalStore() }));
7242
7299
  const secret = this.state("haExportMediaSecret", string(), "");
7243
7300
  const existing = await secret.get();
7244
7301
  if (existing.length === 0) {
@@ -7639,32 +7696,39 @@ var HaExportAddon = class extends BaseAddon {
7639
7696
  const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
7640
7697
  const zonesVisited = readStringArray(data, "zonesVisited");
7641
7698
  const fusedIntoTrackId = readString(data, "fusedIntoTrackId");
7642
- const mediaUrl = this.mintTrackMediaUrl(trackId, deviceId);
7643
- const values = projectTrackLifecycle(deviceKey, {
7644
- deviceId,
7645
- trackId,
7646
- phase,
7647
- classes,
7648
- ...bestClassName !== null ? { bestClassName } : {},
7649
- ...label !== null ? { label } : {},
7650
- ...plateText !== null ? { plateText } : {},
7651
- lastSeen,
7652
- zonesVisited,
7653
- ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7654
- }, mediaUrl);
7655
- if (values.length === 0) {
7656
- this.ctx.logger.debug("ha-export: track produced no exportable state", {
7657
- tags: { deviceId },
7658
- meta: {
7659
- trackId,
7660
- bestClassName,
7661
- phase,
7662
- fusedIntoTrackId
7663
- }
7664
- });
7665
- return;
7699
+ const exported = this.exported.get(deviceKey);
7700
+ if (exported === void 0) return;
7701
+ const bases = await this.mediaBasesFor(exported.brokerIds, deviceId);
7702
+ let produced = false;
7703
+ for (const brokerId of exported.brokerIds) {
7704
+ const link = this.links.get(brokerId);
7705
+ if (link === void 0) continue;
7706
+ const base = bases.get(brokerId) ?? null;
7707
+ const values = projectTrackLifecycle(deviceKey, {
7708
+ deviceId,
7709
+ trackId,
7710
+ phase,
7711
+ classes,
7712
+ ...bestClassName !== null ? { bestClassName } : {},
7713
+ ...label !== null ? { label } : {},
7714
+ ...plateText !== null ? { plateText } : {},
7715
+ lastSeen,
7716
+ zonesVisited,
7717
+ ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7718
+ }, base === null ? null : this.mintTrackMediaUrl(trackId, deviceId, base));
7719
+ if (values.length === 0) continue;
7720
+ produced = true;
7721
+ for (const value of values) link.client.publishState(value.topic, value.value);
7666
7722
  }
7667
- this.push(deviceId, values);
7723
+ if (!produced) this.ctx.logger.debug("ha-export: track produced no exportable state", {
7724
+ tags: { deviceId },
7725
+ meta: {
7726
+ trackId,
7727
+ bestClassName,
7728
+ phase,
7729
+ fusedIntoTrackId
7730
+ }
7731
+ });
7668
7732
  }
7669
7733
  onAudioWindow(data) {
7670
7734
  const deviceId = readNumber(data, "deviceId");
@@ -7809,7 +7873,6 @@ var HaExportAddon = class extends BaseAddon {
7809
7873
  await this.refreshBrokers();
7810
7874
  const fullResync = this.resync.take(reason);
7811
7875
  let readsHeld = false;
7812
- await this.refreshMediaBaseUrl();
7813
7876
  await this.refreshComponentSupport();
7814
7877
  const enabled = this.enabledBrokerIds();
7815
7878
  const devices = await this.ctx.api.deviceManager.listAll.query({});
@@ -9286,28 +9349,41 @@ var HaExportAddon = class extends BaseAddon {
9286
9349
  * cannot open is worse than no image, because it renders as a broken
9287
9350
  * entity rather than an empty one.
9288
9351
  */
9289
- mintTrackMediaUrl(trackId, deviceId) {
9290
- if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
9352
+ mintTrackMediaUrl(trackId, deviceId, baseUrl) {
9353
+ if (this.mediaSecret.length === 0) return null;
9291
9354
  return buildMediaUrl({
9292
- baseUrl: this.mediaBaseUrl,
9355
+ baseUrl,
9293
9356
  routePrefix: MEDIA_ROUTE_PREFIX,
9294
9357
  id: `${String(deviceId)}:${trackId}`,
9295
9358
  secret: this.mediaSecret,
9296
9359
  expMs: Date.now() + MEDIA_URL_TTL_MS
9297
9360
  });
9298
9361
  }
9299
- async refreshMediaBaseUrl() {
9300
- if (this.config.publicBaseUrl.length > 0) {
9301
- this.mediaBaseUrl = this.config.publicBaseUrl;
9302
- return;
9303
- }
9362
+ /**
9363
+ * Each broker's media base, read per use: the palette's role the broker chose
9364
+ * (`mediaRole:<brokerId>`, `internal` by default). A role that resolves to nothing
9365
+ * is `null` and says so — the image entities then carry no value, which reads as
9366
+ * empty rather than as a broken link.
9367
+ */
9368
+ async mediaBasesFor(brokerIds, deviceId) {
9369
+ let bases;
9304
9370
  try {
9305
- this.mediaBaseUrl = haMediaBaseUrl(await this.ctx.api.localNetwork.getAddresses.query());
9371
+ bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
9306
9372
  } catch (err) {
9307
- this.mediaBaseUrl = null;
9308
- this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });
9373
+ this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9374
+ tags: { deviceId },
9375
+ meta: { error: errMsg(err) }
9376
+ });
9377
+ return new Map(brokerIds.map((brokerId) => [brokerId, null]));
9378
+ }
9379
+ for (const brokerId of brokerIds) {
9380
+ if (bases.get(brokerId) !== null) continue;
9381
+ this.ctx.logger.warn("ha-export: the broker media role resolves to no hub address — image entities carry no value until the Network page names one", {
9382
+ tags: { deviceId },
9383
+ meta: { brokerId }
9384
+ });
9309
9385
  }
9310
- if (this.mediaBaseUrl === null) this.ctx.logger.warn("ha-export: no reachable hub URL — image entities will carry no value until one is set");
9386
+ return bases;
9311
9387
  }
9312
9388
  /**
9313
9389
  * The cap-level expose/unexpose applies to EVERY enabled broker.
@@ -9361,21 +9437,26 @@ var HaExportAddon = class extends BaseAddon {
9361
9437
  variant: "warning"
9362
9438
  }]
9363
9439
  },
9364
- {
9440
+ ...this.config.knownBrokers.length > 0 ? [{
9365
9441
  id: "ha-export-transport",
9366
9442
  title: "Connection",
9367
9443
  titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
9444
+ description: "The hub address each Home Assistant instance fetches image links from. Internal is the address flagged Internal on the Network page; choose Public endpoint for an instance outside your network.",
9368
9445
  columns: 1,
9369
- fields: [{
9370
- type: "text",
9371
- key: "publicBaseUrl",
9372
- label: "Hub URL Home Assistant can reach",
9373
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.label",
9374
- description: "Used for the signed image links Home Assistant fetches; leave empty to derive one automatically.",
9375
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.description",
9376
- default: ""
9377
- }]
9378
- },
9446
+ fields: this.config.knownBrokers.map((broker) => ({
9447
+ type: "select",
9448
+ key: mediaRoleKey(broker.id),
9449
+ label: `Image links for ${broker.name}`,
9450
+ options: [{
9451
+ value: "internal",
9452
+ label: "Internal (LAN address)"
9453
+ }, {
9454
+ value: "publicEndpoint",
9455
+ label: "Public endpoint (reachable from outside)"
9456
+ }],
9457
+ default: "internal"
9458
+ }))
9459
+ }] : [],
9379
9460
  {
9380
9461
  id: "ha-export-advanced",
9381
9462
  title: "Advanced",
package/i18n/en.json CHANGED
@@ -14,8 +14,6 @@
14
14
  "globalSettings.s_ha-export-brokers.title": "Home Assistant instances",
15
15
  "globalSettings.s_ha-export-brokers.ha-export___no-brokers.label": "No brokers",
16
16
  "globalSettings.s_ha-export-transport.title": "Connection",
17
- "globalSettings.s_ha-export-transport.publicBaseUrl.label": "Hub URL Home Assistant can reach",
18
- "globalSettings.s_ha-export-transport.publicBaseUrl.description": "Used for the signed image links Home Assistant fetches; leave empty to derive one automatically.",
19
17
  "globalSettings.s_ha-export-advanced.title": "Advanced",
20
18
  "globalSettings.s_ha-export-advanced.reconcileIntervalSec.label": "Reconcile interval (s)",
21
19
  "globalSettings.s_ha-export-advanced.reconcileIntervalSec.description": "Full re-announce and re-push cadence.",
package/i18n/it.json CHANGED
@@ -14,8 +14,6 @@
14
14
  "globalSettings.s_ha-export-brokers.title": "Istanze di Home Assistant",
15
15
  "globalSettings.s_ha-export-brokers.ha-export___no-brokers.label": "Nessun broker",
16
16
  "globalSettings.s_ha-export-transport.title": "Connessione",
17
- "globalSettings.s_ha-export-transport.publicBaseUrl.label": "URL dell'hub raggiungibile da Home Assistant",
18
- "globalSettings.s_ha-export-transport.publicBaseUrl.description": "Usato per i link firmati delle immagini che Home Assistant scarica; lascia vuoto per ricavarlo automaticamente.",
19
17
  "globalSettings.s_ha-export-advanced.title": "Avanzate",
20
18
  "globalSettings.s_ha-export-advanced.reconcileIntervalSec.label": "Intervallo di riconciliazione (s)",
21
19
  "globalSettings.s_ha-export-advanced.reconcileIntervalSec.description": "Cadenza di ri-annuncio e ri-invio completo.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-provider-homeassistant",
3
- "version": "1.2.646",
3
+ "version": "1.2.648",
4
4
  "description": "Home Assistant device provider addon for CamStack",
5
5
  "keywords": [
6
6
  "camstack",
@@ -127,7 +127,7 @@
127
127
  "description": "Device provider (import) + native entity export (push). Two addons, one bundle, separate processes."
128
128
  },
129
129
  "requires": {
130
- "server": ">=1.2.730"
130
+ "server": ">=1.2.733"
131
131
  }
132
132
  },
133
133
  "files": [