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