@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { F as buildAddonRouteProvider, It as statusInstanceOf, Jt as BaseAddon, Ot as runExportedAction, Pt as sendToExportedTarget, Qt as baseUnitOf, Sn as EventCategory, U as consumableFormOf, W as consumableKindOf, X as deviceExportCapability, Zt as RevConflictError, _ as HvacModeSchema, _t as oauthIntegrationCapability, a as AlarmArmModeSchema, an as nodePin, bt as preconditionOf, c as CAMERA_SWITCH_ORDER, cn as boolean, d as ConsumableFormSchema, en as defineAddonMigrations, f as ConsumableItemSchema, fn as object, g as FanDirectionSchema, gn as unknown, h as ExportInstanceUnknownError, i as listHomeAssistantBrokers, in as isReadingQuantity, l as COCO_TO_MACRO, mn as string, on as _enum, p as ConsumableStatusSchema, sn as array, t as haInstanceSettingsPortsOver, tn as defineEntryKind, u as CameraSwitchIdSchema, v as MediaPlayerRepeatSchema, w as addonRoutesCapability, wt as pruneDeletedEntities, y as PLUG_APPLIANCE_TYPES } from "../ha-instance-settings-EQ5Ey3Q-.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import "node:zlib";
|
|
4
4
|
import "node:fs";
|
|
@@ -5028,133 +5028,6 @@ function verifyExpiringUrl(input) {
|
|
|
5028
5028
|
return timingSafeEqual(a, b);
|
|
5029
5029
|
}
|
|
5030
5030
|
//#endregion
|
|
5031
|
-
//#region src/ha-export/membership.ts
|
|
5032
|
-
function devicesForBroker(membership, brokerId) {
|
|
5033
|
-
return membership[brokerId] ?? [];
|
|
5034
|
-
}
|
|
5035
|
-
function isExposed(membership, brokerId, deviceId) {
|
|
5036
|
-
return devicesForBroker(membership, brokerId).includes(deviceId);
|
|
5037
|
-
}
|
|
5038
|
-
/** Every broker a device reaches. Drives the N switches in the panel. */
|
|
5039
|
-
function brokersExposing(membership, deviceId) {
|
|
5040
|
-
return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
|
|
5041
|
-
}
|
|
5042
|
-
/** Every device exported to at least one of the given brokers. */
|
|
5043
|
-
function exposedDeviceIds(membership, brokerIds) {
|
|
5044
|
-
const ids = /* @__PURE__ */ new Set();
|
|
5045
|
-
for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
|
|
5046
|
-
return [...ids].sort();
|
|
5047
|
-
}
|
|
5048
|
-
function setExposed(membership, brokerId, deviceId, exposed) {
|
|
5049
|
-
const current = devicesForBroker(membership, brokerId);
|
|
5050
|
-
if (exposed === current.includes(deviceId)) return membership;
|
|
5051
|
-
const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
|
|
5052
|
-
return {
|
|
5053
|
-
...membership,
|
|
5054
|
-
[brokerId]: next
|
|
5055
|
-
};
|
|
5056
|
-
}
|
|
5057
|
-
/**
|
|
5058
|
-
* Forget brokers that no longer exist.
|
|
5059
|
-
*
|
|
5060
|
-
* A removed HA instance must not keep a membership list that reappears if
|
|
5061
|
-
* a broker id is ever reused — and a stale key would also inflate
|
|
5062
|
-
* `exposedDeviceCount` with devices going nowhere.
|
|
5063
|
-
*/
|
|
5064
|
-
function pruneBrokers(membership, knownBrokerIds) {
|
|
5065
|
-
const known = new Set(knownBrokerIds);
|
|
5066
|
-
const kept = Object.keys(membership).filter((brokerId) => known.has(brokerId));
|
|
5067
|
-
if (kept.length === Object.keys(membership).length) return membership;
|
|
5068
|
-
const next = {};
|
|
5069
|
-
for (const brokerId of kept) next[brokerId] = membership[brokerId] ?? [];
|
|
5070
|
-
return next;
|
|
5071
|
-
}
|
|
5072
|
-
/**
|
|
5073
|
-
* The brokers the operator has switched ON, read from the RAW addon store.
|
|
5074
|
-
*
|
|
5075
|
-
* `exportTo:<brokerId>` is a **dynamic** settings key — one per broker the
|
|
5076
|
-
* hub happens to know about — so it can never appear in the addon's
|
|
5077
|
-
* `DEFAULT_CONFIG`. `BaseAddon.resolveConfig` copies only keys that are
|
|
5078
|
-
* present in the defaults ("the store can contain extra keys … without
|
|
5079
|
-
* polluting the typed config"), so every `exportTo:*` key is dropped on the
|
|
5080
|
-
* way into `this.config` and reading one there yields `undefined` for ever.
|
|
5081
|
-
*
|
|
5082
|
-
* That is not theoretical. On 2026-08-09 the switch was on
|
|
5083
|
-
* (`getGlobalSettings` returned `exportTo:ha_001 → value: true`, because the
|
|
5084
|
-
* settings FORM reads the store) and every reconcile still logged
|
|
5085
|
-
* `brokers=0`: nothing was ever exported, the per-device Export tab rendered
|
|
5086
|
-
* its "Not configured" card, and no operator action could have changed
|
|
5087
|
-
* either. The store is the single authority — read it, do not mirror it into
|
|
5088
|
-
* a typed field that would then be a second one (D62).
|
|
5089
|
-
*
|
|
5090
|
-
* A broker that is no longer known is not enabled, whatever the store says:
|
|
5091
|
-
* a stale `exportTo:` key for a deleted instance must not resurrect it.
|
|
5092
|
-
*
|
|
5093
|
-
* The switch is a setting of the INSTANCE (D985): `perBroker` carries each broker's integration
|
|
5094
|
-
* settings, and a broker whose integration has an `exportTo` answers from it. A broker whose
|
|
5095
|
-
* integration has none yet — the migration has not copied it — falls back to the legacy
|
|
5096
|
-
* `exportTo:<brokerId>` key, so the move never flips a switch mid-way.
|
|
5097
|
-
*/
|
|
5098
|
-
function enabledBrokerIds(store, knownBrokerIds, perBroker = NO_INSTANCE_SETTINGS) {
|
|
5099
|
-
return knownBrokerIds.filter((brokerId) => {
|
|
5100
|
-
const own = perBroker.get(brokerId)?.["exportTo"];
|
|
5101
|
-
return typeof own === "boolean" ? own : store[`exportTo:${brokerId}`] === true;
|
|
5102
|
-
});
|
|
5103
|
-
}
|
|
5104
|
-
var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
|
|
5105
|
-
/** Same brokers, same order, same names — nothing for the form to re-render. */
|
|
5106
|
-
function sameBrokers(a, b) {
|
|
5107
|
-
if (a.length !== b.length) return false;
|
|
5108
|
-
return a.every((broker, index) => {
|
|
5109
|
-
const other = b[index];
|
|
5110
|
-
return other !== void 0 && other.id === broker.id && other.name === broker.name;
|
|
5111
|
-
});
|
|
5112
|
-
}
|
|
5113
|
-
/**
|
|
5114
|
-
* What a broker refresh persists — and, above all, what it must NOT.
|
|
5115
|
-
*
|
|
5116
|
-
* **A patch never names a key it did not change.** That reads like tidiness
|
|
5117
|
-
* and is not: `updateGlobalSettings` is a read-modify-write over the addon
|
|
5118
|
-
* store, so naming `membership` writes whatever `this.config.membership`
|
|
5119
|
-
* currently holds — and `this.config` is not always the store.
|
|
5120
|
-
* `BaseAddon.resolveConfig` falls back to the CONSTRUCTOR DEFAULTS whenever
|
|
5121
|
-
* the settings read answers empty, and the kernel's `readAddonStore` answers
|
|
5122
|
-
* empty for a FAILED read exactly as it does for a missing one, silently. A
|
|
5123
|
-
* boot-window read that misses therefore gives the addon `membership: {}`,
|
|
5124
|
-
* and the first refresh that merely re-records `knownBrokers` hands that
|
|
5125
|
-
* empty object to the store as if the operator had unexported everything.
|
|
5126
|
-
*
|
|
5127
|
-
* That is not a hypothetical. On 2026-08-24 the hub restarted at 21:49; the
|
|
5128
|
-
* export runner came up while the cap resolver was still answering
|
|
5129
|
-
* "no provider registered" for hub-resident caps; at 21:55:13 a refresh
|
|
5130
|
-
* found `knownBrokers` stale (`[]` vs `[ha_001]`) and wrote BOTH keys. The
|
|
5131
|
-
* two exported cameras (104 hall, 101 front door) were erased from the
|
|
5132
|
-
* store, `devices` fell from 4 to 2 (the two synthetic devices), and Home
|
|
5133
|
-
* Assistant kept every camera entity as a restored/unavailable orphan for
|
|
5134
|
-
* three days: no stream ("the hub does not export this camera"), no
|
|
5135
|
-
* doorbell, no camera state. Nothing logged it, because from the store's
|
|
5136
|
-
* point of view the addon simply asked for `{}`.
|
|
5137
|
-
*
|
|
5138
|
-
* With the keys split, that pass writes `knownBrokers` alone, the
|
|
5139
|
-
* `resolveConfig` that follows the write re-reads a store that still holds
|
|
5140
|
-
* the membership, and the next reconcile exports both cameras again.
|
|
5141
|
-
*
|
|
5142
|
-
* Identity, not deep equality, decides `membership`: `pruneBrokers` returns
|
|
5143
|
-
* the SAME reference when it removed nothing, which is precisely the
|
|
5144
|
-
* "nothing changed" signal this needs.
|
|
5145
|
-
*
|
|
5146
|
-
* @returns the patch to persist, or `null` when there is nothing to write.
|
|
5147
|
-
*/
|
|
5148
|
-
function brokerRefreshPatch(current, next) {
|
|
5149
|
-
const brokersChanged = !sameBrokers(next.knownBrokers, current.knownBrokers);
|
|
5150
|
-
const membershipChanged = next.membership !== current.membership;
|
|
5151
|
-
if (!brokersChanged && !membershipChanged) return null;
|
|
5152
|
-
return {
|
|
5153
|
-
...brokersChanged ? { knownBrokers: next.knownBrokers } : {},
|
|
5154
|
-
...membershipChanged ? { membership: next.membership } : {}
|
|
5155
|
-
};
|
|
5156
|
-
}
|
|
5157
|
-
//#endregion
|
|
5158
5031
|
//#region src/ha-export/media-url.ts
|
|
5159
5032
|
/**
|
|
5160
5033
|
* Signed, expiring media links.
|
|
@@ -5181,19 +5054,6 @@ function brokerRefreshPatch(current, next) {
|
|
|
5181
5054
|
*/
|
|
5182
5055
|
/** The hub address roles a Home Assistant media link may be minted on. */
|
|
5183
5056
|
var HA_MEDIA_ROLES = ["internal", "publicEndpoint"];
|
|
5184
|
-
/** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
|
|
5185
|
-
function mediaRoleKey(brokerId) {
|
|
5186
|
-
return `mediaRole:${brokerId}`;
|
|
5187
|
-
}
|
|
5188
|
-
/**
|
|
5189
|
-
* A broker's chosen role; anything stored that is not a known role is `internal`. The role is a
|
|
5190
|
-
* setting of the INSTANCE (D985): the integration's `mediaRole` answers when it has one, and a
|
|
5191
|
-
* broker the migration has not reached falls back to the legacy `mediaRole:<brokerId>` key.
|
|
5192
|
-
*/
|
|
5193
|
-
function haMediaRoleOf(store, brokerId, perBroker = NO_INSTANCE_SETTINGS) {
|
|
5194
|
-
const own = perBroker.get(brokerId)?.["mediaRole"];
|
|
5195
|
-
return (own !== void 0 ? own : store[mediaRoleKey(brokerId)]) === "publicEndpoint" ? "publicEndpoint" : "internal";
|
|
5196
|
-
}
|
|
5197
5057
|
/**
|
|
5198
5058
|
* The base a media link is minted on: the hub's role (D734) — `internal` unless the
|
|
5199
5059
|
* broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
|
|
@@ -5204,8 +5064,8 @@ function haMediaBaseUrl(book, role = "internal") {
|
|
|
5204
5064
|
return book.roles[role].resolved;
|
|
5205
5065
|
}
|
|
5206
5066
|
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5207
|
-
function mediaBasesByBroker(
|
|
5208
|
-
return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book,
|
|
5067
|
+
function mediaBasesByBroker(brokerIds, book, roleOf) {
|
|
5068
|
+
return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, roleOf(brokerId))]));
|
|
5209
5069
|
}
|
|
5210
5070
|
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
5211
5071
|
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
@@ -5336,7 +5196,139 @@ async function planDropPerBrokerFlags(ports) {
|
|
|
5336
5196
|
}] };
|
|
5337
5197
|
}
|
|
5338
5198
|
//#endregion
|
|
5199
|
+
//#region src/ha-export/migrations/links-to-record.ts
|
|
5200
|
+
/**
|
|
5201
|
+
* What the export carries to a Home Assistant instance moves into the instance's
|
|
5202
|
+
* `ha-export-links` record (planning#408 P4). Two migrations, both `block-owner`: the addon must
|
|
5203
|
+
* not serve a membership it cannot trust (an empty list unexposes every device on the next
|
|
5204
|
+
* reconcile), and after them nothing reads the old shape.
|
|
5205
|
+
*
|
|
5206
|
+
* The old shape, per broker: the `membership` setting (device ids), and the two flags, each kept
|
|
5207
|
+
* on the instance's integration (`exportTo`, `mediaRole`) or, for a broker the 2026-11-03
|
|
5208
|
+
* migration had not reached, in the dynamic addon keys `exportTo:<brokerId>` /
|
|
5209
|
+
* `mediaRole:<brokerId>`. The integration's value won over the key; so it does here.
|
|
5210
|
+
*
|
|
5211
|
+
* 1. `moveLinksToRecords` (procedure): one record per broker, id = broker id, written with
|
|
5212
|
+
* `ifRev: 0` (an existing record is never overwritten), then read BACK and compared; a
|
|
5213
|
+
* mismatch throws and nothing is deleted.
|
|
5214
|
+
* 2. `planDropMovedLinks` (plan): the addon keys are deleted only while every record holds what
|
|
5215
|
+
* they carry; a missing or short record refuses, a read that fails leaves them in place. The
|
|
5216
|
+
* `exportTo` / `mediaRole` values on the integrations are the provider's instance data and
|
|
5217
|
+
* are not touched here; nothing in this addon reads them any more.
|
|
5218
|
+
*/
|
|
5219
|
+
var MEMBERSHIP_KEY = "membership";
|
|
5220
|
+
var EXPORT_PREFIX = "exportTo:";
|
|
5221
|
+
var ROLE_PREFIX = "mediaRole:";
|
|
5222
|
+
var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
|
|
5223
|
+
function membershipOf$1(value) {
|
|
5224
|
+
const out = /* @__PURE__ */ new Map();
|
|
5225
|
+
if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
|
|
5226
|
+
for (const [brokerId, devices] of Object.entries(value)) {
|
|
5227
|
+
if (!Array.isArray(devices)) continue;
|
|
5228
|
+
out.set(brokerId, devices.filter((device) => typeof device === "string"));
|
|
5229
|
+
}
|
|
5230
|
+
return out;
|
|
5231
|
+
}
|
|
5232
|
+
function integrationByBroker(rows, warn) {
|
|
5233
|
+
const out = /* @__PURE__ */ new Map();
|
|
5234
|
+
for (const row of [...rows].sort((a, b) => a.integrationId.localeCompare(b.integrationId))) {
|
|
5235
|
+
if (row.brokerId === null) continue;
|
|
5236
|
+
if (out.has(row.brokerId)) {
|
|
5237
|
+
warn(`homeassistant-export: integrations ${out.get(row.brokerId)?.integrationId} and ${row.integrationId} both link broker ${row.brokerId}; the first one answers`);
|
|
5238
|
+
continue;
|
|
5239
|
+
}
|
|
5240
|
+
out.set(row.brokerId, row);
|
|
5241
|
+
}
|
|
5242
|
+
return out;
|
|
5243
|
+
}
|
|
5244
|
+
/** Every broker the old shape has something for, with the value the old reader would have used. */
|
|
5245
|
+
function legacyLinks(stored, rows, warn) {
|
|
5246
|
+
const membership = membershipOf$1(stored[MEMBERSHIP_KEY]);
|
|
5247
|
+
const integrations = integrationByBroker(rows, warn);
|
|
5248
|
+
const brokers = new Set(membership.keys());
|
|
5249
|
+
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));
|
|
5250
|
+
for (const [brokerId, row] of integrations) if ("exportTo" in row.settings || "mediaRole" in row.settings) brokers.add(brokerId);
|
|
5251
|
+
return [...brokers].sort().map((brokerId) => {
|
|
5252
|
+
const own = integrations.get(brokerId)?.settings;
|
|
5253
|
+
const ownExport = own?.["exportTo"];
|
|
5254
|
+
const ownRole = own?.["mediaRole"];
|
|
5255
|
+
return {
|
|
5256
|
+
brokerId,
|
|
5257
|
+
exportTo: typeof ownExport === "boolean" ? ownExport : stored[`${EXPORT_PREFIX}${brokerId}`] === true,
|
|
5258
|
+
mediaRole: roleOf(ownRole !== void 0 ? ownRole : stored[`${ROLE_PREFIX}${brokerId}`]) ?? "internal",
|
|
5259
|
+
devices: membership.get(brokerId) ?? []
|
|
5260
|
+
};
|
|
5261
|
+
});
|
|
5262
|
+
}
|
|
5263
|
+
/** The addon keys the old shape lives in. */
|
|
5264
|
+
function legacyKeys(stored) {
|
|
5265
|
+
return Object.keys(stored).filter((key) => key === "membership" || key.startsWith(EXPORT_PREFIX) && key.length > 9 || key.startsWith(ROLE_PREFIX) && key.length > 10);
|
|
5266
|
+
}
|
|
5267
|
+
var sameDevices = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
|
|
5268
|
+
async function moveLinksToRecords(ports) {
|
|
5269
|
+
const links = legacyLinks(await ports.readStored(), await ports.listInstances(), ports.warn);
|
|
5270
|
+
if (links.length === 0) return {
|
|
5271
|
+
outcome: "already-done",
|
|
5272
|
+
summary: { moved: 0 }
|
|
5273
|
+
};
|
|
5274
|
+
const created = [];
|
|
5275
|
+
for (const link of links) {
|
|
5276
|
+
const body = {
|
|
5277
|
+
name: link.brokerId,
|
|
5278
|
+
exportTo: link.exportTo,
|
|
5279
|
+
mediaRole: link.mediaRole,
|
|
5280
|
+
devices: link.devices
|
|
5281
|
+
};
|
|
5282
|
+
if (await ports.putLinks(link.brokerId, body) === "created") created.push(link);
|
|
5283
|
+
}
|
|
5284
|
+
if (created.length === 0) return {
|
|
5285
|
+
outcome: "already-done",
|
|
5286
|
+
summary: { moved: 0 }
|
|
5287
|
+
};
|
|
5288
|
+
const held = await ports.readLinks();
|
|
5289
|
+
for (const link of created) {
|
|
5290
|
+
const row = held.get(link.brokerId);
|
|
5291
|
+
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`);
|
|
5292
|
+
}
|
|
5293
|
+
return {
|
|
5294
|
+
outcome: "done",
|
|
5295
|
+
summary: {
|
|
5296
|
+
moved: created.length,
|
|
5297
|
+
devices: created.reduce((sum, link) => sum + link.devices.length, 0)
|
|
5298
|
+
}
|
|
5299
|
+
};
|
|
5300
|
+
}
|
|
5301
|
+
var rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
|
|
5302
|
+
async function planDropMovedLinks(ports) {
|
|
5303
|
+
const stored = await ports.readStored();
|
|
5304
|
+
const present = legacyKeys(stored);
|
|
5305
|
+
if (present.length === 0) return { edits: [] };
|
|
5306
|
+
const refuse = (reason) => {
|
|
5307
|
+
ports.warn(`homeassistant-export: the legacy export keys are kept: ${reason}`);
|
|
5308
|
+
throw new Error(`the legacy export keys are not deleted: ${reason}`);
|
|
5309
|
+
};
|
|
5310
|
+
const links = legacyLinks(stored, await ports.listInstances(), ports.warn);
|
|
5311
|
+
const held = await ports.readLinks();
|
|
5312
|
+
for (const link of links) {
|
|
5313
|
+
const row = held.get(link.brokerId);
|
|
5314
|
+
if (row === void 0) return refuse(`no export-links record exists for ${link.brokerId}`);
|
|
5315
|
+
for (const device of link.devices) if (!row.devices.includes(device)) return refuse(`the record of ${link.brokerId} lacks the device ${device}`);
|
|
5316
|
+
if (row.exportTo !== link.exportTo || row.mediaRole !== link.mediaRole) return refuse(`the record of ${link.brokerId} disagrees on the switch or the address role`);
|
|
5317
|
+
}
|
|
5318
|
+
return { edits: [{
|
|
5319
|
+
op: "deleteKeys",
|
|
5320
|
+
scope: ADDON_SETTINGS_TABLE,
|
|
5321
|
+
keys: present.map(rowId),
|
|
5322
|
+
precondition: preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
|
|
5323
|
+
addonId: HA_EXPORT_ADDON_ID,
|
|
5324
|
+
key,
|
|
5325
|
+
value: stored[key]
|
|
5326
|
+
}])))
|
|
5327
|
+
}] };
|
|
5328
|
+
}
|
|
5329
|
+
//#endregion
|
|
5339
5330
|
//#region src/ha-export/migrations/index.ts
|
|
5331
|
+
var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
|
|
5340
5332
|
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
5341
5333
|
var ADDON_MIGRATIONS = [
|
|
5342
5334
|
{
|
|
@@ -5386,51 +5378,249 @@ var ADDON_MIGRATIONS = [
|
|
|
5386
5378
|
fixtureMark: "a legacy flag parked when the integration holds a different one"
|
|
5387
5379
|
},
|
|
5388
5380
|
plan: planDropPerBrokerFlags
|
|
5381
|
+
},
|
|
5382
|
+
{
|
|
5383
|
+
meta: {
|
|
5384
|
+
id: LINKS_TO_RECORD_ID,
|
|
5385
|
+
owner: "homeassistant-export",
|
|
5386
|
+
level: "addon-state",
|
|
5387
|
+
seq: 4,
|
|
5388
|
+
dependsOn: [],
|
|
5389
|
+
store: "settings",
|
|
5390
|
+
scope: "cluster",
|
|
5391
|
+
onFailure: "block-owner",
|
|
5392
|
+
bodyKind: "procedure",
|
|
5393
|
+
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",
|
|
5394
|
+
fixtureMark: "the reconcile interval the migration does not know"
|
|
5395
|
+
},
|
|
5396
|
+
procedure: moveLinksToRecords
|
|
5397
|
+
},
|
|
5398
|
+
{
|
|
5399
|
+
meta: {
|
|
5400
|
+
id: "homeassistant-export/2026-11-04-drop-moved-links",
|
|
5401
|
+
owner: "homeassistant-export",
|
|
5402
|
+
level: "addon-state",
|
|
5403
|
+
seq: 5,
|
|
5404
|
+
dependsOn: [LINKS_TO_RECORD_ID],
|
|
5405
|
+
store: "settings",
|
|
5406
|
+
scope: "cluster",
|
|
5407
|
+
onFailure: "block-owner",
|
|
5408
|
+
bodyKind: "plan",
|
|
5409
|
+
description: "Delete the membership, exportTo and mediaRole keys once the records hold what they carried",
|
|
5410
|
+
fixtureMark: "the reconcile interval the migration does not know"
|
|
5411
|
+
},
|
|
5412
|
+
plan: planDropMovedLinks
|
|
5389
5413
|
}
|
|
5390
5414
|
];
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
|
|
5394
|
-
|
|
5395
|
-
|
|
5396
|
-
|
|
5397
|
-
|
|
5398
|
-
|
|
5399
|
-
|
|
5400
|
-
|
|
5401
|
-
|
|
5402
|
-
|
|
5403
|
-
|
|
5404
|
-
|
|
5405
|
-
|
|
5406
|
-
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
5410
|
-
|
|
5411
|
-
|
|
5412
|
-
|
|
5415
|
+
var haExportLinksKind = defineEntryKind("ha-export-links", {
|
|
5416
|
+
title: "Home Assistant export links",
|
|
5417
|
+
nameField: "name",
|
|
5418
|
+
schema: object({
|
|
5419
|
+
name: string().min(1),
|
|
5420
|
+
exportTo: boolean(),
|
|
5421
|
+
mediaRole: _enum(HA_MEDIA_ROLES),
|
|
5422
|
+
devices: array(string())
|
|
5423
|
+
}),
|
|
5424
|
+
writeTrigger: "an operator switches the export of a Home Assistant instance, picks its image-link address, or exports a device to it"
|
|
5425
|
+
});
|
|
5426
|
+
/** A link the operator has not touched: off, on the internal address, no device. */
|
|
5427
|
+
function defaultLinks(name) {
|
|
5428
|
+
return {
|
|
5429
|
+
name,
|
|
5430
|
+
exportTo: false,
|
|
5431
|
+
mediaRole: "internal",
|
|
5432
|
+
devices: []
|
|
5433
|
+
};
|
|
5434
|
+
}
|
|
5435
|
+
/** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
|
|
5436
|
+
async function readAllLinks(handle) {
|
|
5437
|
+
const rows = /* @__PURE__ */ new Map();
|
|
5438
|
+
let cursor;
|
|
5439
|
+
do {
|
|
5440
|
+
const page = await handle.query({
|
|
5441
|
+
limit: 1e3,
|
|
5442
|
+
...cursor === void 0 ? {} : { cursor }
|
|
5443
|
+
});
|
|
5444
|
+
for (const row of page.records) rows.set(row.id, row.data);
|
|
5445
|
+
cursor = page.nextCursor ?? void 0;
|
|
5446
|
+
} while (cursor !== void 0);
|
|
5447
|
+
return rows;
|
|
5448
|
+
}
|
|
5449
|
+
function membershipOf(rows) {
|
|
5450
|
+
const out = {};
|
|
5451
|
+
for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
|
|
5452
|
+
return out;
|
|
5453
|
+
}
|
|
5454
|
+
/**
|
|
5455
|
+
* The in-memory copy of the records. This addon is their only writer, so the copy is the
|
|
5456
|
+
* synchronous read the event path needs; a write lands in the record first and in the copy
|
|
5457
|
+
* after it. `membership()` keeps its identity until a device list changes.
|
|
5458
|
+
*/
|
|
5459
|
+
var ExportLinks = class ExportLinks {
|
|
5460
|
+
handle;
|
|
5461
|
+
rows;
|
|
5462
|
+
memberships;
|
|
5463
|
+
constructor(handle, rows) {
|
|
5464
|
+
this.handle = handle;
|
|
5465
|
+
this.rows = rows;
|
|
5466
|
+
this.memberships = membershipOf(rows);
|
|
5467
|
+
}
|
|
5468
|
+
static async load(handle) {
|
|
5469
|
+
return new ExportLinks(handle, await readAllLinks(handle));
|
|
5470
|
+
}
|
|
5471
|
+
isEnabled(brokerId) {
|
|
5472
|
+
return this.rows.get(brokerId)?.exportTo === true;
|
|
5473
|
+
}
|
|
5474
|
+
mediaRole(brokerId) {
|
|
5475
|
+
return this.rows.get(brokerId)?.mediaRole ?? "internal";
|
|
5476
|
+
}
|
|
5477
|
+
/** brokerId → exported device ids, for the brokers that export at least one. */
|
|
5478
|
+
membership() {
|
|
5479
|
+
return this.memberships;
|
|
5480
|
+
}
|
|
5481
|
+
brokerIds() {
|
|
5482
|
+
return [...this.rows.keys()];
|
|
5483
|
+
}
|
|
5484
|
+
/** Change the switch and / or the role of one instance; creates the record on first use. */
|
|
5485
|
+
async setFlags(brokerId, name, patch) {
|
|
5486
|
+
const current = this.rows.get(brokerId) ?? defaultLinks(name);
|
|
5487
|
+
const next = {
|
|
5488
|
+
...current,
|
|
5489
|
+
exportTo: patch.exportTo ?? current.exportTo,
|
|
5490
|
+
mediaRole: patch.mediaRole ?? current.mediaRole
|
|
5491
|
+
};
|
|
5492
|
+
if (this.rows.has(brokerId) && sameLinks(current, next)) return;
|
|
5493
|
+
await this.write(brokerId, next);
|
|
5494
|
+
}
|
|
5495
|
+
/** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
|
|
5496
|
+
async setMembership(next, nameOf) {
|
|
5497
|
+
for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
|
|
5498
|
+
const devices = next[brokerId] ?? [];
|
|
5499
|
+
const current = this.rows.get(brokerId);
|
|
5500
|
+
if (sameList(current?.devices ?? [], devices)) continue;
|
|
5501
|
+
await this.write(brokerId, {
|
|
5502
|
+
...current ?? defaultLinks(nameOf(brokerId)),
|
|
5503
|
+
devices: [...devices]
|
|
5413
5504
|
});
|
|
5414
5505
|
}
|
|
5415
5506
|
}
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
|
|
5507
|
+
/** Forget the instances that no longer exist; returns the ids it removed. */
|
|
5508
|
+
async prune(knownBrokerIds) {
|
|
5509
|
+
const known = new Set(knownBrokerIds);
|
|
5510
|
+
const gone = [...this.rows.keys()].filter((id) => !known.has(id));
|
|
5511
|
+
for (const id of gone) await this.handle.delete(id);
|
|
5512
|
+
if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
|
|
5513
|
+
return gone;
|
|
5514
|
+
}
|
|
5515
|
+
async write(brokerId, body) {
|
|
5516
|
+
await this.handle.put({
|
|
5517
|
+
id: brokerId,
|
|
5518
|
+
data: body
|
|
5519
|
+
});
|
|
5520
|
+
this.adopt(new Map(this.rows).set(brokerId, body));
|
|
5521
|
+
}
|
|
5522
|
+
adopt(rows) {
|
|
5523
|
+
const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
|
|
5524
|
+
this.rows = rows;
|
|
5525
|
+
if (devicesChanged) this.memberships = membershipOf(rows);
|
|
5526
|
+
}
|
|
5527
|
+
};
|
|
5528
|
+
function sameList(a, b) {
|
|
5529
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
5530
|
+
}
|
|
5531
|
+
function sameLinks(a, b) {
|
|
5532
|
+
return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
|
|
5533
|
+
}
|
|
5534
|
+
/**
|
|
5535
|
+
* The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
|
|
5536
|
+
* `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
|
|
5537
|
+
* records and a write is routed to the record of the broker they name.
|
|
5538
|
+
*/
|
|
5539
|
+
var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
|
|
5540
|
+
var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
|
|
5541
|
+
function parseLinkFormKey(key) {
|
|
5542
|
+
for (const flag of ["exportTo", "mediaRole"]) {
|
|
5543
|
+
const prefix = `${flag}:`;
|
|
5544
|
+
if (key.startsWith(prefix) && key.length > prefix.length) return {
|
|
5545
|
+
flag,
|
|
5546
|
+
brokerId: key.slice(prefix.length)
|
|
5547
|
+
};
|
|
5548
|
+
}
|
|
5549
|
+
return null;
|
|
5420
5550
|
}
|
|
5421
5551
|
/** The form's dynamic keys carrying each instance's own value. */
|
|
5422
|
-
function
|
|
5552
|
+
function linkFormOverlay(links) {
|
|
5423
5553
|
const overlay = {};
|
|
5424
|
-
for (const
|
|
5425
|
-
|
|
5426
|
-
|
|
5427
|
-
const value = row.settings[flag];
|
|
5428
|
-
if (value !== void 0) overlay[keyOf(row.brokerId)] = value;
|
|
5429
|
-
}
|
|
5554
|
+
for (const brokerId of links.brokerIds()) {
|
|
5555
|
+
overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
|
|
5556
|
+
overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
|
|
5430
5557
|
}
|
|
5431
5558
|
return overlay;
|
|
5432
5559
|
}
|
|
5433
5560
|
//#endregion
|
|
5561
|
+
//#region src/ha-export/migrations/bind-ports.ts
|
|
5562
|
+
/**
|
|
5563
|
+
* Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
|
|
5564
|
+
* `integrations` capability (the old home of the two flags, read by the migrations only) and the
|
|
5565
|
+
* `ha-export-links` kind.
|
|
5566
|
+
*/
|
|
5567
|
+
function haExportMigrationPorts(deps) {
|
|
5568
|
+
return {
|
|
5569
|
+
readStored: deps.readStored,
|
|
5570
|
+
...haInstanceSettingsPortsOver(deps.api.integrations),
|
|
5571
|
+
warn: deps.warn,
|
|
5572
|
+
putLinks: async (brokerId, body) => {
|
|
5573
|
+
if (await deps.links.get(brokerId) !== null) return "exists";
|
|
5574
|
+
try {
|
|
5575
|
+
await deps.links.put({
|
|
5576
|
+
id: brokerId,
|
|
5577
|
+
data: body
|
|
5578
|
+
}, { ifRev: 0 });
|
|
5579
|
+
} catch (err) {
|
|
5580
|
+
if (err instanceof RevConflictError) return "exists";
|
|
5581
|
+
throw err;
|
|
5582
|
+
}
|
|
5583
|
+
return "created";
|
|
5584
|
+
},
|
|
5585
|
+
readLinks: () => readAllLinks(deps.links)
|
|
5586
|
+
};
|
|
5587
|
+
}
|
|
5588
|
+
//#endregion
|
|
5589
|
+
//#region src/ha-export/membership.ts
|
|
5590
|
+
function devicesForBroker(membership, brokerId) {
|
|
5591
|
+
return membership[brokerId] ?? [];
|
|
5592
|
+
}
|
|
5593
|
+
function isExposed(membership, brokerId, deviceId) {
|
|
5594
|
+
return devicesForBroker(membership, brokerId).includes(deviceId);
|
|
5595
|
+
}
|
|
5596
|
+
/** Every broker a device reaches. Drives the N switches in the panel. */
|
|
5597
|
+
function brokersExposing(membership, deviceId) {
|
|
5598
|
+
return Object.keys(membership).filter((brokerId) => isExposed(membership, brokerId, deviceId)).sort();
|
|
5599
|
+
}
|
|
5600
|
+
/** Every device exported to at least one of the given brokers. */
|
|
5601
|
+
function exposedDeviceIds(membership, brokerIds) {
|
|
5602
|
+
const ids = /* @__PURE__ */ new Set();
|
|
5603
|
+
for (const brokerId of brokerIds) for (const deviceId of devicesForBroker(membership, brokerId)) ids.add(deviceId);
|
|
5604
|
+
return [...ids].sort();
|
|
5605
|
+
}
|
|
5606
|
+
function setExposed(membership, brokerId, deviceId, exposed) {
|
|
5607
|
+
const current = devicesForBroker(membership, brokerId);
|
|
5608
|
+
if (exposed === current.includes(deviceId)) return membership;
|
|
5609
|
+
const next = exposed ? [...current, deviceId].sort() : current.filter((id) => id !== deviceId);
|
|
5610
|
+
return {
|
|
5611
|
+
...membership,
|
|
5612
|
+
[brokerId]: next
|
|
5613
|
+
};
|
|
5614
|
+
}
|
|
5615
|
+
/** Same brokers, same order, same names — nothing for the form to re-render. */
|
|
5616
|
+
function sameBrokers(a, b) {
|
|
5617
|
+
if (a.length !== b.length) return false;
|
|
5618
|
+
return a.every((broker, index) => {
|
|
5619
|
+
const other = b[index];
|
|
5620
|
+
return other !== void 0 && other.id === broker.id && other.name === broker.name;
|
|
5621
|
+
});
|
|
5622
|
+
}
|
|
5623
|
+
//#endregion
|
|
5434
5624
|
//#region src/ha-export/node-reads.ts
|
|
5435
5625
|
var UNREAD = {
|
|
5436
5626
|
footageUsedBytes: null,
|
|
@@ -7485,7 +7675,6 @@ var PTZ_MOVE_BY_DIRECTION = {
|
|
|
7485
7675
|
};
|
|
7486
7676
|
var DEFAULT_CONFIG = {
|
|
7487
7677
|
reconcileIntervalSec: 300,
|
|
7488
|
-
membership: {},
|
|
7489
7678
|
knownBrokers: [],
|
|
7490
7679
|
exportedEntities: {
|
|
7491
7680
|
actions: [],
|
|
@@ -7598,10 +7787,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7598
7787
|
super({ ...DEFAULT_CONFIG });
|
|
7599
7788
|
}
|
|
7600
7789
|
async onInitialize() {
|
|
7601
|
-
|
|
7790
|
+
const records = this.ctx.records;
|
|
7791
|
+
if (records === void 0) throw new Error("ha-export: records are not available");
|
|
7792
|
+
await records.declare({ "ha-export-links": haExportLinksKind });
|
|
7793
|
+
const linksHandle = records.kind(haExportLinksKind);
|
|
7794
|
+
await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
|
|
7602
7795
|
readStored: async () => this.resolveGlobalStore(),
|
|
7603
|
-
|
|
7604
|
-
|
|
7796
|
+
api: this.ctx.api,
|
|
7797
|
+
links: linksHandle,
|
|
7798
|
+
warn: (message) => this.ctx.logger.warn(message)
|
|
7799
|
+
})));
|
|
7800
|
+
this.linkBook = await ExportLinks.load(linksHandle);
|
|
7605
7801
|
const secret = this.state("haExportMediaSecret", string(), "");
|
|
7606
7802
|
const existing = await secret.get();
|
|
7607
7803
|
if (existing.length === 0) {
|
|
@@ -7637,31 +7833,46 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7637
7833
|
{
|
|
7638
7834
|
capability: deviceExportCapability,
|
|
7639
7835
|
provider: {
|
|
7640
|
-
getStatus: async () => this.buildStatus(),
|
|
7641
|
-
|
|
7642
|
-
|
|
7643
|
-
|
|
7644
|
-
|
|
7836
|
+
getStatus: async (input) => this.buildStatus(this.brokerScope(statusInstanceOf(input))),
|
|
7837
|
+
listInstances: async () => this.config.knownBrokers.map((broker) => ({
|
|
7838
|
+
instanceId: broker.id,
|
|
7839
|
+
name: broker.name,
|
|
7840
|
+
enabled: this.enabledBrokerIds().includes(broker.id),
|
|
7841
|
+
kind: "home-assistant"
|
|
7645
7842
|
})),
|
|
7646
|
-
|
|
7647
|
-
|
|
7843
|
+
listSupportedDeviceKinds: async ({ instanceId }) => {
|
|
7844
|
+
this.brokerScope(instanceId);
|
|
7845
|
+
return [...SUPPORTED_DEVICE_KINDS];
|
|
7846
|
+
},
|
|
7847
|
+
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
|
|
7848
|
+
exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
|
|
7849
|
+
unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
|
|
7648
7850
|
listSupportedEntityKinds: async () => [
|
|
7649
7851
|
"device",
|
|
7650
7852
|
"action",
|
|
7651
7853
|
"notifier"
|
|
7652
7854
|
],
|
|
7653
|
-
listExposedEntities: async () => {
|
|
7855
|
+
listExposedEntities: async ({ instanceId }) => {
|
|
7856
|
+
const scope = this.brokerScope(instanceId);
|
|
7654
7857
|
await exportedEntities.pruneDeleted();
|
|
7655
|
-
return [...
|
|
7858
|
+
return [...this.exposedDeviceRows(scope).map((row) => ({
|
|
7656
7859
|
entity: {
|
|
7657
7860
|
kind: "device",
|
|
7658
|
-
deviceId
|
|
7861
|
+
deviceId: row.deviceId
|
|
7659
7862
|
},
|
|
7660
|
-
exposedAs:
|
|
7863
|
+
exposedAs: row.exposedAs
|
|
7661
7864
|
})), ...exportedEntities.list()];
|
|
7662
7865
|
},
|
|
7663
|
-
exposeEntity: async ({ entity }) =>
|
|
7664
|
-
|
|
7866
|
+
exposeEntity: async ({ instanceId, entity }) => {
|
|
7867
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
|
|
7868
|
+
this.brokerScope(instanceId);
|
|
7869
|
+
return exportedEntities.expose(entity);
|
|
7870
|
+
},
|
|
7871
|
+
unexposeEntity: async ({ instanceId, entity }) => {
|
|
7872
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
|
|
7873
|
+
this.brokerScope(instanceId);
|
|
7874
|
+
return exportedEntities.unexpose(entity);
|
|
7875
|
+
},
|
|
7665
7876
|
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
7666
7877
|
getDeviceLiveContribution: async () => null,
|
|
7667
7878
|
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
@@ -7686,29 +7897,43 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7686
7897
|
];
|
|
7687
7898
|
}
|
|
7688
7899
|
/**
|
|
7689
|
-
* The form of the addon shows each instance's own switch and media role
|
|
7690
|
-
*
|
|
7900
|
+
* The form of the addon shows each instance's own switch and media role. They are not settings
|
|
7901
|
+
* of this addon: the values come from the instance's `ha-export-links` record.
|
|
7691
7902
|
*/
|
|
7692
7903
|
async getGlobalSettings(overlay, cap, nodeId) {
|
|
7693
|
-
const rows = await listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
|
|
7694
7904
|
return super.getGlobalSettings({
|
|
7695
7905
|
...overlay,
|
|
7696
|
-
...
|
|
7906
|
+
...linkFormOverlay(this.book())
|
|
7697
7907
|
}, cap, nodeId);
|
|
7698
7908
|
}
|
|
7699
7909
|
/**
|
|
7700
|
-
* An operator write: the `exportTo:` / `mediaRole:` keys go to the
|
|
7701
|
-
*
|
|
7702
|
-
*
|
|
7910
|
+
* An operator write: the `exportTo:` / `mediaRole:` keys go to the record of their broker,
|
|
7911
|
+
* everything else to the addon store as before. A key naming a broker this addon does not know
|
|
7912
|
+
* is refused, never parked.
|
|
7703
7913
|
*/
|
|
7704
7914
|
async updateGlobalSettingsAsOperator(patch, nodeId) {
|
|
7705
|
-
const
|
|
7706
|
-
const
|
|
7707
|
-
for (const [
|
|
7708
|
-
|
|
7709
|
-
|
|
7915
|
+
const rest = {};
|
|
7916
|
+
const flags = /* @__PURE__ */ new Map();
|
|
7917
|
+
for (const [key, value] of Object.entries(patch)) {
|
|
7918
|
+
const field = parseLinkFormKey(key);
|
|
7919
|
+
if (field === null) {
|
|
7920
|
+
rest[key] = value;
|
|
7921
|
+
continue;
|
|
7922
|
+
}
|
|
7923
|
+
this.brokerScope(field.brokerId);
|
|
7924
|
+
const entry = flags.get(field.brokerId) ?? {};
|
|
7925
|
+
if (field.flag === "exportTo") {
|
|
7926
|
+
if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
|
|
7927
|
+
entry.exportTo = value;
|
|
7928
|
+
} else {
|
|
7929
|
+
const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
|
|
7930
|
+
if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
|
|
7931
|
+
entry.mediaRole = role;
|
|
7932
|
+
}
|
|
7933
|
+
flags.set(field.brokerId, entry);
|
|
7710
7934
|
}
|
|
7711
|
-
|
|
7935
|
+
for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
|
|
7936
|
+
if (Object.keys(rest).length > 0 || flags.size === 0) {
|
|
7712
7937
|
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7713
7938
|
return;
|
|
7714
7939
|
}
|
|
@@ -8220,7 +8445,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8220
8445
|
* asking per camera would be the fan-out this repo just finished
|
|
8221
8446
|
* removing from a 300 s poll.
|
|
8222
8447
|
*/
|
|
8223
|
-
const exposedIds = exposedDeviceIds(this.
|
|
8448
|
+
const exposedIds = exposedDeviceIds(this.membership(), enabled);
|
|
8224
8449
|
const cameraDeviceIds = [];
|
|
8225
8450
|
for (const idStr of exposedIds) {
|
|
8226
8451
|
const numericId = Number(idStr);
|
|
@@ -8241,7 +8466,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8241
8466
|
const allCaps = /* @__PURE__ */ new Set();
|
|
8242
8467
|
const exportedDeviceLog = [];
|
|
8243
8468
|
let entityCount = 0;
|
|
8244
|
-
for (const deviceIdStr of exposedDeviceIds(this.
|
|
8469
|
+
for (const deviceIdStr of exposedDeviceIds(this.membership(), enabled)) {
|
|
8245
8470
|
const numericId = Number(deviceIdStr);
|
|
8246
8471
|
if (!Number.isFinite(numericId)) {
|
|
8247
8472
|
this.ctx.logger.warn("ha-export: membership holds a non-numeric device id, skipping", { meta: { deviceId: deviceIdStr } });
|
|
@@ -8286,7 +8511,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8286
8511
|
const catalogDevice = built.device;
|
|
8287
8512
|
if (catalogDevice === null) continue;
|
|
8288
8513
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
8289
|
-
const brokerIds = brokersExposing(this.
|
|
8514
|
+
const brokerIds = brokersExposing(this.membership(), deviceIdStr).filter((id) => enabled.includes(id));
|
|
8290
8515
|
/**
|
|
8291
8516
|
* The plan is built ONCE and announced to every broker that
|
|
8292
8517
|
* receives the device, so it can only use platforms ALL of them
|
|
@@ -9177,27 +9402,19 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9177
9402
|
* is pruned on the next pass that returns a NON-empty list without it,
|
|
9178
9403
|
* which costs one interval and cannot cost the operator their config.
|
|
9179
9404
|
*/
|
|
9180
|
-
|
|
9181
|
-
|
|
9405
|
+
if (haBrokers.length === 0) {
|
|
9406
|
+
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() } });
|
|
9407
|
+
} else {
|
|
9408
|
+
const gone = await this.book().prune(haBrokers.map((broker) => broker.id));
|
|
9409
|
+
if (gone.length > 0) this.ctx.logger.info("ha-export: forgot the export links of removed instances", { meta: { brokers: gone } });
|
|
9410
|
+
}
|
|
9182
9411
|
/**
|
|
9183
|
-
*
|
|
9184
|
-
*
|
|
9185
|
-
*
|
|
9186
|
-
* EITHER differed. A boot-window settings read that answers empty leaves
|
|
9187
|
-
* `this.config` on its constructor defaults, and that joint write then
|
|
9188
|
-
* persisted `membership: {}` over the operator's real export list on a
|
|
9189
|
-
* pass whose only news was the broker roster. It cost this hub three days
|
|
9190
|
-
* of Home Assistant: 2026-08-24 21:55:13.
|
|
9412
|
+
* Only the roster is a setting; the membership is not, so a boot-window read that answers
|
|
9413
|
+
* empty can no longer erase it (2026-08-24: a refresh wrote `membership: {}` over the
|
|
9414
|
+
* operator's export list). The records are read at boot and a failed read stops it.
|
|
9191
9415
|
*/
|
|
9192
|
-
|
|
9193
|
-
|
|
9194
|
-
membership: this.config.membership
|
|
9195
|
-
}, {
|
|
9196
|
-
knownBrokers: known,
|
|
9197
|
-
membership: pruned
|
|
9198
|
-
});
|
|
9199
|
-
if (patch !== null) await this.updateGlobalSettings(patch);
|
|
9200
|
-
await this.refreshEnabledBrokers(known);
|
|
9416
|
+
if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
|
|
9417
|
+
this.refreshEnabledBrokers(known);
|
|
9201
9418
|
const wanted = new Set(this.enabledBrokerIds());
|
|
9202
9419
|
for (const [brokerId, link] of this.links) {
|
|
9203
9420
|
if (wanted.has(brokerId)) continue;
|
|
@@ -9343,30 +9560,27 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9343
9560
|
/**
|
|
9344
9561
|
* The brokers switched on, as of the last {@link refreshEnabledBrokers}.
|
|
9345
9562
|
*
|
|
9346
|
-
* NOT read from `this.config`:
|
|
9347
|
-
*
|
|
9348
|
-
*
|
|
9349
|
-
* `membership.ts` for what that cost. The store is the authority; this is a
|
|
9350
|
-
* read-through cache of it, refreshed at the top of every reconcile pass and
|
|
9351
|
-
* before the per-device panel renders, never written to.
|
|
9563
|
+
* NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
|
|
9564
|
+
* record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
|
|
9565
|
+
* and before the per-device panel renders, never written to.
|
|
9352
9566
|
*/
|
|
9353
9567
|
enabled = [];
|
|
9354
|
-
|
|
9355
|
-
|
|
9356
|
-
|
|
9568
|
+
/** The `ha-export-links` records, loaded at boot after the migrations. */
|
|
9569
|
+
linkBook = null;
|
|
9570
|
+
book() {
|
|
9571
|
+
if (this.linkBook === null) throw new Error("ha-export: the export links are not loaded yet");
|
|
9572
|
+
return this.linkBook;
|
|
9357
9573
|
}
|
|
9358
|
-
/**
|
|
9359
|
-
|
|
9360
|
-
return
|
|
9574
|
+
/** brokerId → exported device ids, from the records. */
|
|
9575
|
+
membership() {
|
|
9576
|
+
return this.book().membership();
|
|
9361
9577
|
}
|
|
9362
|
-
/**
|
|
9363
|
-
|
|
9364
|
-
|
|
9365
|
-
|
|
9366
|
-
|
|
9367
|
-
|
|
9368
|
-
const rows = await 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 } }));
|
|
9369
|
-
return rows.length === 0 ? NO_INSTANCE_SETTINGS : settingsByBroker(rows);
|
|
9578
|
+
/** Persist a membership change: each instance's record first, the in-memory copy after it. */
|
|
9579
|
+
async persistMembership(next) {
|
|
9580
|
+
await this.book().setMembership(next, (brokerId) => this.brokerName(brokerId));
|
|
9581
|
+
}
|
|
9582
|
+
refreshEnabledBrokers(known) {
|
|
9583
|
+
this.enabled = known.map((broker) => broker.id).filter((id) => this.book().isEnabled(id));
|
|
9370
9584
|
}
|
|
9371
9585
|
enabledBrokerIds() {
|
|
9372
9586
|
return this.enabled;
|
|
@@ -9760,14 +9974,14 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9760
9974
|
}
|
|
9761
9975
|
/**
|
|
9762
9976
|
* Each broker's media base, read per use: the palette's role the broker chose
|
|
9763
|
-
* (`mediaRole
|
|
9977
|
+
* (the `ha-export-links` record's `mediaRole`, `internal` by default). A role that resolves to nothing
|
|
9764
9978
|
* is `null` and says so — the image entities then carry no value, which reads as
|
|
9765
9979
|
* empty rather than as a broken link.
|
|
9766
9980
|
*/
|
|
9767
9981
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
9768
9982
|
let bases;
|
|
9769
9983
|
try {
|
|
9770
|
-
bases = mediaBasesByBroker(
|
|
9984
|
+
bases = mediaBasesByBroker(brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), (brokerId) => this.book().mediaRole(brokerId));
|
|
9771
9985
|
} catch (err) {
|
|
9772
9986
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
9773
9987
|
tags: { deviceId },
|
|
@@ -9785,29 +9999,53 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9785
9999
|
return bases;
|
|
9786
10000
|
}
|
|
9787
10001
|
/**
|
|
9788
|
-
*
|
|
9789
|
-
*
|
|
9790
|
-
*
|
|
9791
|
-
|
|
9792
|
-
|
|
9793
|
-
|
|
10002
|
+
* Every broker this addon knows, enabled or not: a membership write may
|
|
10003
|
+
* target a broker that is switched off (its devices resume when it is turned
|
|
10004
|
+
* back on). A cold `knownBrokers` still counts the enabled ids.
|
|
10005
|
+
*/
|
|
10006
|
+
knownBrokerIds() {
|
|
10007
|
+
return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
|
|
10008
|
+
}
|
|
10009
|
+
/**
|
|
10010
|
+
* The brokers a `device-export` read answers over: the named instance, or
|
|
10011
|
+
* (absent) every enabled broker. A broker this addon does not know is
|
|
10012
|
+
* refused, never answered empty.
|
|
10013
|
+
*/
|
|
10014
|
+
brokerScope(instanceId) {
|
|
10015
|
+
if (instanceId === void 0) return this.enabledBrokerIds();
|
|
10016
|
+
if (!this.knownBrokerIds().has(instanceId)) throw new ExportInstanceUnknownError("provider-homeassistant", instanceId);
|
|
10017
|
+
return [instanceId];
|
|
10018
|
+
}
|
|
10019
|
+
exposedDeviceRows(scope) {
|
|
10020
|
+
return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
|
|
10021
|
+
deviceId,
|
|
10022
|
+
exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
|
|
10023
|
+
}));
|
|
10024
|
+
}
|
|
10025
|
+
/**
|
|
10026
|
+
* A cap-level expose/unexpose changes ONE broker's membership — the named
|
|
10027
|
+
* instance. There is no write without a target and no "everywhere": the
|
|
10028
|
+
* other brokers' membership is not touched.
|
|
9794
10029
|
*/
|
|
9795
|
-
async
|
|
9796
|
-
|
|
9797
|
-
|
|
9798
|
-
if (next === this.
|
|
10030
|
+
async setBrokerMembership(instanceId, deviceId, exposed) {
|
|
10031
|
+
this.brokerScope(instanceId);
|
|
10032
|
+
const next = setExposed(this.membership(), instanceId, deviceId, exposed);
|
|
10033
|
+
if (next === this.membership()) {
|
|
9799
10034
|
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
9800
10035
|
tags: { deviceId: Number(deviceId) },
|
|
9801
|
-
meta: {
|
|
10036
|
+
meta: {
|
|
10037
|
+
exposed,
|
|
10038
|
+
brokerId: instanceId
|
|
10039
|
+
}
|
|
9802
10040
|
});
|
|
9803
10041
|
return;
|
|
9804
10042
|
}
|
|
9805
|
-
await this.
|
|
10043
|
+
await this.persistMembership(next);
|
|
9806
10044
|
this.ctx.logger.info("ha-export: export membership changed", {
|
|
9807
10045
|
tags: { deviceId: Number(deviceId) },
|
|
9808
10046
|
meta: {
|
|
9809
10047
|
exposed,
|
|
9810
|
-
|
|
10048
|
+
brokerId: instanceId
|
|
9811
10049
|
}
|
|
9812
10050
|
});
|
|
9813
10051
|
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
@@ -9815,7 +10053,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9815
10053
|
globalSettingsSchema() {
|
|
9816
10054
|
const brokerFields = this.config.knownBrokers.map((broker) => ({
|
|
9817
10055
|
type: "boolean",
|
|
9818
|
-
key:
|
|
10056
|
+
key: exportToFormKey(broker.id),
|
|
9819
10057
|
label: broker.name,
|
|
9820
10058
|
style: "switch",
|
|
9821
10059
|
default: false
|
|
@@ -9844,7 +10082,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9844
10082
|
columns: 1,
|
|
9845
10083
|
fields: this.config.knownBrokers.map((broker) => ({
|
|
9846
10084
|
type: "select",
|
|
9847
|
-
key:
|
|
10085
|
+
key: mediaRoleFormKey(broker.id),
|
|
9848
10086
|
label: `Image links for ${broker.name}`,
|
|
9849
10087
|
options: [{
|
|
9850
10088
|
value: "internal",
|
|
@@ -9885,7 +10123,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9885
10123
|
* exporter's value in this one's toggle.
|
|
9886
10124
|
*/
|
|
9887
10125
|
async buildContribution(deviceId) {
|
|
9888
|
-
|
|
10126
|
+
this.refreshEnabledBrokers(this.config.knownBrokers);
|
|
9889
10127
|
const idStr = String(deviceId);
|
|
9890
10128
|
const enabled = this.enabledBrokerIds();
|
|
9891
10129
|
const fields = enabled.map((brokerId) => ({
|
|
@@ -9893,7 +10131,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9893
10131
|
key: `haExport:${deviceId}:${brokerId}`,
|
|
9894
10132
|
label: `Export to ${this.brokerName(brokerId)}`,
|
|
9895
10133
|
style: "switch",
|
|
9896
|
-
value: isExposed(this.
|
|
10134
|
+
value: isExposed(this.membership(), brokerId, idStr),
|
|
9897
10135
|
immediate: true
|
|
9898
10136
|
}));
|
|
9899
10137
|
return {
|
|
@@ -9923,7 +10161,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9923
10161
|
}
|
|
9924
10162
|
async applyDeviceSettingsPatch(deviceId, patch) {
|
|
9925
10163
|
const idStr = String(deviceId);
|
|
9926
|
-
let next = this.
|
|
10164
|
+
let next = this.membership();
|
|
9927
10165
|
let touched = false;
|
|
9928
10166
|
/**
|
|
9929
10167
|
* Driven by the PATCH KEYS, not by the enabled-broker list.
|
|
@@ -9953,7 +10191,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9953
10191
|
});
|
|
9954
10192
|
return { success: true };
|
|
9955
10193
|
}
|
|
9956
|
-
await this.
|
|
10194
|
+
await this.persistMembership(next);
|
|
9957
10195
|
this.ctx.logger.info("ha-export: per-broker export membership changed", {
|
|
9958
10196
|
tags: { deviceId },
|
|
9959
10197
|
meta: { brokers: brokersExposing(next, idStr) }
|
|
@@ -9961,19 +10199,20 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9961
10199
|
await this.reconcile("device-panel");
|
|
9962
10200
|
return { success: true };
|
|
9963
10201
|
}
|
|
9964
|
-
buildStatus() {
|
|
9965
|
-
const enabled =
|
|
9966
|
-
const
|
|
9967
|
-
const
|
|
10202
|
+
buildStatus(scope) {
|
|
10203
|
+
const enabled = scope;
|
|
10204
|
+
const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
|
|
10205
|
+
const healthy = links.filter((link) => link.client.healthy).length;
|
|
10206
|
+
const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
|
|
9968
10207
|
return {
|
|
9969
|
-
linkState: enabled.length === 0 ? "unlinked" : healthy ===
|
|
9970
|
-
exposedDeviceCount: exposedDeviceIds(this.
|
|
10208
|
+
linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
|
|
10209
|
+
exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
|
|
9971
10210
|
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9972
10211
|
setup: {
|
|
9973
10212
|
fields: [
|
|
9974
10213
|
{
|
|
9975
10214
|
label: "Home Assistant instances",
|
|
9976
|
-
value: `${healthy} / ${
|
|
10215
|
+
value: `${healthy} / ${links.length} reachable`
|
|
9977
10216
|
},
|
|
9978
10217
|
{
|
|
9979
10218
|
label: "Last push failure",
|