@camstack/addon-provider-homeassistant 1.2.653 → 1.2.655
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 +15 -0
- package/dist/addon.js +938 -540
- package/dist/addon.mjs +481 -86
- package/dist/ha-export/ha-export.addon.js +452 -160
- package/dist/ha-export/ha-export.addon.mjs +406 -114
- package/dist/{ha-broker-source-DnSL-mbs.js → ha-instance-settings-5Ms7yRju.js} +1402 -525
- package/dist/{ha-broker-source-CKdH9XHn.mjs → ha-instance-settings-DLDNOmgL.mjs} +1342 -525
- package/package.json +6 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $t as baseUnitOf, At as runExportedAction, Cn as EventCategory, E as addonRoutesCapability, Et as pruneDeletedEntities, G as consumableFormOf, It as sendToExportedTarget, K as consumableKindOf, L as buildAddonRouteProvider, Q as deviceExportCapability, Rt as statusInstanceOf, St as preconditionOf, Xt as BaseAddon, _ as ExportInstanceUnknownError, _n as unknown, an as isReadingQuantity, b as MediaPlayerRepeatSchema, cn as array, d as COCO_TO_MACRO, f as CameraSwitchIdSchema, h as ConsumableStatusSchema, hn as string, m as ConsumableItemSchema, n as listInstancesOrNothing, o as listHomeAssistantBrokers, on as nodePin, p as ConsumableFormSchema, pn as object, r as settingsByBroker, s as AlarmArmModeSchema, t as haInstanceSettingsPortsOver, tn as defineAddonMigrations, u as CAMERA_SWITCH_ORDER, v as FanDirectionSchema, x as PLUG_APPLIANCE_TYPES, y as HvacModeSchema, yt as oauthIntegrationCapability } from "../ha-instance-settings-DLDNOmgL.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import "node:zlib";
|
|
4
4
|
import "node:fs";
|
|
@@ -4973,25 +4973,6 @@ async function planDropPublicBaseUrl(ports) {
|
|
|
4973
4973
|
}])))
|
|
4974
4974
|
}] };
|
|
4975
4975
|
}
|
|
4976
|
-
//#endregion
|
|
4977
|
-
//#region src/ha-export/migrations/index.ts
|
|
4978
|
-
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
4979
|
-
var ADDON_MIGRATIONS = [{
|
|
4980
|
-
meta: {
|
|
4981
|
-
id: "homeassistant-export/2026-11-02-drop-public-base-url",
|
|
4982
|
-
owner: "homeassistant-export",
|
|
4983
|
-
level: "addon-state",
|
|
4984
|
-
seq: 1,
|
|
4985
|
-
dependsOn: [],
|
|
4986
|
-
store: "settings",
|
|
4987
|
-
scope: "cluster",
|
|
4988
|
-
onFailure: "tolerate",
|
|
4989
|
-
bodyKind: "plan",
|
|
4990
|
-
description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
|
|
4991
|
-
fixtureMark: "the reconcile interval the migration does not know"
|
|
4992
|
-
},
|
|
4993
|
-
plan: planDropPublicBaseUrl
|
|
4994
|
-
}];
|
|
4995
4976
|
process.platform, process.env["SystemRoot"];
|
|
4996
4977
|
/**
|
|
4997
4978
|
* The ONE derivation of "a signed, expiring URL".
|
|
@@ -5047,65 +5028,6 @@ function verifyExpiringUrl(input) {
|
|
|
5047
5028
|
return timingSafeEqual(a, b);
|
|
5048
5029
|
}
|
|
5049
5030
|
//#endregion
|
|
5050
|
-
//#region src/ha-export/media-url.ts
|
|
5051
|
-
/**
|
|
5052
|
-
* Signed, expiring media links.
|
|
5053
|
-
*
|
|
5054
|
-
* Images never cross the push transport. Home Assistant is handed a URL
|
|
5055
|
-
* and fetches the bytes itself, on demand, which is what keeps a camera
|
|
5056
|
-
* with a `last_image` entity from turning every detection into a
|
|
5057
|
-
* multi-megabyte POST.
|
|
5058
|
-
*
|
|
5059
|
-
* The link is signed rather than protected by a session, because the
|
|
5060
|
-
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
5061
|
-
* camstack token.
|
|
5062
|
-
*
|
|
5063
|
-
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
5064
|
-
* notification artifact plane each carried their own copy, because addons never
|
|
5065
|
-
* import each other and neither could reach the other's. That reasoning was
|
|
5066
|
-
* right and the conclusion was wrong: both now delegate to the single
|
|
5067
|
-
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
5068
|
-
* stays local is policy — the TTL below, and the id this plane signs.
|
|
5069
|
-
*
|
|
5070
|
-
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
5071
|
-
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
5072
|
-
* URL stops working on its own.
|
|
5073
|
-
*/
|
|
5074
|
-
/** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
|
|
5075
|
-
function mediaRoleKey(brokerId) {
|
|
5076
|
-
return `mediaRole:${brokerId}`;
|
|
5077
|
-
}
|
|
5078
|
-
/** A broker's chosen role; anything stored that is not a known role is `internal`. */
|
|
5079
|
-
function haMediaRoleOf(store, brokerId) {
|
|
5080
|
-
return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
|
|
5081
|
-
}
|
|
5082
|
-
/**
|
|
5083
|
-
* The base a media link is minted on: the hub's role (D734) — `internal` unless the
|
|
5084
|
-
* broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
|
|
5085
|
-
* when the role resolves to nothing): a link Home Assistant cannot open is worse
|
|
5086
|
-
* than no image.
|
|
5087
|
-
*/
|
|
5088
|
-
function haMediaBaseUrl(book, role = "internal") {
|
|
5089
|
-
return book.roles[role].resolved;
|
|
5090
|
-
}
|
|
5091
|
-
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5092
|
-
function mediaBasesByBroker(store, brokerIds, book) {
|
|
5093
|
-
return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
|
|
5094
|
-
}
|
|
5095
|
-
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
5096
|
-
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
5097
|
-
function signMedia(secret, id, expMs) {
|
|
5098
|
-
return signExpiringUrl(secret, id, expMs);
|
|
5099
|
-
}
|
|
5100
|
-
function buildMediaUrl(input) {
|
|
5101
|
-
const base = input.baseUrl.replace(/\/+$/, "");
|
|
5102
|
-
const sig = signMedia(input.secret, input.id, input.expMs);
|
|
5103
|
-
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
5104
|
-
}
|
|
5105
|
-
function verifyMediaSignature(input) {
|
|
5106
|
-
return verifyExpiringUrl(input);
|
|
5107
|
-
}
|
|
5108
|
-
//#endregion
|
|
5109
5031
|
//#region src/ha-export/membership.ts
|
|
5110
5032
|
function devicesForBroker(membership, brokerId) {
|
|
5111
5033
|
return membership[brokerId] ?? [];
|
|
@@ -5167,10 +5089,19 @@ function pruneBrokers(membership, knownBrokerIds) {
|
|
|
5167
5089
|
*
|
|
5168
5090
|
* A broker that is no longer known is not enabled, whatever the store says:
|
|
5169
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.
|
|
5170
5097
|
*/
|
|
5171
|
-
function enabledBrokerIds(store, knownBrokerIds) {
|
|
5172
|
-
return knownBrokerIds.filter((brokerId) =>
|
|
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
|
+
});
|
|
5173
5103
|
}
|
|
5104
|
+
var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
|
|
5174
5105
|
/** Same brokers, same order, same names — nothing for the form to re-render. */
|
|
5175
5106
|
function sameBrokers(a, b) {
|
|
5176
5107
|
if (a.length !== b.length) return false;
|
|
@@ -5224,6 +5155,282 @@ function brokerRefreshPatch(current, next) {
|
|
|
5224
5155
|
};
|
|
5225
5156
|
}
|
|
5226
5157
|
//#endregion
|
|
5158
|
+
//#region src/ha-export/media-url.ts
|
|
5159
|
+
/**
|
|
5160
|
+
* Signed, expiring media links.
|
|
5161
|
+
*
|
|
5162
|
+
* Images never cross the push transport. Home Assistant is handed a URL
|
|
5163
|
+
* and fetches the bytes itself, on demand, which is what keeps a camera
|
|
5164
|
+
* with a `last_image` entity from turning every detection into a
|
|
5165
|
+
* multi-megabyte POST.
|
|
5166
|
+
*
|
|
5167
|
+
* The link is signed rather than protected by a session, because the
|
|
5168
|
+
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
5169
|
+
* camstack token.
|
|
5170
|
+
*
|
|
5171
|
+
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
5172
|
+
* notification artifact plane each carried their own copy, because addons never
|
|
5173
|
+
* import each other and neither could reach the other's. That reasoning was
|
|
5174
|
+
* right and the conclusion was wrong: both now delegate to the single
|
|
5175
|
+
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
5176
|
+
* stays local is policy — the TTL below, and the id this plane signs.
|
|
5177
|
+
*
|
|
5178
|
+
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
5179
|
+
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
5180
|
+
* URL stops working on its own.
|
|
5181
|
+
*/
|
|
5182
|
+
/** The hub address roles a Home Assistant media link may be minted on. */
|
|
5183
|
+
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
|
+
/**
|
|
5198
|
+
* The base a media link is minted on: the hub's role (D734) — `internal` unless the
|
|
5199
|
+
* broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
|
|
5200
|
+
* when the role resolves to nothing): a link Home Assistant cannot open is worse
|
|
5201
|
+
* than no image.
|
|
5202
|
+
*/
|
|
5203
|
+
function haMediaBaseUrl(book, role = "internal") {
|
|
5204
|
+
return book.roles[role].resolved;
|
|
5205
|
+
}
|
|
5206
|
+
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5207
|
+
function mediaBasesByBroker(store, brokerIds, book, perBroker = NO_INSTANCE_SETTINGS) {
|
|
5208
|
+
return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId, perBroker))]));
|
|
5209
|
+
}
|
|
5210
|
+
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
5211
|
+
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
5212
|
+
function signMedia(secret, id, expMs) {
|
|
5213
|
+
return signExpiringUrl(secret, id, expMs);
|
|
5214
|
+
}
|
|
5215
|
+
function buildMediaUrl(input) {
|
|
5216
|
+
const base = input.baseUrl.replace(/\/+$/, "");
|
|
5217
|
+
const sig = signMedia(input.secret, input.id, input.expMs);
|
|
5218
|
+
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
5219
|
+
}
|
|
5220
|
+
function verifyMediaSignature(input) {
|
|
5221
|
+
return verifyExpiringUrl(input);
|
|
5222
|
+
}
|
|
5223
|
+
//#endregion
|
|
5224
|
+
//#region src/ha-export/migrations/per-broker-flags.ts
|
|
5225
|
+
/**
|
|
5226
|
+
* Whether the export reaches a Home Assistant instance, and which hub address role its image
|
|
5227
|
+
* links use, are facts of THAT instance (D985): they live in the instance's integration settings
|
|
5228
|
+
* as `exportTo` and `mediaRole`, not in the addon store as one dynamic key per broker
|
|
5229
|
+
* (`exportTo:<brokerId>`, `mediaRole:<brokerId>`).
|
|
5230
|
+
*
|
|
5231
|
+
* Two migrations:
|
|
5232
|
+
*
|
|
5233
|
+
* 1. `per-broker-flags-to-integration` (procedure) copies each legacy key to the integration that
|
|
5234
|
+
* links its broker. A flag the integration already has is never overwritten; a key whose
|
|
5235
|
+
* broker has no integration any more (a deleted instance) is skipped, never recreated; two
|
|
5236
|
+
* integrations linking one broker are an ambiguity and throw; a value that is not a boolean /
|
|
5237
|
+
* a known role is skipped (the reader already treats it as off / `internal`).
|
|
5238
|
+
* 2. `drop-per-broker-flags` (plan) deletes a legacy key only after a read-back: the integration
|
|
5239
|
+
* must show the same flag. A key the integration disagrees with (its own value was there
|
|
5240
|
+
* first) or whose broker has no integration stays PARKED in place, never deleted.
|
|
5241
|
+
*
|
|
5242
|
+
* Both are idempotent.
|
|
5243
|
+
*/
|
|
5244
|
+
var PREFIX = {
|
|
5245
|
+
exportTo: "exportTo:",
|
|
5246
|
+
mediaRole: "mediaRole:"
|
|
5247
|
+
};
|
|
5248
|
+
var validValue = (flag, value) => flag === "exportTo" ? typeof value === "boolean" : typeof value === "string" && HA_MEDIA_ROLES.some((role) => role === value);
|
|
5249
|
+
/** Every legacy key with a value the reader understands, per broker. */
|
|
5250
|
+
function legacyFlags(stored) {
|
|
5251
|
+
const out = [];
|
|
5252
|
+
for (const [storeKey, value] of Object.entries(stored)) for (const flag of ["exportTo", "mediaRole"]) {
|
|
5253
|
+
const prefix = PREFIX[flag];
|
|
5254
|
+
if (!storeKey.startsWith(prefix) || storeKey.length === prefix.length) continue;
|
|
5255
|
+
if (validValue(flag, value)) out.push({
|
|
5256
|
+
storeKey,
|
|
5257
|
+
brokerId: storeKey.slice(prefix.length),
|
|
5258
|
+
flag,
|
|
5259
|
+
value
|
|
5260
|
+
});
|
|
5261
|
+
}
|
|
5262
|
+
return out;
|
|
5263
|
+
}
|
|
5264
|
+
/** The one integration per broker; two integrations linking one broker are an ambiguity. */
|
|
5265
|
+
function instanceByBroker(rows) {
|
|
5266
|
+
const out = /* @__PURE__ */ new Map();
|
|
5267
|
+
for (const row of rows) {
|
|
5268
|
+
if (row.brokerId === null) continue;
|
|
5269
|
+
const other = out.get(row.brokerId);
|
|
5270
|
+
if (other !== void 0) throw new Error(`homeassistant-export: integrations ${other.integrationId} and ${row.integrationId} both link broker ${row.brokerId} — nothing copied`);
|
|
5271
|
+
out.set(row.brokerId, row);
|
|
5272
|
+
}
|
|
5273
|
+
return out;
|
|
5274
|
+
}
|
|
5275
|
+
async function copyFlagsToIntegrations(ports) {
|
|
5276
|
+
const legacy = legacyFlags(await ports.readStored());
|
|
5277
|
+
if (legacy.length === 0) return {
|
|
5278
|
+
outcome: "already-done",
|
|
5279
|
+
summary: { reason: "no-legacy-flags" }
|
|
5280
|
+
};
|
|
5281
|
+
const byBroker = instanceByBroker(await ports.listInstances());
|
|
5282
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5283
|
+
let kept = 0;
|
|
5284
|
+
let skipped = 0;
|
|
5285
|
+
for (const entry of legacy) {
|
|
5286
|
+
const instance = byBroker.get(entry.brokerId);
|
|
5287
|
+
if (instance === void 0) {
|
|
5288
|
+
skipped += 1;
|
|
5289
|
+
continue;
|
|
5290
|
+
}
|
|
5291
|
+
if (entry.flag in instance.settings) {
|
|
5292
|
+
kept += 1;
|
|
5293
|
+
continue;
|
|
5294
|
+
}
|
|
5295
|
+
pending.set(instance.integrationId, {
|
|
5296
|
+
...pending.get(instance.integrationId),
|
|
5297
|
+
[entry.flag]: entry.value
|
|
5298
|
+
});
|
|
5299
|
+
}
|
|
5300
|
+
let copied = 0;
|
|
5301
|
+
let gone = 0;
|
|
5302
|
+
for (const [integrationId, patch] of pending) if (await ports.patchInstanceSettings(integrationId, patch)) copied += Object.keys(patch).length;
|
|
5303
|
+
else gone += 1;
|
|
5304
|
+
return {
|
|
5305
|
+
outcome: copied === 0 ? "already-done" : "done",
|
|
5306
|
+
summary: {
|
|
5307
|
+
copied,
|
|
5308
|
+
kept,
|
|
5309
|
+
skipped,
|
|
5310
|
+
gone
|
|
5311
|
+
}
|
|
5312
|
+
};
|
|
5313
|
+
}
|
|
5314
|
+
async function planDropPerBrokerFlags(ports) {
|
|
5315
|
+
const legacy = legacyFlags(await ports.readStored());
|
|
5316
|
+
if (legacy.length === 0) return { edits: [] };
|
|
5317
|
+
const byBroker = instanceByBroker(await ports.listInstances());
|
|
5318
|
+
const rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
|
|
5319
|
+
const drop = [];
|
|
5320
|
+
for (const entry of legacy) {
|
|
5321
|
+
const instance = byBroker.get(entry.brokerId);
|
|
5322
|
+
if (instance === void 0) continue;
|
|
5323
|
+
if (!(entry.flag in instance.settings)) throw new Error(`homeassistant-export: ${entry.storeKey} is not dropped, integration ${instance.integrationId} does not show ${entry.flag}`);
|
|
5324
|
+
if (instance.settings[entry.flag] === entry.value) drop.push(entry);
|
|
5325
|
+
}
|
|
5326
|
+
if (drop.length === 0) return { edits: [] };
|
|
5327
|
+
return { edits: [{
|
|
5328
|
+
op: "deleteKeys",
|
|
5329
|
+
scope: ADDON_SETTINGS_TABLE,
|
|
5330
|
+
keys: drop.map((entry) => rowId(entry.storeKey)),
|
|
5331
|
+
precondition: preconditionOf(Object.fromEntries(drop.map((entry) => [rowId(entry.storeKey), {
|
|
5332
|
+
addonId: HA_EXPORT_ADDON_ID,
|
|
5333
|
+
key: entry.storeKey,
|
|
5334
|
+
value: entry.value
|
|
5335
|
+
}])))
|
|
5336
|
+
}] };
|
|
5337
|
+
}
|
|
5338
|
+
//#endregion
|
|
5339
|
+
//#region src/ha-export/migrations/index.ts
|
|
5340
|
+
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
5341
|
+
var ADDON_MIGRATIONS = [
|
|
5342
|
+
{
|
|
5343
|
+
meta: {
|
|
5344
|
+
id: "homeassistant-export/2026-11-02-drop-public-base-url",
|
|
5345
|
+
owner: "homeassistant-export",
|
|
5346
|
+
level: "addon-state",
|
|
5347
|
+
seq: 1,
|
|
5348
|
+
dependsOn: [],
|
|
5349
|
+
store: "settings",
|
|
5350
|
+
scope: "cluster",
|
|
5351
|
+
onFailure: "tolerate",
|
|
5352
|
+
bodyKind: "plan",
|
|
5353
|
+
description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
|
|
5354
|
+
fixtureMark: "the reconcile interval the migration does not know"
|
|
5355
|
+
},
|
|
5356
|
+
plan: planDropPublicBaseUrl
|
|
5357
|
+
},
|
|
5358
|
+
{
|
|
5359
|
+
meta: {
|
|
5360
|
+
id: "homeassistant-export/2026-11-03-per-broker-flags-to-integration",
|
|
5361
|
+
owner: "homeassistant-export",
|
|
5362
|
+
level: "addon-state",
|
|
5363
|
+
seq: 2,
|
|
5364
|
+
dependsOn: [],
|
|
5365
|
+
store: "settings",
|
|
5366
|
+
scope: "cluster",
|
|
5367
|
+
onFailure: "tolerate",
|
|
5368
|
+
bodyKind: "procedure",
|
|
5369
|
+
description: "Copy each exportTo:<broker> and mediaRole:<broker> key to the settings of the integration that links the broker",
|
|
5370
|
+
fixtureMark: "the broker link stored beside the flags of an integration"
|
|
5371
|
+
},
|
|
5372
|
+
procedure: copyFlagsToIntegrations
|
|
5373
|
+
},
|
|
5374
|
+
{
|
|
5375
|
+
meta: {
|
|
5376
|
+
id: "homeassistant-export/2026-11-03-drop-per-broker-flags",
|
|
5377
|
+
owner: "homeassistant-export",
|
|
5378
|
+
level: "addon-state",
|
|
5379
|
+
seq: 3,
|
|
5380
|
+
dependsOn: ["homeassistant-export/2026-11-03-per-broker-flags-to-integration"],
|
|
5381
|
+
store: "settings",
|
|
5382
|
+
scope: "cluster",
|
|
5383
|
+
onFailure: "tolerate",
|
|
5384
|
+
bodyKind: "plan",
|
|
5385
|
+
description: "Delete the per-broker keys once the integration shows the same flag",
|
|
5386
|
+
fixtureMark: "a legacy flag parked when the integration holds a different one"
|
|
5387
|
+
},
|
|
5388
|
+
plan: planDropPerBrokerFlags
|
|
5389
|
+
}
|
|
5390
|
+
];
|
|
5391
|
+
//#endregion
|
|
5392
|
+
//#region src/ha-export/instance-flags.ts
|
|
5393
|
+
var exportKey = (brokerId) => `exportTo:${brokerId}`;
|
|
5394
|
+
var KEYS = [{
|
|
5395
|
+
flag: "exportTo",
|
|
5396
|
+
keyOf: exportKey
|
|
5397
|
+
}, {
|
|
5398
|
+
flag: "mediaRole",
|
|
5399
|
+
keyOf: mediaRoleKey
|
|
5400
|
+
}];
|
|
5401
|
+
function splitInstanceKeys(patch, instances) {
|
|
5402
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
5403
|
+
const toInstances = /* @__PURE__ */ new Map();
|
|
5404
|
+
for (const row of instances) {
|
|
5405
|
+
if (row.brokerId === null) continue;
|
|
5406
|
+
for (const { flag, keyOf } of KEYS) {
|
|
5407
|
+
const key = keyOf(row.brokerId);
|
|
5408
|
+
if (!(key in patch)) continue;
|
|
5409
|
+
claimed.add(key);
|
|
5410
|
+
toInstances.set(row.integrationId, {
|
|
5411
|
+
...toInstances.get(row.integrationId),
|
|
5412
|
+
[flag]: patch[key]
|
|
5413
|
+
});
|
|
5414
|
+
}
|
|
5415
|
+
}
|
|
5416
|
+
return {
|
|
5417
|
+
toInstances,
|
|
5418
|
+
rest: Object.fromEntries(Object.entries(patch).filter(([key]) => !claimed.has(key)))
|
|
5419
|
+
};
|
|
5420
|
+
}
|
|
5421
|
+
/** The form's dynamic keys carrying each instance's own value. */
|
|
5422
|
+
function instanceOverlay(instances) {
|
|
5423
|
+
const overlay = {};
|
|
5424
|
+
for (const row of instances) {
|
|
5425
|
+
if (row.brokerId === null) continue;
|
|
5426
|
+
for (const { flag, keyOf } of KEYS) {
|
|
5427
|
+
const value = row.settings[flag];
|
|
5428
|
+
if (value !== void 0) overlay[keyOf(row.brokerId)] = value;
|
|
5429
|
+
}
|
|
5430
|
+
}
|
|
5431
|
+
return overlay;
|
|
5432
|
+
}
|
|
5433
|
+
//#endregion
|
|
5227
5434
|
//#region src/ha-export/node-reads.ts
|
|
5228
5435
|
var UNREAD = {
|
|
5229
5436
|
footageUsedBytes: null,
|
|
@@ -7391,7 +7598,10 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7391
7598
|
super({ ...DEFAULT_CONFIG });
|
|
7392
7599
|
}
|
|
7393
7600
|
async onInitialize() {
|
|
7394
|
-
await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, {
|
|
7601
|
+
await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, {
|
|
7602
|
+
readStored: async () => this.resolveGlobalStore(),
|
|
7603
|
+
...this.instancePorts()
|
|
7604
|
+
}));
|
|
7395
7605
|
const secret = this.state("haExportMediaSecret", string(), "");
|
|
7396
7606
|
const existing = await secret.get();
|
|
7397
7607
|
if (existing.length === 0) {
|
|
@@ -7427,31 +7637,46 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7427
7637
|
{
|
|
7428
7638
|
capability: deviceExportCapability,
|
|
7429
7639
|
provider: {
|
|
7430
|
-
getStatus: async () => this.buildStatus(),
|
|
7431
|
-
|
|
7432
|
-
|
|
7433
|
-
|
|
7434
|
-
|
|
7640
|
+
getStatus: async (input) => this.buildStatus(this.brokerScope(statusInstanceOf(input))),
|
|
7641
|
+
listInstances: async () => this.config.knownBrokers.map((broker) => ({
|
|
7642
|
+
instanceId: broker.id,
|
|
7643
|
+
name: broker.name,
|
|
7644
|
+
enabled: this.enabledBrokerIds().includes(broker.id),
|
|
7645
|
+
kind: "home-assistant"
|
|
7435
7646
|
})),
|
|
7436
|
-
|
|
7437
|
-
|
|
7647
|
+
listSupportedDeviceKinds: async ({ instanceId }) => {
|
|
7648
|
+
this.brokerScope(instanceId);
|
|
7649
|
+
return [...SUPPORTED_DEVICE_KINDS];
|
|
7650
|
+
},
|
|
7651
|
+
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
|
|
7652
|
+
exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
|
|
7653
|
+
unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
|
|
7438
7654
|
listSupportedEntityKinds: async () => [
|
|
7439
7655
|
"device",
|
|
7440
7656
|
"action",
|
|
7441
7657
|
"notifier"
|
|
7442
7658
|
],
|
|
7443
|
-
listExposedEntities: async () => {
|
|
7659
|
+
listExposedEntities: async ({ instanceId }) => {
|
|
7660
|
+
const scope = this.brokerScope(instanceId);
|
|
7444
7661
|
await exportedEntities.pruneDeleted();
|
|
7445
|
-
return [...
|
|
7662
|
+
return [...this.exposedDeviceRows(scope).map((row) => ({
|
|
7446
7663
|
entity: {
|
|
7447
7664
|
kind: "device",
|
|
7448
|
-
deviceId
|
|
7665
|
+
deviceId: row.deviceId
|
|
7449
7666
|
},
|
|
7450
|
-
exposedAs:
|
|
7667
|
+
exposedAs: row.exposedAs
|
|
7451
7668
|
})), ...exportedEntities.list()];
|
|
7452
7669
|
},
|
|
7453
|
-
exposeEntity: async ({ entity }) =>
|
|
7454
|
-
|
|
7670
|
+
exposeEntity: async ({ instanceId, entity }) => {
|
|
7671
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
|
|
7672
|
+
this.brokerScope(instanceId);
|
|
7673
|
+
return exportedEntities.expose(entity);
|
|
7674
|
+
},
|
|
7675
|
+
unexposeEntity: async ({ instanceId, entity }) => {
|
|
7676
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
|
|
7677
|
+
this.brokerScope(instanceId);
|
|
7678
|
+
return exportedEntities.unexpose(entity);
|
|
7679
|
+
},
|
|
7455
7680
|
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
7456
7681
|
getDeviceLiveContribution: async () => null,
|
|
7457
7682
|
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
@@ -7475,6 +7700,35 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7475
7700
|
}
|
|
7476
7701
|
];
|
|
7477
7702
|
}
|
|
7703
|
+
/**
|
|
7704
|
+
* The form of the addon shows each instance's own switch and media role: the values sit on its
|
|
7705
|
+
* integration (D985), overlaid on the legacy keys the store may still hold.
|
|
7706
|
+
*/
|
|
7707
|
+
async getGlobalSettings(overlay, cap, nodeId) {
|
|
7708
|
+
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 } }));
|
|
7709
|
+
return super.getGlobalSettings({
|
|
7710
|
+
...overlay,
|
|
7711
|
+
...instanceOverlay(rows)
|
|
7712
|
+
}, cap, nodeId);
|
|
7713
|
+
}
|
|
7714
|
+
/**
|
|
7715
|
+
* An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
|
|
7716
|
+
* (D985), everything else to the addon store as before. A key whose broker has no integration
|
|
7717
|
+
* stays in the store.
|
|
7718
|
+
*/
|
|
7719
|
+
async updateGlobalSettingsAsOperator(patch, nodeId) {
|
|
7720
|
+
const ports = this.instancePorts();
|
|
7721
|
+
const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
|
|
7722
|
+
for (const [integrationId, flags] of toInstances) {
|
|
7723
|
+
if (await ports.patchInstanceSettings(integrationId, flags)) continue;
|
|
7724
|
+
throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
|
|
7725
|
+
}
|
|
7726
|
+
if (Object.keys(rest).length > 0 || toInstances.size === 0) {
|
|
7727
|
+
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7728
|
+
return;
|
|
7729
|
+
}
|
|
7730
|
+
await this.onConfigChanged();
|
|
7731
|
+
}
|
|
7478
7732
|
async onConfigChanged() {
|
|
7479
7733
|
for (const link of this.links.values()) link.client.resetBackoff();
|
|
7480
7734
|
await this.reconcile("config-changed");
|
|
@@ -9114,7 +9368,20 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9114
9368
|
enabled = [];
|
|
9115
9369
|
async refreshEnabledBrokers(known) {
|
|
9116
9370
|
const store = await this.resolveGlobalStore();
|
|
9117
|
-
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
|
|
9371
|
+
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
|
|
9372
|
+
}
|
|
9373
|
+
/** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
|
|
9374
|
+
instancePorts() {
|
|
9375
|
+
return haInstanceSettingsPortsOver(this.ctx.api.integrations);
|
|
9376
|
+
}
|
|
9377
|
+
/**
|
|
9378
|
+
* Each broker's integration settings (D985): the switch and the media role of an instance.
|
|
9379
|
+
* A read that fails answers nothing, so every broker falls back to its legacy key and the
|
|
9380
|
+
* failure is logged — never read as "everything off".
|
|
9381
|
+
*/
|
|
9382
|
+
async readInstanceSettings() {
|
|
9383
|
+
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 } }));
|
|
9384
|
+
return rows.length === 0 ? NO_INSTANCE_SETTINGS : settingsByBroker(rows);
|
|
9118
9385
|
}
|
|
9119
9386
|
enabledBrokerIds() {
|
|
9120
9387
|
return this.enabled;
|
|
@@ -9515,7 +9782,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9515
9782
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
9516
9783
|
let bases;
|
|
9517
9784
|
try {
|
|
9518
|
-
bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
|
|
9785
|
+
bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
|
|
9519
9786
|
} catch (err) {
|
|
9520
9787
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
9521
9788
|
tags: { deviceId },
|
|
@@ -9533,20 +9800,44 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9533
9800
|
return bases;
|
|
9534
9801
|
}
|
|
9535
9802
|
/**
|
|
9536
|
-
*
|
|
9537
|
-
*
|
|
9538
|
-
*
|
|
9539
|
-
* and has no notion of a broker; per-broker granularity lives in the
|
|
9540
|
-
* derived Export panel, over the same store. There is exactly one
|
|
9541
|
-
* authority either way.
|
|
9803
|
+
* Every broker this addon knows, enabled or not: a membership write may
|
|
9804
|
+
* target a broker that is switched off (its devices resume when it is turned
|
|
9805
|
+
* back on). A cold `knownBrokers` still counts the enabled ids.
|
|
9542
9806
|
*/
|
|
9543
|
-
|
|
9544
|
-
|
|
9545
|
-
|
|
9807
|
+
knownBrokerIds() {
|
|
9808
|
+
return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
|
|
9809
|
+
}
|
|
9810
|
+
/**
|
|
9811
|
+
* The brokers a `device-export` read answers over: the named instance, or
|
|
9812
|
+
* (absent) every enabled broker. A broker this addon does not know is
|
|
9813
|
+
* refused, never answered empty.
|
|
9814
|
+
*/
|
|
9815
|
+
brokerScope(instanceId) {
|
|
9816
|
+
if (instanceId === void 0) return this.enabledBrokerIds();
|
|
9817
|
+
if (!this.knownBrokerIds().has(instanceId)) throw new ExportInstanceUnknownError("provider-homeassistant", instanceId);
|
|
9818
|
+
return [instanceId];
|
|
9819
|
+
}
|
|
9820
|
+
exposedDeviceRows(scope) {
|
|
9821
|
+
return exposedDeviceIds(this.config.membership, scope).map((deviceId) => ({
|
|
9822
|
+
deviceId,
|
|
9823
|
+
exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
|
|
9824
|
+
}));
|
|
9825
|
+
}
|
|
9826
|
+
/**
|
|
9827
|
+
* A cap-level expose/unexpose changes ONE broker's membership — the named
|
|
9828
|
+
* instance. There is no write without a target and no "everywhere": the
|
|
9829
|
+
* other brokers' membership is not touched.
|
|
9830
|
+
*/
|
|
9831
|
+
async setBrokerMembership(instanceId, deviceId, exposed) {
|
|
9832
|
+
this.brokerScope(instanceId);
|
|
9833
|
+
const next = setExposed(this.config.membership, instanceId, deviceId, exposed);
|
|
9546
9834
|
if (next === this.config.membership) {
|
|
9547
9835
|
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
9548
9836
|
tags: { deviceId: Number(deviceId) },
|
|
9549
|
-
meta: {
|
|
9837
|
+
meta: {
|
|
9838
|
+
exposed,
|
|
9839
|
+
brokerId: instanceId
|
|
9840
|
+
}
|
|
9550
9841
|
});
|
|
9551
9842
|
return;
|
|
9552
9843
|
}
|
|
@@ -9555,7 +9846,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9555
9846
|
tags: { deviceId: Number(deviceId) },
|
|
9556
9847
|
meta: {
|
|
9557
9848
|
exposed,
|
|
9558
|
-
|
|
9849
|
+
brokerId: instanceId
|
|
9559
9850
|
}
|
|
9560
9851
|
});
|
|
9561
9852
|
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
@@ -9709,19 +10000,20 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9709
10000
|
await this.reconcile("device-panel");
|
|
9710
10001
|
return { success: true };
|
|
9711
10002
|
}
|
|
9712
|
-
buildStatus() {
|
|
9713
|
-
const enabled =
|
|
9714
|
-
const
|
|
9715
|
-
const
|
|
10003
|
+
buildStatus(scope) {
|
|
10004
|
+
const enabled = scope;
|
|
10005
|
+
const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
|
|
10006
|
+
const healthy = links.filter((link) => link.client.healthy).length;
|
|
10007
|
+
const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
|
|
9716
10008
|
return {
|
|
9717
|
-
linkState: enabled.length === 0 ? "unlinked" : healthy ===
|
|
10009
|
+
linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
|
|
9718
10010
|
exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
|
|
9719
10011
|
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9720
10012
|
setup: {
|
|
9721
10013
|
fields: [
|
|
9722
10014
|
{
|
|
9723
10015
|
label: "Home Assistant instances",
|
|
9724
|
-
value: `${healthy} / ${
|
|
10016
|
+
value: `${healthy} / ${links.length} reachable`
|
|
9725
10017
|
},
|
|
9726
10018
|
{
|
|
9727
10019
|
label: "Last push failure",
|