@camstack/addon-provider-homeassistant 1.2.647 → 1.2.649

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-BFFi4NDH.js");
27
+ const require_ha_broker_source = require("../ha-broker-source-CE3nHkyW.js");
28
28
  let node_crypto = require("node:crypto");
29
29
  require("node:zlib");
30
30
  let node_fs = require("node:fs");
@@ -4940,6 +4940,60 @@ async function call(slice, capName, make) {
4940
4940
  }
4941
4941
  return { ok: true };
4942
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 addon id, the prefix of its rows in the canonical settings table. */
4954
+ var HA_EXPORT_ADDON_ID = "homeassistant-export";
4955
+ /**
4956
+ * Where the addon's global settings live: the canonical `addon-settings` table, one row per
4957
+ * key, id `<addonId>.<key>`, data `{ addonId, key, value }` (never a per-addon scoped table,
4958
+ * which exists only when something lazily declared it).
4959
+ */
4960
+ var ADDON_SETTINGS_TABLE = "addon-settings";
4961
+ var RETIRED_URL_KEYS = ["publicBaseUrl"];
4962
+ async function planDropPublicBaseUrl(ports) {
4963
+ const stored = await ports.readStored();
4964
+ const present = RETIRED_URL_KEYS.filter((key) => key in stored);
4965
+ if (present.length === 0) return { edits: [] };
4966
+ const rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
4967
+ return { edits: [{
4968
+ op: "deleteKeys",
4969
+ scope: ADDON_SETTINGS_TABLE,
4970
+ keys: present.map(rowId),
4971
+ precondition: require_ha_broker_source.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
4972
+ addonId: HA_EXPORT_ADDON_ID,
4973
+ key,
4974
+ value: stored[key]
4975
+ }])))
4976
+ }] };
4977
+ }
4978
+ //#endregion
4979
+ //#region src/ha-export/migrations/index.ts
4980
+ /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
4981
+ var ADDON_MIGRATIONS = [{
4982
+ meta: {
4983
+ id: "homeassistant-export/2026-11-02-drop-public-base-url",
4984
+ owner: "homeassistant-export",
4985
+ level: "addon-state",
4986
+ seq: 1,
4987
+ dependsOn: [],
4988
+ store: "settings",
4989
+ scope: "cluster",
4990
+ onFailure: "tolerate",
4991
+ bodyKind: "plan",
4992
+ description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
4993
+ fixtureMark: "the reconcile interval the migration does not know"
4994
+ },
4995
+ plan: planDropPublicBaseUrl
4996
+ }];
4943
4997
  process.platform, process.env["SystemRoot"];
4944
4998
  /**
4945
4999
  * The ONE derivation of "a signed, expiring URL".
@@ -5019,15 +5073,26 @@ function verifyExpiringUrl(input) {
5019
5073
  * an expired link is refused whether or not its signature is valid, so a leaked
5020
5074
  * URL stops working on its own.
5021
5075
  */
5076
+ /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5077
+ function mediaRoleKey(brokerId) {
5078
+ return `mediaRole:${brokerId}`;
5079
+ }
5080
+ /** A broker's chosen role; anything stored that is not a known role is `internal`. */
5081
+ function haMediaRoleOf(store, brokerId) {
5082
+ return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
5083
+ }
5022
5084
  /**
5023
- * The base a media link is minted on when the operator left the addon's own
5024
- * field empty: the hub's `internal` role (D734) — the address flagged
5025
- * "Internal" on the Network page, else the detected LAN address. Home
5026
- * Assistant is a LAN device pulling from the hub, so never the public
5027
- * endpoint, and never loopback (`null` when nothing qualifies).
5085
+ * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5086
+ * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5087
+ * when the role resolves to nothing): a link Home Assistant cannot open is worse
5088
+ * than no image.
5028
5089
  */
5029
- function haMediaBaseUrl(book) {
5030
- return book.roles.internal.resolved;
5090
+ function haMediaBaseUrl(book, role = "internal") {
5091
+ return book.roles[role].resolved;
5092
+ }
5093
+ /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5094
+ function mediaBasesByBroker(store, brokerIds, book) {
5095
+ return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
5031
5096
  }
5032
5097
  /** How long a minted link stays valid. Long enough for HA to render it. */
5033
5098
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
@@ -7168,8 +7233,7 @@ var DEFAULT_CONFIG = {
7168
7233
  exportedEntities: {
7169
7234
  actions: [],
7170
7235
  targets: []
7171
- },
7172
- publicBaseUrl: ""
7236
+ }
7173
7237
  };
