@camstack/addon-provider-homeassistant 1.2.652 → 1.2.654
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 +13 -0
- package/dist/addon.js +669 -495
- package/dist/addon.mjs +206 -32
- package/dist/ha-export/ha-export.addon.js +382 -130
- package/dist/ha-export/ha-export.addon.mjs +336 -84
- package/dist/{ha-broker-source-D3ScsO0T.js → ha-instance-settings-Bn_Trz6p.js} +502 -220
- package/dist/{ha-broker-source-CBWl6Wat.mjs → ha-instance-settings-g5pwb3Fi.mjs} +484 -220
- package/package.json +2 -2
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $t as nodePin, F as buildAddonRouteProvider, Jt as defineAddonMigrations, Kt as baseUnitOf, Qt as isReadingQuantity, Tt as runExportedAction, U as consumableFormOf, W as consumableKindOf, Wt as BaseAddon, X as deviceExportCapability, _ as FanDirectionSchema, _t as preconditionOf, b as PLUG_APPLIANCE_TYPES, cn as string, d as COCO_TO_MACRO, f as CameraSwitchIdSchema, gn as EventCategory, h as ConsumableStatusSchema, jt as sendToExportedTarget, m as ConsumableItemSchema, mt as oauthIntegrationCapability, n as listInstancesOrNothing, o as listHomeAssistantBrokers, on as object, p as ConsumableFormSchema, r as settingsByBroker, s as AlarmArmModeSchema, t as haInstanceSettingsPortsOver, tn as array, u as CAMERA_SWITCH_ORDER, un as unknown, v as HvacModeSchema, w as addonRoutesCapability, xt as pruneDeletedEntities, y as MediaPlayerRepeatSchema } from "../ha-instance-settings-g5pwb3Fi.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) {
|
|
@@ -7475,6 +7685,35 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7475
7685
|
}
|
|
7476
7686
|
];
|
|
7477
7687
|
}
|
|
7688
|
+
/**
|
|
7689
|
+
* The form of the addon shows each instance's own switch and media role: the values sit on its
|
|
7690
|
+
* integration (D985), overlaid on the legacy keys the store may still hold.
|
|
7691
|
+
*/
|
|
7692
|
+
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
|
+
return super.getGlobalSettings({
|
|
7695
|
+
...overlay,
|
|
7696
|
+
...instanceOverlay(rows)
|
|
7697
|
+
}, cap, nodeId);
|
|
7698
|
+
}
|
|
7699
|
+
/**
|
|
7700
|
+
* An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
|
|
7701
|
+
* (D985), everything else to the addon store as before. A key whose broker has no integration
|
|
7702
|
+
* stays in the store.
|
|
7703
|
+
*/
|
|
7704
|
+
async updateGlobalSettingsAsOperator(patch, nodeId) {
|
|
7705
|
+
const ports = this.instancePorts();
|
|
7706
|
+
const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
|
|
7707
|
+
for (const [integrationId, flags] of toInstances) {
|
|
7708
|
+
if (await ports.patchInstanceSettings(integrationId, flags)) continue;
|
|
7709
|
+
throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
|
|
7710
|
+
}
|
|
7711
|
+
if (Object.keys(rest).length > 0 || toInstances.size === 0) {
|
|
7712
|
+
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7713
|
+
return;
|
|
7714
|
+
}
|
|
7715
|
+
await this.onConfigChanged();
|
|
7716
|
+
}
|
|
7478
7717
|
async onConfigChanged() {
|
|
7479
7718
|
for (const link of this.links.values()) link.client.resetBackoff();
|
|
7480
7719
|
await this.reconcile("config-changed");
|
|
@@ -9114,7 +9353,20 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9114
9353
|
enabled = [];
|
|
9115
9354
|
async refreshEnabledBrokers(known) {
|
|
9116
9355
|
const store = await this.resolveGlobalStore();
|
|
9117
|
-
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
|
|
9356
|
+
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
|
|
9357
|
+
}
|
|
9358
|
+
/** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
|
|
9359
|
+
instancePorts() {
|
|
9360
|
+
return haInstanceSettingsPortsOver(this.ctx.api.integrations);
|
|
9361
|
+
}
|
|
9362
|
+
/**
|
|
9363
|
+
* Each broker's integration settings (D985): the switch and the media role of an instance.
|
|
9364
|
+
* A read that fails answers nothing, so every broker falls back to its legacy key and the
|
|
9365
|
+
* failure is logged — never read as "everything off".
|
|
9366
|
+
*/
|
|
9367
|
+
async readInstanceSettings() {
|
|
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);
|
|
9118
9370
|
}
|
|
9119
9371
|
enabledBrokerIds() {
|
|
9120
9372
|
return this.enabled;
|
|
@@ -9515,7 +9767,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9515
9767
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
9516
9768
|
let bases;
|
|
9517
9769
|
try {
|
|
9518
|
-
bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
|
|
9770
|
+
bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
|
|
9519
9771
|
} catch (err) {
|
|
9520
9772
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
9521
9773
|
tags: { deviceId },
|