@camstack/addon-provider-homeassistant 1.2.654 → 1.2.656
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 +14 -0
- package/dist/addon.js +313 -88
- package/dist/addon.mjs +310 -88
- package/dist/ha-export/ha-export.addon.js +528 -275
- package/dist/ha-export/ha-export.addon.mjs +515 -276
- package/dist/{ha-instance-settings-Bn_Trz6p.js → ha-instance-settings-Dm0tU8iv.js} +1045 -340
- package/dist/{ha-instance-settings-g5pwb3Fi.mjs → ha-instance-settings-EQ5Ey3Q-.mjs} +1000 -331
- package/package.json +6 -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-Dm0tU8iv.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) {
|
|
@@ -7672,31 +7882,46 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7672
7882
|
{
|
|
7673
7883
|
capability: require_ha_instance_settings.deviceExportCapability,
|
|
7674
7884
|
provider: {
|
|
7675
|
-
getStatus: async () => this.buildStatus(),
|
|
7676
|
-
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7885
|
+
getStatus: async (input) => this.buildStatus(this.brokerScope(require_ha_instance_settings.statusInstanceOf(input))),
|
|
7886
|
+
listInstances: async () => this.config.knownBrokers.map((broker) => ({
|
|
7887
|
+
instanceId: broker.id,
|
|
7888
|
+
name: broker.name,
|
|
7889
|
+
enabled: this.enabledBrokerIds().includes(broker.id),
|
|
7890
|
+
kind: "home-assistant"
|
|
7680
7891
|
})),
|
|
7681
|
-
|
|
7682
|
-
|
|
7892
|
+
listSupportedDeviceKinds: async ({ instanceId }) => {
|
|
7893
|
+
this.brokerScope(instanceId);
|
|
7894
|
+
return [...SUPPORTED_DEVICE_KINDS];
|
|
7895
|
+
},
|
|
7896
|
+
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
|
|
7897
|
+
exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
|
|
7898
|
+
unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
|
|
7683
7899
|
listSupportedEntityKinds: async () => [
|
|
7684
7900
|
"device",
|
|
7685
7901
|
"action",
|
|
7686
7902
|
"notifier"
|
|
7687
7903
|
],
|
|
7688
|
-
listExposedEntities: async () => {
|
|
7904
|
+
listExposedEntities: async ({ instanceId }) => {
|
|
7905
|
+
const scope = this.brokerScope(instanceId);
|
|
7689
7906
|
await exportedEntities.pruneDeleted();
|
|
7690
|
-
return [...
|
|
7907
|
+
return [...this.exposedDeviceRows(scope).map((row) => ({
|
|
7691
7908
|
entity: {
|
|
7692
7909
|
kind: "device",
|
|
7693
|
-
deviceId
|
|
7910
|
+
deviceId: row.deviceId
|
|
7694
7911
|
},
|
|
7695
|
-
exposedAs:
|
|
7912
|
+
exposedAs: row.exposedAs
|
|
7696
7913
|
})), ...exportedEntities.list()];
|
|
7697
7914
|
},
|
|
7698
|
-
exposeEntity: async ({ entity }) =>
|
|
7699
|
-
|
|
7915
|
+
exposeEntity: async ({ instanceId, entity }) => {
|
|
7916
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
|
|
7917
|
+
this.brokerScope(instanceId);
|
|
7918
|
+
return exportedEntities.expose(entity);
|
|
7919
|
+
},
|
|
7920
|
+
unexposeEntity: async ({ instanceId, entity }) => {
|
|
7921
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
|
|
7922
|
+
this.brokerScope(instanceId);
|
|
7923
|
+
return exportedEntities.unexpose(entity);
|
|
7924
|
+
},
|
|
7700
7925
|
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
7701
7926
|
getDeviceLiveContribution: async () => null,
|
|
7702
7927
|
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
@@ -7721,29 +7946,43 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7721
7946
|
];
|
|
7722
7947
|
}
|
|
7723
7948
|
/**
|
|
7724
|
-
* The form of the addon shows each instance's own switch and media role
|
|
7725
|
-
*
|
|
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.
|
|
7726
7951
|
*/
|
|
7727
7952
|
async getGlobalSettings(overlay, cap, nodeId) {
|
|
7728
|
-
const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
|
|
7729
7953
|
return super.getGlobalSettings({
|
|
7730
7954
|
...overlay,
|
|
7731
|
-
...
|
|
7955
|
+
...linkFormOverlay(this.book())
|
|
7732
7956
|
}, cap, nodeId);
|
|
7733
7957
|
}
|
|
7734
7958
|
/**
|
|
7735
|
-
* An operator write: the `exportTo:` / `mediaRole:` keys go to the
|
|
7736
|
-
*
|
|
7737
|
-
*
|
|
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.
|
|
7738
7962
|
*/
|
|
7739
7963
|
async updateGlobalSettingsAsOperator(patch, nodeId) {
|
|
7740
|
-
const
|
|
7741
|
-
const
|
|
7742
|
-
for (const [
|
|
7743
|
-
|
|
7744
|
-
|
|
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);
|
|
7745
7983
|
}
|
|
7746
|
-
|
|
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) {
|
|
7747
7986
|
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7748
7987
|
return;
|
|
7749
7988
|
}
|
|
@@ -8255,7 +8494,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
8255
8494
|
* asking per camera would be the fan-out this repo just finished
|
|
8256
8495
|
* removing from a 300 s poll.
|
|
8257
8496
|
*/
|
|
8258
|
-
const exposedIds = exposedDeviceIds(this.
|
|
8497
|
+
const exposedIds = exposedDeviceIds(this.membership(), enabled);
|
|
8259
8498
|
const cameraDeviceIds = [];
|
|
8260
8499
|
for (const idStr of exposedIds) {
|
|
8261
8500
|
const numericId = Number(idStr);
|
|
@@ -8276,7 +8515,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
8276
8515
|
const allCaps = /* @__PURE__ */ new Set();
|
|
8277
8516
|
const exportedDeviceLog = [];
|
|
8278
8517
|
let entityCount = 0;
|
|
8279
|
-
for (const deviceIdStr of exposedDeviceIds(this.
|
|
8518
|
+
for (const deviceIdStr of exposedDeviceIds(this.membership(), enabled)) {
|
|
8280
8519
|
const numericId = Number(deviceIdStr);
|
|
8281
8520
|
if (!Number.isFinite(numericId)) {
|
|
8282
8521
|
this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
|
|
@@ -8321,7 +8560,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
8321
8560
|
const catalogDevice = built.device;
|
|
8322
8561
|
if (catalogDevice === null) continue;
|
|
8323
8562
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
8324
|
-
const brokerIds = brokersExposing(this.
|
|
8563
|
+
const brokerIds = brokersExposing(this.membership(), deviceIdStr).filter((id) => enabled.includes(id));
|
|
8325
8564
|
/**
|
|
8326
8565
|
* The plan is built ONCE and announced to every broker that
|
|
8327
8566
|
* receives the device, so it can only use platforms ALL of them
|
|
@@ -9212,27 +9451,19 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9212
9451
|
* is pruned on the next pass that returns a NON-empty list without it,
|
|
9213
9452
|
* which costs one interval and cannot cost the operator their config.
|
|
9214
9453
|
*/
|
|
9215
|
-
|
|
9216
|
-
|
|
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
|
+
}
|
|
9217
9460
|
/**
|
|
9218
|
-
*
|
|
9219
|
-
*
|
|
9220
|
-
*
|
|
9221
|
-
* EITHER differed. A boot-window settings read that answers empty leaves
|
|
9222
|
-
* `this.config` on its constructor defaults, and that joint write then
|
|
9223
|
-
* persisted `membership: {}` over the operator's real export list on a
|
|
9224
|
-
* pass whose only news was the broker roster. It cost this hub three days
|
|
9225
|
-
* 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.
|
|
9226
9464
|
*/
|
|
9227
|
-
|
|
9228
|
-
|
|
9229
|
-
membership: this.config.membership
|
|
9230
|
-
}, {
|
|
9231
|
-
knownBrokers: known,
|
|
9232
|
-
membership: pruned
|
|
9233
|
-
});
|
|
9234
|
-
if (patch !== null) await this.updateGlobalSettings(patch);
|
|
9235
|
-
await this.refreshEnabledBrokers(known);
|
|
9465
|
+
if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
|
|
9466
|
+
this.refreshEnabledBrokers(known);
|
|
9236
9467
|
const wanted = new Set(this.enabledBrokerIds());
|
|
9237
9468
|
for (const [brokerId, link] of this.links) {
|
|
9238
9469
|
if (wanted.has(brokerId)) continue;
|
|
@@ -9378,30 +9609,27 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9378
9609
|
/**
|
|
9379
9610
|
* The brokers switched on, as of the last {@link refreshEnabledBrokers}.
|
|
9380
9611
|
*
|
|
9381
|
-
* NOT read from `this.config`:
|
|
9382
|
-
*
|
|
9383
|
-
*
|
|
9384
|
-
* `membership.ts` for what that cost. The store is the authority; this is a
|
|
9385
|
-
* read-through cache of it, refreshed at the top of every reconcile pass and
|
|
9386
|
-
* 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.
|
|
9387
9615
|
*/
|
|
9388
9616
|
enabled = [];
|
|
9389
|
-
|
|
9390
|
-
|
|
9391
|
-
|
|
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;
|
|
9392
9622
|
}
|
|
9393
|
-
/**
|
|
9394
|
-
|
|
9395
|
-
return
|
|
9623
|
+
/** brokerId → exported device ids, from the records. */
|
|
9624
|
+
membership() {
|
|
9625
|
+
return this.book().membership();
|
|
9396
9626
|
}
|
|
9397
|
-
/**
|
|
9398
|
-
|
|
9399
|
-
|
|
9400
|
-
|
|
9401
|
-
|
|
9402
|
-
|
|
9403
|
-
const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read — the legacy per-broker keys answer", { meta: { error } }));
|
|
9404
|
-
return rows.length === 0 ? NO_INSTANCE_SETTINGS : require_ha_instance_settings.settingsByBroker(rows);
|
|
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));
|
|
9405
9633
|
}
|
|
9406
9634
|
enabledBrokerIds() {
|
|
9407
9635
|
return this.enabled;
|
|
@@ -9795,14 +10023,14 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9795
10023
|
}
|
|
9796
10024
|
/**
|
|
9797
10025
|
* Each broker's media base, read per use: the palette's role the broker chose
|
|
9798
|
-
* (`mediaRole
|
|
10026
|
+
* (the `ha-export-links` record's `mediaRole`, `internal` by default). A role that resolves to nothing
|
|
9799
10027
|
* is `null` and says so — the image entities then carry no value, which reads as
|
|
9800
10028
|
* empty rather than as a broken link.
|
|
9801
10029
|
*/
|
|
9802
10030
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
9803
10031
|
let bases;
|
|
9804
10032
|
try {
|
|
9805
|
-
bases = mediaBasesByBroker(
|
|
10033
|
+
bases = mediaBasesByBroker(brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), (brokerId) => this.book().mediaRole(brokerId));
|
|
9806
10034
|
} catch (err) {
|
|
9807
10035
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
9808
10036
|
tags: { deviceId },
|
|
@@ -9820,29 +10048,53 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9820
10048
|
return bases;
|
|
9821
10049
|
}
|
|
9822
10050
|
/**
|
|
9823
|
-
*
|
|
9824
|
-
*
|
|
9825
|
-
*
|
|
9826
|
-
|
|
9827
|
-
|
|
9828
|
-
|
|
10051
|
+
* Every broker this addon knows, enabled or not: a membership write may
|
|
10052
|
+
* target a broker that is switched off (its devices resume when it is turned
|
|
10053
|
+
* back on). A cold `knownBrokers` still counts the enabled ids.
|
|
10054
|
+
*/
|
|
10055
|
+
knownBrokerIds() {
|
|
10056
|
+
return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
|
|
10057
|
+
}
|
|
10058
|
+
/**
|
|
10059
|
+
* The brokers a `device-export` read answers over: the named instance, or
|
|
10060
|
+
* (absent) every enabled broker. A broker this addon does not know is
|
|
10061
|
+
* refused, never answered empty.
|
|
10062
|
+
*/
|
|
10063
|
+
brokerScope(instanceId) {
|
|
10064
|
+
if (instanceId === void 0) return this.enabledBrokerIds();
|
|
10065
|
+
if (!this.knownBrokerIds().has(instanceId)) throw new require_ha_instance_settings.ExportInstanceUnknownError("provider-homeassistant", instanceId);
|
|
10066
|
+
return [instanceId];
|
|
10067
|
+
}
|
|
10068
|
+
exposedDeviceRows(scope) {
|
|
10069
|
+
return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
|
|
10070
|
+
deviceId,
|
|
10071
|
+
exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
|
|
10072
|
+
}));
|
|
10073
|
+
}
|
|
10074
|
+
/**
|
|
10075
|
+
* A cap-level expose/unexpose changes ONE broker's membership — the named
|
|
10076
|
+
* instance. There is no write without a target and no "everywhere": the
|
|
10077
|
+
* other brokers' membership is not touched.
|
|
9829
10078
|
*/
|
|
9830
|
-
async
|
|
9831
|
-
|
|
9832
|
-
|
|
9833
|
-
if (next === this.
|
|
10079
|
+
async setBrokerMembership(instanceId, deviceId, exposed) {
|
|
10080
|
+
this.brokerScope(instanceId);
|
|
10081
|
+
const next = setExposed(this.membership(), instanceId, deviceId, exposed);
|
|
10082
|
+
if (next === this.membership()) {
|
|
9834
10083
|
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
9835
10084
|
tags: { deviceId: Number(deviceId) },
|
|
9836
|
-
meta: {
|
|
10085
|
+
meta: {
|
|
10086
|
+
exposed,
|
|
10087
|
+
brokerId: instanceId
|
|
10088
|
+
}
|
|
9837
10089
|
});
|
|
9838
10090
|
return;
|
|
9839
10091
|
}
|
|
9840
|
-
await this.
|
|
10092
|
+
await this.persistMembership(next);
|
|
9841
10093
|
this.ctx.logger.info("ha-export: export membership changed", {
|
|
9842
10094
|
tags: { deviceId: Number(deviceId) },
|
|
9843
10095
|
meta: {
|
|
9844
10096
|
exposed,
|
|
9845
|
-
|
|
10097
|
+
brokerId: instanceId
|
|
9846
10098
|
}
|
|
9847
10099
|
});
|
|
9848
10100
|
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
@@ -9850,7 +10102,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9850
10102
|
globalSettingsSchema() {
|
|
9851
10103
|
const brokerFields = this.config.knownBrokers.map((broker) => ({
|
|
9852
10104
|
type: "boolean",
|
|
9853
|
-
key:
|
|
10105
|
+
key: exportToFormKey(broker.id),
|
|
9854
10106
|
label: broker.name,
|
|
9855
10107
|
style: "switch",
|
|
9856
10108
|
default: false
|
|
@@ -9879,7 +10131,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9879
10131
|
columns: 1,
|
|
9880
10132
|
fields: this.config.knownBrokers.map((broker) => ({
|
|
9881
10133
|
type: "select",
|
|
9882
|
-
key:
|
|
10134
|
+
key: mediaRoleFormKey(broker.id),
|
|
9883
10135
|
label: `Image links for ${broker.name}`,
|
|
9884
10136
|
options: [{
|
|
9885
10137
|
value: "internal",
|
|
@@ -9920,7 +10172,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9920
10172
|
* exporter's value in this one's toggle.
|
|
9921
10173
|
*/
|
|
9922
10174
|
async buildContribution(deviceId) {
|
|
9923
|
-
|
|
10175
|
+
this.refreshEnabledBrokers(this.config.knownBrokers);
|
|
9924
10176
|
const idStr = String(deviceId);
|
|
9925
10177
|
const enabled = this.enabledBrokerIds();
|
|
9926
10178
|
const fields = enabled.map((brokerId) => ({
|
|
@@ -9928,7 +10180,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9928
10180
|
key: `haExport:${deviceId}:${brokerId}`,
|
|
9929
10181
|
label: `Export to ${this.brokerName(brokerId)}`,
|
|
9930
10182
|
style: "switch",
|
|
9931
|
-
value: isExposed(this.
|
|
10183
|
+
value: isExposed(this.membership(), brokerId, idStr),
|
|
9932
10184
|
immediate: true
|
|
9933
10185
|
}));
|
|
9934
10186
|
return {
|
|
@@ -9958,7 +10210,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9958
10210
|
}
|
|
9959
10211
|
async applyDeviceSettingsPatch(deviceId, patch) {
|
|
9960
10212
|
const idStr = String(deviceId);
|
|
9961
|
-
let next = this.
|
|
10213
|
+
let next = this.membership();
|
|
9962
10214
|
let touched = false;
|
|
9963
10215
|
/**
|
|
9964
10216
|
* Driven by the PATCH KEYS, not by the enabled-broker list.
|
|
@@ -9988,7 +10240,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9988
10240
|
});
|
|
9989
10241
|
return { success: true };
|
|
9990
10242
|
}
|
|
9991
|
-
await this.
|
|
10243
|
+
await this.persistMembership(next);
|
|
9992
10244
|
this.ctx.logger.info("ha-export: per-broker export membership changed", {
|
|
9993
10245
|
tags: { deviceId },
|
|
9994
10246
|
meta: { brokers: brokersExposing(next, idStr) }
|
|
@@ -9996,19 +10248,20 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9996
10248
|
await this.reconcile("device-panel");
|
|
9997
10249
|
return { success: true };
|
|
9998
10250
|
}
|
|
9999
|
-
buildStatus() {
|
|
10000
|
-
const enabled =
|
|
10001
|
-
const
|
|
10002
|
-
const
|
|
10251
|
+
buildStatus(scope) {
|
|
10252
|
+
const enabled = scope;
|
|
10253
|
+
const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
|
|
10254
|
+
const healthy = links.filter((link) => link.client.healthy).length;
|
|
10255
|
+
const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
|
|
10003
10256
|
return {
|
|
10004
|
-
linkState: enabled.length === 0 ? "unlinked" : healthy ===
|
|
10005
|
-
exposedDeviceCount: exposedDeviceIds(this.
|
|
10257
|
+
linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
|
|
10258
|
+
exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
|
|
10006
10259
|
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
10007
10260
|
setup: {
|
|
10008
10261
|
fields: [
|
|
10009
10262
|
{
|
|
10010
10263
|
label: "Home Assistant instances",
|
|
10011
|
-
value: `${healthy} / ${
|
|
10264
|
+
value: `${healthy} / ${links.length} reachable`
|
|
10012
10265
|
},
|
|
10013
10266
|
{
|
|
10014
10267
|
label: "Last push failure",
|