7174
7238
  var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7175
7239
  links = /* @__PURE__ */ new Map();
@@ -7178,7 +7242,6 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7178
7242
  /** numeric deviceId → deviceKey, for the event path. */
7179
7243
  keyByDeviceId = /* @__PURE__ */ new Map();
7180
7244
  mediaSecret = "";
7181
- mediaBaseUrl = null;
7182
7245
  reconcileTimer = null;
7183
7246
  reconcileInFlight = false;
7184
7247
  /** A reason asked for while one was running. Re-run, never dropped. */
@@ -7278,6 +7341,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7278
7341
  super({ ...DEFAULT_CONFIG });
7279
7342
  }
7280
7343
  async onInitialize() {
7344
+ await this.runMigrations(require_ha_broker_source.defineAddonMigrations(ADDON_MIGRATIONS, { readStored: async () => this.resolveGlobalStore() }));
7281
7345
  const secret = this.state("haExportMediaSecret", require_ha_broker_source.string(), "");
7282
7346
  const existing = await secret.get();
7283
7347
  if (existing.length === 0) {
@@ -7678,32 +7742,39 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7678
7742
  const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
7679
7743
  const zonesVisited = readStringArray(data, "zonesVisited");
7680
7744
  const fusedIntoTrackId = readString(data, "fusedIntoTrackId");
7681
- const mediaUrl = this.mintTrackMediaUrl(trackId, deviceId);
7682
- const values = projectTrackLifecycle(deviceKey, {
7683
- deviceId,
7684
- trackId,
7685
- phase,
7686
- classes,
7687
- ...bestClassName !== null ? { bestClassName } : {},
7688
- ...label !== null ? { label } : {},
7689
- ...plateText !== null ? { plateText } : {},
7690
- lastSeen,
7691
- zonesVisited,
7692
- ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7693
- }, mediaUrl);
7694
- if (values.length === 0) {
7695
- this.ctx.logger.debug("ha-export: track produced no exportable state", {
7696
- tags: { deviceId },
7697
- meta: {
7698
- trackId,
7699
- bestClassName,
7700
- phase,
7701
- fusedIntoTrackId
7702
- }
7703
- });
7704
- return;
7745
+ const exported = this.exported.get(deviceKey);
7746
+ if (exported === void 0) return;
7747
+ const bases = await this.mediaBasesFor(exported.brokerIds, deviceId);
7748
+ let produced = false;
7749
+ for (const brokerId of exported.brokerIds) {
7750
+ const link = this.links.get(brokerId);
7751
+ if (link === void 0) continue;
7752
+ const base = bases.get(brokerId) ?? null;
7753
+ const values = projectTrackLifecycle(deviceKey, {
7754
+ deviceId,
7755
+ trackId,
7756
+ phase,
7757
+ classes,
7758
+ ...bestClassName !== null ? { bestClassName } : {},
7759
+ ...label !== null ? { label } : {},
7760
+ ...plateText !== null ? { plateText } : {},
7761
+ lastSeen,
7762
+ zonesVisited,
7763
+ ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7764
+ }, base === null ? null : this.mintTrackMediaUrl(trackId, deviceId, base));
7765
+ if (values.length === 0) continue;
7766
+ produced = true;
7767
+ for (const value of values) link.client.publishState(value.topic, value.value);
7705
7768
  }
7706
- this.push(deviceId, values);
7769
+ if (!produced) this.ctx.logger.debug("ha-export: track produced no exportable state", {
7770
+ tags: { deviceId },
7771
+ meta: {
7772
+ trackId,
7773
+ bestClassName,
7774
+ phase,
7775
+ fusedIntoTrackId
7776
+ }
7777
+ });
7707
7778
  }
7708
7779
  onAudioWindow(data) {
7709
7780
  const deviceId = readNumber(data, "deviceId");
@@ -7848,7 +7919,6 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
7848
7919
  await this.refreshBrokers();
7849
7920
  const fullResync = this.resync.take(reason);
7850
7921
  let readsHeld = false;
7851
- await this.refreshMediaBaseUrl();
7852
7922
  await this.refreshComponentSupport();
7853
7923
  const enabled = this.enabledBrokerIds();
7854
7924
  const devices = await this.ctx.api.deviceManager.listAll.query({});
@@ -9325,28 +9395,41 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9325
9395
  * cannot open is worse than no image, because it renders as a broken
9326
9396
  * entity rather than an empty one.
9327
9397
  */
9328
- mintTrackMediaUrl(trackId, deviceId) {
9329
- if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
9398
+ mintTrackMediaUrl(trackId, deviceId, baseUrl) {
9399
+ if (this.mediaSecret.length === 0) return null;
9330
9400
  return buildMediaUrl({
9331
- baseUrl: this.mediaBaseUrl,
9401
+ baseUrl,
9332
9402
  routePrefix: MEDIA_ROUTE_PREFIX,
9333
9403
  id: `${String(deviceId)}:${trackId}`,
9334
9404
  secret: this.mediaSecret,
9335
9405
  expMs: Date.now() + MEDIA_URL_TTL_MS
9336
9406
  });
9337
9407
  }
9338
- async refreshMediaBaseUrl() {
9339
- if (this.config.publicBaseUrl.length > 0) {
9340
- this.mediaBaseUrl = this.config.publicBaseUrl;
9341
- return;
9342
- }
9408
+ /**
9409
+ * Each broker's media base, read per use: the palette's role the broker chose
9410
+ * (`mediaRole:<brokerId>`, `internal` by default). A role that resolves to nothing
9411
+ * is `null` and says so — the image entities then carry no value, which reads as
9412
+ * empty rather than as a broken link.
9413
+ */
9414
+ async mediaBasesFor(brokerIds, deviceId) {
9415
+ let bases;
9343
9416
  try {
9344
- this.mediaBaseUrl = haMediaBaseUrl(await this.ctx.api.localNetwork.getAddresses.query());
9417
+ bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
9345
9418
  } catch (err) {
9346
- this.mediaBaseUrl = null;
9347
- this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });
9419
+ this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9420
+ tags: { deviceId },
9421
+ meta: { error: errMsg(err) }
9422
+ });
9423
+ return new Map(brokerIds.map((brokerId) => [brokerId, null]));
9424
+ }
9425
+ for (const brokerId of brokerIds) {
9426
+ if (bases.get(brokerId) !== null) continue;
9427
+ 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", {
9428
+ tags: { deviceId },
9429
+ meta: { brokerId }
9430
+ });
9348
9431
  }
9349
- if (this.mediaBaseUrl === null) this.ctx.logger.warn("ha-export: no reachable hub URL — image entities will carry no value until one is set");
9432
+ return bases;
9350
9433
  }
9351
9434
  /**
9352
9435
  * The cap-level expose/unexpose applies to EVERY enabled broker.
@@ -9400,21 +9483,26 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
9400
9483
  variant: "warning"
9401
9484
  }]
9402
9485
  },
9403
- {
9486
+ ...this.config.knownBrokers.length > 0 ? [{
9404
9487
  id: "ha-export-transport",
9405
9488
  title: "Connection",
9406
9489
  titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
9490
+ 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.",
9407
9491
  columns: 1,
9408
- fields: [{
9409
- type: "text",
9410
- key: "publicBaseUrl",
9411
- label: "Hub URL Home Assistant can reach",
9412
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.label",
9413
- description: "Used for the signed image links Home Assistant fetches; leave empty to derive one automatically.",
9414
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.description",
9415
- default: ""
9416
- }]
9417
- },
9492
+ fields: this.config.knownBrokers.map((broker) => ({
9493
+ type: "select",
9494
+ key: mediaRoleKey(broker.id),
9495
+ label: `Image links for ${broker.name}`,
9496
+ options: [{
9497
+ value: "internal",
9498
+ label: "Internal (LAN address)"
9499
+ }, {
9500
+ value: "publicEndpoint",
9501
+ label: "Public endpoint (reachable from outside)"
9502
+ }],
9503
+ default: "internal"
9504
+ }))
9505
+ }] : [],
9418
9506
  {
9419
9507
  id: "ha-export-advanced",
9420
9508
  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-B6wqfEUz.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-D3FBA6uG.mjs";
2
2
  import { createHmac, timingSafeEqual } from "node:crypto";
3
3
  import "node:zlib";
4
4
  import "node:fs";
@@ -4905,6 +4905,60 @@ async function call(slice, capName, make) {
4905
4905
  }
4906
4906
  return { ok: true };
4907
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 addon id, the prefix of its rows in the canonical settings table. */
4919
+ var HA_EXPORT_ADDON_ID = "homeassistant-export";
4920
+ /**
4921
+ * Where the addon's global settings live: the canonical `addon-settings` table, one row per
4922
+ * key, id `<addonId>.<key>`, data `{ addonId, key, value }` (never a per-addon scoped table,
4923
+ * which exists only when something lazily declared it).
4924
+ */
4925
+ var ADDON_SETTINGS_TABLE = "addon-settings";
4926
+ var RETIRED_URL_KEYS = ["publicBaseUrl"];
4927
+ async function planDropPublicBaseUrl(ports) {
4928
+ const stored = await ports.readStored();
4929
+ const present = RETIRED_URL_KEYS.filter((key) => key in stored);
4930
+ if (present.length === 0) return { edits: [] };
4931
+ const rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
4932
+ return { edits: [{
4933
+ op: "deleteKeys",
4934
+ scope: ADDON_SETTINGS_TABLE,
4935
+ keys: present.map(rowId),
4936
+ precondition: preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
4937
+ addonId: HA_EXPORT_ADDON_ID,
4938
+ key,
4939
+ value: stored[key]
4940
+ }])))
4941
+ }] };
4942
+ }
4943
+ //#endregion
4944
+ //#region src/ha-export/migrations/index.ts
4945
+ /** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
4946
+ var ADDON_MIGRATIONS = [{
4947
+ meta: {
4948
+ id: "homeassistant-export/2026-11-02-drop-public-base-url",
4949
+ owner: "homeassistant-export",
4950
+ level: "addon-state",
4951
+ seq: 1,
4952
+ dependsOn: [],
4953
+ store: "settings",
4954
+ scope: "cluster",
4955
+ onFailure: "tolerate",
4956
+ bodyKind: "plan",
4957
+ description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
4958
+ fixtureMark: "the reconcile interval the migration does not know"
4959
+ },
4960
+ plan: planDropPublicBaseUrl
4961
+ }];
4908
4962
  process.platform, process.env["SystemRoot"];
4909
4963
  /**
4910
4964
  * The ONE derivation of "a signed, expiring URL".
@@ -4984,15 +5038,26 @@ function verifyExpiringUrl(input) {
4984
5038
  * an expired link is refused whether or not its signature is valid, so a leaked
4985
5039
  * URL stops working on its own.
4986
5040
  */
5041
+ /** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
5042
+ function mediaRoleKey(brokerId) {
5043
+ return `mediaRole:${brokerId}`;
5044
+ }
5045
+ /** A broker's chosen role; anything stored that is not a known role is `internal`. */
5046
+ function haMediaRoleOf(store, brokerId) {
5047
+ return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
5048
+ }
4987
5049
  /**
4988
- * The base a media link is minted on when the operator left the addon's own
4989
- * field empty: the hub's `internal` role (D734) — the address flagged
4990
- * "Internal" on the Network page, else the detected LAN address. Home
4991
- * Assistant is a LAN device pulling from the hub, so never the public
4992
- * endpoint, and never loopback (`null` when nothing qualifies).
5050
+ * The base a media link is minted on: the hub's role (D734) — `internal` unless the
5051
+ * broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
5052
+ * when the role resolves to nothing): a link Home Assistant cannot open is worse
5053
+ * than no image.
4993
5054
  */
4994
- function haMediaBaseUrl(book) {
4995
- return book.roles.internal.resolved;
5055
+ function haMediaBaseUrl(book, role = "internal") {
5056
+ return book.roles[role].resolved;
5057
+ }
5058
+ /** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
5059
+ function mediaBasesByBroker(store, brokerIds, book) {
5060
+ return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
4996
5061
  }
4997
5062
  /** How long a minted link stays valid. Long enough for HA to render it. */
4998
5063
  var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
@@ -7133,8 +7198,7 @@ var DEFAULT_CONFIG = {
7133
7198
  exportedEntities: {
7134
7199
  actions: [],
7135
7200
  targets: []
7136
- },
7137
- publicBaseUrl: ""
7201
+ }
7138
7202
  };
7139
7203
  var HaExportAddon = class extends BaseAddon {
7140
7204
  links = /* @__PURE__ */ new Map();
@@ -7143,7 +7207,6 @@ var HaExportAddon = class extends BaseAddon {
7143
7207
  /** numeric deviceId → deviceKey, for the event path. */
7144
7208
  keyByDeviceId = /* @__PURE__ */ new Map();
7145
7209
  mediaSecret = "";
7146
- mediaBaseUrl = null;
7147
7210
  reconcileTimer = null;
7148
7211
  reconcileInFlight = false;
7149
7212
  /** A reason asked for while one was running. Re-run, never dropped. */
@@ -7243,6 +7306,7 @@ var HaExportAddon = class extends BaseAddon {
7243
7306
  super({ ...DEFAULT_CONFIG });
7244
7307
  }
7245
7308
  async onInitialize() {
7309
+ await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, { readStored: async () => this.resolveGlobalStore() }));
7246
7310
  const secret = this.state("haExportMediaSecret", string(), "");
7247
7311
  const existing = await secret.get();
7248
7312
  if (existing.length === 0) {
@@ -7643,32 +7707,39 @@ var HaExportAddon = class extends BaseAddon {
7643
7707
  const lastSeen = readNumber(data, "lastSeen") ?? Date.now();
7644
7708
  const zonesVisited = readStringArray(data, "zonesVisited");
7645
7709
  const fusedIntoTrackId = readString(data, "fusedIntoTrackId");
7646
- const mediaUrl = this.mintTrackMediaUrl(trackId, deviceId);
7647
- const values = projectTrackLifecycle(deviceKey, {
7648
- deviceId,
7649
- trackId,
7650
- phase,
7651
- classes,
7652
- ...bestClassName !== null ? { bestClassName } : {},
7653
- ...label !== null ? { label } : {},
7654
- ...plateText !== null ? { plateText } : {},
7655
- lastSeen,
7656
- zonesVisited,
7657
- ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7658
- }, mediaUrl);
7659
- if (values.length === 0) {
7660
- this.ctx.logger.debug("ha-export: track produced no exportable state", {
7661
- tags: { deviceId },
7662
- meta: {
7663
- trackId,
7664
- bestClassName,
7665
- phase,
7666
- fusedIntoTrackId
7667
- }
7668
- });
7669
- return;
7710
+ const exported = this.exported.get(deviceKey);
7711
+ if (exported === void 0) return;
7712
+ const bases = await this.mediaBasesFor(exported.brokerIds, deviceId);
7713
+ let produced = false;
7714
+ for (const brokerId of exported.brokerIds) {
7715
+ const link = this.links.get(brokerId);
7716
+ if (link === void 0) continue;
7717
+ const base = bases.get(brokerId) ?? null;
7718
+ const values = projectTrackLifecycle(deviceKey, {
7719
+ deviceId,
7720
+ trackId,
7721
+ phase,
7722
+ classes,
7723
+ ...bestClassName !== null ? { bestClassName } : {},
7724
+ ...label !== null ? { label } : {},
7725
+ ...plateText !== null ? { plateText } : {},
7726
+ lastSeen,
7727
+ zonesVisited,
7728
+ ...fusedIntoTrackId !== null ? { fusedIntoTrackId } : {}
7729
+ }, base === null ? null : this.mintTrackMediaUrl(trackId, deviceId, base));
7730
+ if (values.length === 0) continue;
7731
+ produced = true;
7732
+ for (const value of values) link.client.publishState(value.topic, value.value);
7670
7733
  }
7671
- this.push(deviceId, values);
7734
+ if (!produced) this.ctx.logger.debug("ha-export: track produced no exportable state", {
7735
+ tags: { deviceId },
7736
+ meta: {
7737
+ trackId,
7738
+ bestClassName,
7739
+ phase,
7740
+ fusedIntoTrackId
7741
+ }
7742
+ });
7672
7743
  }
7673
7744
  onAudioWindow(data) {
7674
7745
  const deviceId = readNumber(data, "deviceId");
@@ -7813,7 +7884,6 @@ var HaExportAddon = class extends BaseAddon {
7813
7884
  await this.refreshBrokers();
7814
7885
  const fullResync = this.resync.take(reason);
7815
7886
  let readsHeld = false;
7816
- await this.refreshMediaBaseUrl();
7817
7887
  await this.refreshComponentSupport();
7818
7888
  const enabled = this.enabledBrokerIds();
7819
7889
  const devices = await this.ctx.api.deviceManager.listAll.query({});
@@ -9290,28 +9360,41 @@ var HaExportAddon = class extends BaseAddon {
9290
9360
  * cannot open is worse than no image, because it renders as a broken
9291
9361
  * entity rather than an empty one.
9292
9362
  */
9293
- mintTrackMediaUrl(trackId, deviceId) {
9294
- if (this.mediaBaseUrl === null || this.mediaSecret.length === 0) return null;
9363
+ mintTrackMediaUrl(trackId, deviceId, baseUrl) {
9364
+ if (this.mediaSecret.length === 0) return null;
9295
9365
  return buildMediaUrl({
9296
- baseUrl: this.mediaBaseUrl,
9366
+ baseUrl,
9297
9367
  routePrefix: MEDIA_ROUTE_PREFIX,
9298
9368
  id: `${String(deviceId)}:${trackId}`,
9299
9369
  secret: this.mediaSecret,
9300
9370
  expMs: Date.now() + MEDIA_URL_TTL_MS
9301
9371
  });
9302
9372
  }
9303
- async refreshMediaBaseUrl() {
9304
- if (this.config.publicBaseUrl.length > 0) {
9305
- this.mediaBaseUrl = this.config.publicBaseUrl;
9306
- return;
9307
- }
9373
+ /**
9374
+ * Each broker's media base, read per use: the palette's role the broker chose
9375
+ * (`mediaRole:<brokerId>`, `internal` by default). A role that resolves to nothing
9376
+ * is `null` and says so — the image entities then carry no value, which reads as
9377
+ * empty rather than as a broken link.
9378
+ */
9379
+ async mediaBasesFor(brokerIds, deviceId) {
9380
+ let bases;
9308
9381
  try {
9309
- this.mediaBaseUrl = haMediaBaseUrl(await this.ctx.api.localNetwork.getAddresses.query());
9382
+ bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
9310
9383
  } catch (err) {
9311
- this.mediaBaseUrl = null;
9312
- this.ctx.logger.debug("ha-export: could not resolve a media base URL", { meta: { error: errMsg(err) } });
9384
+ this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
9385
+ tags: { deviceId },
9386
+ meta: { error: errMsg(err) }
9387
+ });
9388
+ return new Map(brokerIds.map((brokerId) => [brokerId, null]));
9389
+ }
9390
+ for (const brokerId of brokerIds) {
9391
+ if (bases.get(brokerId) !== null) continue;
9392
+ 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", {
9393
+ tags: { deviceId },
9394
+ meta: { brokerId }
9395
+ });
9313
9396
  }
9314
- if (this.mediaBaseUrl === null) this.ctx.logger.warn("ha-export: no reachable hub URL — image entities will carry no value until one is set");
9397
+ return bases;
9315
9398
  }
9316
9399
  /**
9317
9400
  * The cap-level expose/unexpose applies to EVERY enabled broker.
@@ -9365,21 +9448,26 @@ var HaExportAddon = class extends BaseAddon {
9365
9448
  variant: "warning"
9366
9449
  }]
9367
9450
  },
9368
- {
9451
+ ...this.config.knownBrokers.length > 0 ? [{
9369
9452
  id: "ha-export-transport",
9370
9453
  title: "Connection",
9371
9454
  titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
9455
+ 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.",
9372
9456
  columns: 1,
9373
- fields: [{
9374
- type: "text",
9375
- key: "publicBaseUrl",
9376
- label: "Hub URL Home Assistant can reach",
9377
- labelKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.label",
9378
- description: "Used for the signed image links Home Assistant fetches; leave empty to derive one automatically.",
9379
- descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-transport.publicBaseUrl.description",
9380
- default: ""
9381
- }]
9382
- },
9457
+ fields: this.config.knownBrokers.map((broker) => ({
9458
+ type: "select",
9459
+ key: mediaRoleKey(broker.id),
9460
+ label: `Image links for ${broker.name}`,
9461
+ options: [{
9462
+ value: "internal",
9463
+ label: "Internal (LAN address)"
9464
+ }, {
9465
+ value: "publicEndpoint",
9466
+ label: "Public endpoint (reachable from outside)"
9467
+ }],
9468
+ default: "internal"
9469
+ }))
9470
+ }] : [],
9383
9471
  {
9384
9472
  id: "ha-export-advanced",
9385
9473
  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.647",
3
+ "version": "1.2.649",
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.731"
130
+ "server": ">=1.2.738"
131
131
  }
132
132
  },
133
133
  "files": [