@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
|
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
enumerable: true
|
|
25
25
|
}) : target, mod));
|
|
26
26
|
//#endregion
|
|
27
|
-
const
|
|
27
|
+
const require_ha_instance_settings = require("../ha-instance-settings-5Ms7yRju.js");
|
|
28
28
|
let node_crypto = require("node:crypto");
|
|
29
29
|
require("node:zlib");
|
|
30
30
|
let node_fs = require("node:fs");
|
|
@@ -91,7 +91,7 @@ async function applyCameraFunctionWrite(api, deviceId, write, sourceNodeId) {
|
|
|
91
91
|
await api.streamBroker.setDeviceAudioMute.mutate({
|
|
92
92
|
deviceId,
|
|
93
93
|
muted: write.muted
|
|
94
|
-
},
|
|
94
|
+
}, require_ha_instance_settings.nodePin(sourceNodeId));
|
|
95
95
|
return;
|
|
96
96
|
}
|
|
97
97
|
}
|
|
@@ -725,8 +725,8 @@ function consumableEntity(key, facet) {
|
|
|
725
725
|
* rather than to an icon for nothing.
|
|
726
726
|
*/
|
|
727
727
|
function consumableForm(item) {
|
|
728
|
-
const declared =
|
|
729
|
-
return
|
|
728
|
+
const declared = require_ha_instance_settings.ConsumableFormSchema.safeParse(item.form);
|
|
729
|
+
return require_ha_instance_settings.consumableFormOf({
|
|
730
730
|
...item.kind !== void 0 ? { kind: item.kind } : {},
|
|
731
731
|
...declared.success ? { form: declared.data } : {}
|
|
732
732
|
});
|
|
@@ -739,7 +739,7 @@ function partIcon(key, label) {
|
|
|
739
739
|
function formIcon(item, form) {
|
|
740
740
|
switch (form) {
|
|
741
741
|
case "part": return partIcon(item.key, item.label);
|
|
742
|
-
case "tank": return
|
|
742
|
+
case "tank": return require_ha_instance_settings.consumableKindOf(item) === "waste" ? "mdi:water-off" : "mdi:water";
|
|
743
743
|
case "product": return "mdi:shaker-outline";
|
|
744
744
|
case "bin": return "mdi:delete";
|
|
745
745
|
}
|
|
@@ -750,8 +750,8 @@ function formIcon(item, form) {
|
|
|
750
750
|
* form this export does not know degrades one icon instead of refusing the
|
|
751
751
|
* whole slice.
|
|
752
752
|
*/
|
|
753
|
-
var ExportConsumableItemSchema =
|
|
754
|
-
var ExportConsumablesSchema =
|
|
753
|
+
var ExportConsumableItemSchema = require_ha_instance_settings.ConsumableItemSchema.extend({ form: require_ha_instance_settings.unknown().optional() });
|
|
754
|
+
var ExportConsumablesSchema = require_ha_instance_settings.object({ items: require_ha_instance_settings.array(ExportConsumableItemSchema) });
|
|
755
755
|
/**
|
|
756
756
|
* A worn part's life is a diagnostic; a tank's fill is what the device page
|
|
757
757
|
* is for (a vacuum's clean water, a dishwasher's salt), so it is not hidden.
|
|
@@ -764,7 +764,7 @@ function consumableEntities(item) {
|
|
|
764
764
|
capName: "consumables",
|
|
765
765
|
itemKey: item.key
|
|
766
766
|
};
|
|
767
|
-
const kind =
|
|
767
|
+
const kind = require_ha_instance_settings.consumableKindOf(item);
|
|
768
768
|
const form = consumableForm(item);
|
|
769
769
|
const icon = formIcon(item, form);
|
|
770
770
|
/** What the item IS, on the sensors that describe it (not on a counter or a button). */
|
|
@@ -804,7 +804,7 @@ function consumableEntities(item) {
|
|
|
804
804
|
platform: "sensor",
|
|
805
805
|
label: `${item.label} status`,
|
|
806
806
|
deviceClass: "enum",
|
|
807
|
-
options:
|
|
807
|
+
options: require_ha_instance_settings.ConsumableStatusSchema.options,
|
|
808
808
|
icon,
|
|
809
809
|
attributes,
|
|
810
810
|
entityCategory: "diagnostic"
|
|
@@ -2405,7 +2405,7 @@ var CAP_ENTITY_MAP = {
|
|
|
2405
2405
|
* failure. `fanMode` and `preset` get no such list at all: they are
|
|
2406
2406
|
* free-form strings, so they stay sensors.
|
|
2407
2407
|
*/
|
|
2408
|
-
options:
|
|
2408
|
+
options: require_ha_instance_settings.HvacModeSchema.options,
|
|
2409
2409
|
extras: {
|
|
2410
2410
|
"current-temp": {
|
|
2411
2411
|
platform: "sensor",
|
|
@@ -2555,7 +2555,7 @@ var CAP_ENTITY_MAP = {
|
|
|
2555
2555
|
field: "direction",
|
|
2556
2556
|
label: "Direction",
|
|
2557
2557
|
writable: true,
|
|
2558
|
-
options:
|
|
2558
|
+
options: require_ha_instance_settings.FanDirectionSchema.options,
|
|
2559
2559
|
enabledByDefault: false
|
|
2560
2560
|
}
|
|
2561
2561
|
}
|
|
@@ -2985,7 +2985,7 @@ var CAP_ENTITY_MAP = {
|
|
|
2985
2985
|
field: "repeat",
|
|
2986
2986
|
label: "Repeat",
|
|
2987
2987
|
writable: true,
|
|
2988
|
-
options:
|
|
2988
|
+
options: require_ha_instance_settings.MediaPlayerRepeatSchema.options,
|
|
2989
2989
|
enabledByDefault: false
|
|
2990
2990
|
},
|
|
2991
2991
|
shuffle: {
|
|
@@ -3481,7 +3481,7 @@ function controlEntitySpecs(slice, quantity) {
|
|
|
3481
3481
|
const min = readFiniteNumber(slice, "min") ?? PERCENT_RANGE.min;
|
|
3482
3482
|
const max = readFiniteNumber(slice, "max") ?? PERCENT_RANGE.max;
|
|
3483
3483
|
const step = readFiniteNumber(slice, "step") ?? PERCENT_RANGE.step;
|
|
3484
|
-
const unit = quantity === void 0 ? void 0 :
|
|
3484
|
+
const unit = quantity === void 0 ? void 0 : require_ha_instance_settings.baseUnitOf(quantity);
|
|
3485
3485
|
if (min < max && step > 0) return [{
|
|
3486
3486
|
entity: primary,
|
|
3487
3487
|
platform: "number",
|
|
@@ -3803,7 +3803,7 @@ function buildDerivedPlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
3803
3803
|
continue;
|
|
3804
3804
|
}
|
|
3805
3805
|
if (cap === "numeric-sensor") {
|
|
3806
|
-
const unit = device.readingQuantity === void 0 ? void 0 :
|
|
3806
|
+
const unit = device.readingQuantity === void 0 ? void 0 : require_ha_instance_settings.baseUnitOf(device.readingQuantity);
|
|
3807
3807
|
specs.push(...capEntitySpecs(cap).map((spec) => degradeUnsupported(unit === void 0 ? spec : {
|
|
3808
3808
|
...spec,
|
|
3809
3809
|
unit
|
|
@@ -3878,7 +3878,7 @@ function buildDevicePlan(device, options = UNNEGOTIATED_PLAN_OPTIONS) {
|
|
|
3878
3878
|
* was.
|
|
3879
3879
|
*/
|
|
3880
3880
|
/** The camera function ids, keyed by the entity slug the catalog emits. */
|
|
3881
|
-
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(
|
|
3881
|
+
var CAMERA_SWITCH_BY_SLUG = Object.fromEntries(require_ha_instance_settings.CAMERA_SWITCH_ORDER.map((id) => [toSlug(id), id]));
|
|
3882
3882
|
var PTZ_DIRECTION_BY_ENTITY = Object.fromEntries(PTZ_BUTTONS.map((entity) => [entity, entity.slice(4)]));
|
|
3883
3883
|
function parseBool(value) {
|
|
3884
3884
|
const lower = value.trim().toLowerCase();
|
|
@@ -3941,7 +3941,7 @@ function parseAlarmAction(value) {
|
|
|
3941
3941
|
if (lower === "disarm" || lower === "disarmed") return { kind: "disarm" };
|
|
3942
3942
|
if (lower === "trigger" || lower === "triggered") return { kind: "trigger" };
|
|
3943
3943
|
const withoutPrefix = lower.startsWith("armed_") ? lower.slice(6) : lower.startsWith("arm_") ? lower.slice(4) : lower;
|
|
3944
|
-
const mode =
|
|
3944
|
+
const mode = require_ha_instance_settings.AlarmArmModeSchema.safeParse(withoutPrefix);
|
|
3945
3945
|
return mode.success ? {
|
|
3946
3946
|
kind: "arm",
|
|
3947
3947
|
mode: mode.data
|
|
@@ -4090,7 +4090,7 @@ var CAP_COMMAND_ROUTES = {
|
|
|
4090
4090
|
},
|
|
4091
4091
|
"climate-control": {
|
|
4092
4092
|
primary: (deviceId, value) => {
|
|
4093
|
-
const mode = parseEnum(
|
|
4093
|
+
const mode = parseEnum(require_ha_instance_settings.HvacModeSchema, value);
|
|
4094
4094
|
return mode === null ? null : {
|
|
4095
4095
|
kind: "climate-mode",
|
|
4096
4096
|
deviceId,
|
|
@@ -4205,7 +4205,7 @@ var CAP_COMMAND_ROUTES = {
|
|
|
4205
4205
|
};
|
|
4206
4206
|
},
|
|
4207
4207
|
direction: (deviceId, value) => {
|
|
4208
|
-
const direction = parseEnum(
|
|
4208
|
+
const direction = parseEnum(require_ha_instance_settings.FanDirectionSchema, value);
|
|
4209
4209
|
return direction === null ? null : {
|
|
4210
4210
|
kind: "fan-direction",
|
|
4211
4211
|
deviceId,
|
|
@@ -4409,7 +4409,7 @@ var CAP_COMMAND_ROUTES = {
|
|
|
4409
4409
|
};
|
|
4410
4410
|
},
|
|
4411
4411
|
repeat: (deviceId, value) => {
|
|
4412
|
-
const repeat = parseEnum(
|
|
4412
|
+
const repeat = parseEnum(require_ha_instance_settings.MediaPlayerRepeatSchema, value);
|
|
4413
4413
|
return repeat === null ? null : {
|
|
4414
4414
|
kind: "media-repeat",
|
|
4415
4415
|
deviceId,
|
|
@@ -5001,32 +5001,13 @@ async function planDropPublicBaseUrl(ports) {
|
|
|
5001
5001
|
op: "deleteKeys",
|
|
5002
5002
|
scope: ADDON_SETTINGS_TABLE,
|
|
5003
5003
|
keys: present.map(rowId),
|
|
5004
|
-
precondition:
|
|
5004
|
+
precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(present.map((key) => [rowId(key), {
|
|
5005
5005
|
addonId: HA_EXPORT_ADDON_ID,
|
|
5006
5006
|
key,
|
|
5007
5007
|
value: stored[key]
|
|
5008
5008
|
}])))
|
|
5009
5009
|
}] };
|
|
5010
5010
|
}
|
|
5011
|
-
//#endregion
|
|
5012
|
-
//#region src/ha-export/migrations/index.ts
|
|
5013
|
-
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
5014
|
-
var ADDON_MIGRATIONS = [{
|
|
5015
|
-
meta: {
|
|
5016
|
-
id: "homeassistant-export/2026-11-02-drop-public-base-url",
|
|
5017
|
-
owner: "homeassistant-export",
|
|
5018
|
-
level: "addon-state",
|
|
5019
|
-
seq: 1,
|
|
5020
|
-
dependsOn: [],
|
|
5021
|
-
store: "settings",
|
|
5022
|
-
scope: "cluster",
|
|
5023
|
-
onFailure: "tolerate",
|
|
5024
|
-
bodyKind: "plan",
|
|
5025
|
-
description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
|
|
5026
|
-
fixtureMark: "the reconcile interval the migration does not know"
|
|
5027
|
-
},
|
|
5028
|
-
plan: planDropPublicBaseUrl
|
|
5029
|
-
}];
|
|
5030
5011
|
process.platform, process.env["SystemRoot"];
|
|
5031
5012
|
/**
|
|
5032
5013
|
* The ONE derivation of "a signed, expiring URL".
|
|
@@ -5082,65 +5063,6 @@ function verifyExpiringUrl(input) {
|
|
|
5082
5063
|
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
5083
5064
|
}
|
|
5084
5065
|
//#endregion
|
|
5085
|
-
//#region src/ha-export/media-url.ts
|
|
5086
|
-
/**
|
|
5087
|
-
* Signed, expiring media links.
|
|
5088
|
-
*
|
|
5089
|
-
* Images never cross the push transport. Home Assistant is handed a URL
|
|
5090
|
-
* and fetches the bytes itself, on demand, which is what keeps a camera
|
|
5091
|
-
* with a `last_image` entity from turning every detection into a
|
|
5092
|
-
* multi-megabyte POST.
|
|
5093
|
-
*
|
|
5094
|
-
* The link is signed rather than protected by a session, because the
|
|
5095
|
-
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
5096
|
-
* camstack token.
|
|
5097
|
-
*
|
|
5098
|
-
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
5099
|
-
* notification artifact plane each carried their own copy, because addons never
|
|
5100
|
-
* import each other and neither could reach the other's. That reasoning was
|
|
5101
|
-
* right and the conclusion was wrong: both now delegate to the single
|
|
5102
|
-
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
5103
|
-
* stays local is policy — the TTL below, and the id this plane signs.
|
|
5104
|
-
*
|
|
5105
|
-
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
5106
|
-
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
5107
|
-
* URL stops working on its own.
|
|
5108
|
-
*/
|
|
5109
|
-
/** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
|
|
5110
|
-
function mediaRoleKey(brokerId) {
|
|
5111
|
-
return `mediaRole:${brokerId}`;
|
|
5112
|
-
}
|
|
5113
|
-
/** A broker's chosen role; anything stored that is not a known role is `internal`. */
|
|
5114
|
-
function haMediaRoleOf(store, brokerId) {
|
|
5115
|
-
return store[mediaRoleKey(brokerId)] === "publicEndpoint" ? "publicEndpoint" : "internal";
|
|
5116
|
-
}
|
|
5117
|
-
/**
|
|
5118
|
-
* The base a media link is minted on: the hub's role (D734) — `internal` unless the
|
|
5119
|
-
* broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
|
|
5120
|
-
* when the role resolves to nothing): a link Home Assistant cannot open is worse
|
|
5121
|
-
* than no image.
|
|
5122
|
-
*/
|
|
5123
|
-
function haMediaBaseUrl(book, role = "internal") {
|
|
5124
|
-
return book.roles[role].resolved;
|
|
5125
|
-
}
|
|
5126
|
-
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5127
|
-
function mediaBasesByBroker(store, brokerIds, book) {
|
|
5128
|
-
return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId))]));
|
|
5129
|
-
}
|
|
5130
|
-
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
5131
|
-
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
5132
|
-
function signMedia(secret, id, expMs) {
|
|
5133
|
-
return signExpiringUrl(secret, id, expMs);
|
|
5134
|
-
}
|
|
5135
|
-
function buildMediaUrl(input) {
|
|
5136
|
-
const base = input.baseUrl.replace(/\/+$/, "");
|
|
5137
|
-
const sig = signMedia(input.secret, input.id, input.expMs);
|
|
5138
|
-
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
5139
|
-
}
|
|
5140
|
-
function verifyMediaSignature(input) {
|
|
5141
|
-
return verifyExpiringUrl(input);
|
|
5142
|
-
}
|
|
5143
|
-
//#endregion
|
|
5144
5066
|
//#region src/ha-export/membership.ts
|
|
5145
5067
|
function devicesForBroker(membership, brokerId) {
|
|
5146
5068
|
return membership[brokerId] ?? [];
|
|
@@ -5202,10 +5124,19 @@ function pruneBrokers(membership, knownBrokerIds) {
|
|
|
5202
5124
|
*
|
|
5203
5125
|
* A broker that is no longer known is not enabled, whatever the store says:
|
|
5204
5126
|
* a stale `exportTo:` key for a deleted instance must not resurrect it.
|
|
5127
|
+
*
|
|
5128
|
+
* The switch is a setting of the INSTANCE (D985): `perBroker` carries each broker's integration
|
|
5129
|
+
* settings, and a broker whose integration has an `exportTo` answers from it. A broker whose
|
|
5130
|
+
* integration has none yet — the migration has not copied it — falls back to the legacy
|
|
5131
|
+
* `exportTo:<brokerId>` key, so the move never flips a switch mid-way.
|
|
5205
5132
|
*/
|
|
5206
|
-
function enabledBrokerIds(store, knownBrokerIds) {
|
|
5207
|
-
return knownBrokerIds.filter((brokerId) =>
|
|
5133
|
+
function enabledBrokerIds(store, knownBrokerIds, perBroker = NO_INSTANCE_SETTINGS) {
|
|
5134
|
+
return knownBrokerIds.filter((brokerId) => {
|
|
5135
|
+
const own = perBroker.get(brokerId)?.["exportTo"];
|
|
5136
|
+
return typeof own === "boolean" ? own : store[`exportTo:${brokerId}`] === true;
|
|
5137
|
+
});
|
|
5208
5138
|
}
|
|
5139
|
+
var NO_INSTANCE_SETTINGS = /* @__PURE__ */ new Map();
|
|
5209
5140
|
/** Same brokers, same order, same names — nothing for the form to re-render. */
|
|
5210
5141
|
function sameBrokers(a, b) {
|
|
5211
5142
|
if (a.length !== b.length) return false;
|
|
@@ -5259,6 +5190,282 @@ function brokerRefreshPatch(current, next) {
|
|
|
5259
5190
|
};
|
|
5260
5191
|
}
|
|
5261
5192
|
//#endregion
|
|
5193
|
+
//#region src/ha-export/media-url.ts
|
|
5194
|
+
/**
|
|
5195
|
+
* Signed, expiring media links.
|
|
5196
|
+
*
|
|
5197
|
+
* Images never cross the push transport. Home Assistant is handed a URL
|
|
5198
|
+
* and fetches the bytes itself, on demand, which is what keeps a camera
|
|
5199
|
+
* with a `last_image` entity from turning every detection into a
|
|
5200
|
+
* multi-megabyte POST.
|
|
5201
|
+
*
|
|
5202
|
+
* The link is signed rather than protected by a session, because the
|
|
5203
|
+
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
5204
|
+
* camstack token.
|
|
5205
|
+
*
|
|
5206
|
+
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
5207
|
+
* notification artifact plane each carried their own copy, because addons never
|
|
5208
|
+
* import each other and neither could reach the other's. That reasoning was
|
|
5209
|
+
* right and the conclusion was wrong: both now delegate to the single
|
|
5210
|
+
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
5211
|
+
* stays local is policy — the TTL below, and the id this plane signs.
|
|
5212
|
+
*
|
|
5213
|
+
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
5214
|
+
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
5215
|
+
* URL stops working on its own.
|
|
5216
|
+
*/
|
|
5217
|
+
/** The hub address roles a Home Assistant media link may be minted on. */
|
|
5218
|
+
var HA_MEDIA_ROLES = ["internal", "publicEndpoint"];
|
|
5219
|
+
/** The settings key that carries one broker's media role (per-instance, like `exportTo:`). */
|
|
5220
|
+
function mediaRoleKey(brokerId) {
|
|
5221
|
+
return `mediaRole:${brokerId}`;
|
|
5222
|
+
}
|
|
5223
|
+
/**
|
|
5224
|
+
* A broker's chosen role; anything stored that is not a known role is `internal`. The role is a
|
|
5225
|
+
* setting of the INSTANCE (D985): the integration's `mediaRole` answers when it has one, and a
|
|
5226
|
+
* broker the migration has not reached falls back to the legacy `mediaRole:<brokerId>` key.
|
|
5227
|
+
*/
|
|
5228
|
+
function haMediaRoleOf(store, brokerId, perBroker = NO_INSTANCE_SETTINGS) {
|
|
5229
|
+
const own = perBroker.get(brokerId)?.["mediaRole"];
|
|
5230
|
+
return (own !== void 0 ? own : store[mediaRoleKey(brokerId)]) === "publicEndpoint" ? "publicEndpoint" : "internal";
|
|
5231
|
+
}
|
|
5232
|
+
/**
|
|
5233
|
+
* The base a media link is minted on: the hub's role (D734) — `internal` unless the
|
|
5234
|
+
* broker chose `publicEndpoint`. Ranked by `local-network`, never loopback (`null`
|
|
5235
|
+
* when the role resolves to nothing): a link Home Assistant cannot open is worse
|
|
5236
|
+
* than no image.
|
|
5237
|
+
*/
|
|
5238
|
+
function haMediaBaseUrl(book, role = "internal") {
|
|
5239
|
+
return book.roles[role].resolved;
|
|
5240
|
+
}
|
|
5241
|
+
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5242
|
+
function mediaBasesByBroker(store, brokerIds, book, perBroker = NO_INSTANCE_SETTINGS) {
|
|
5243
|
+
return new Map(brokerIds.map((brokerId) => [brokerId, haMediaBaseUrl(book, haMediaRoleOf(store, brokerId, perBroker))]));
|
|
5244
|
+
}
|
|
5245
|
+
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
5246
|
+
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
5247
|
+
function signMedia(secret, id, expMs) {
|
|
5248
|
+
return signExpiringUrl(secret, id, expMs);
|
|
5249
|
+
}
|
|
5250
|
+
function buildMediaUrl(input) {
|
|
5251
|
+
const base = input.baseUrl.replace(/\/+$/, "");
|
|
5252
|
+
const sig = signMedia(input.secret, input.id, input.expMs);
|
|
5253
|
+
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
5254
|
+
}
|
|
5255
|
+
function verifyMediaSignature(input) {
|
|
5256
|
+
return verifyExpiringUrl(input);
|
|
5257
|
+
}
|
|
5258
|
+
//#endregion
|
|
5259
|
+
//#region src/ha-export/migrations/per-broker-flags.ts
|
|
5260
|
+
/**
|
|
5261
|
+
* Whether the export reaches a Home Assistant instance, and which hub address role its image
|
|
5262
|
+
* links use, are facts of THAT instance (D985): they live in the instance's integration settings
|
|
5263
|
+
* as `exportTo` and `mediaRole`, not in the addon store as one dynamic key per broker
|
|
5264
|
+
* (`exportTo:<brokerId>`, `mediaRole:<brokerId>`).
|
|
5265
|
+
*
|
|
5266
|
+
* Two migrations:
|
|
5267
|
+
*
|
|
5268
|
+
* 1. `per-broker-flags-to-integration` (procedure) copies each legacy key to the integration that
|
|
5269
|
+
* links its broker. A flag the integration already has is never overwritten; a key whose
|
|
5270
|
+
* broker has no integration any more (a deleted instance) is skipped, never recreated; two
|
|
5271
|
+
* integrations linking one broker are an ambiguity and throw; a value that is not a boolean /
|
|
5272
|
+
* a known role is skipped (the reader already treats it as off / `internal`).
|
|
5273
|
+
* 2. `drop-per-broker-flags` (plan) deletes a legacy key only after a read-back: the integration
|
|
5274
|
+
* must show the same flag. A key the integration disagrees with (its own value was there
|
|
5275
|
+
* first) or whose broker has no integration stays PARKED in place, never deleted.
|
|
5276
|
+
*
|
|
5277
|
+
* Both are idempotent.
|
|
5278
|
+
*/
|
|
5279
|
+
var PREFIX = {
|
|
5280
|
+
exportTo: "exportTo:",
|
|
5281
|
+
mediaRole: "mediaRole:"
|
|
5282
|
+
};
|
|
5283
|
+
var validValue = (flag, value) => flag === "exportTo" ? typeof value === "boolean" : typeof value === "string" && HA_MEDIA_ROLES.some((role) => role === value);
|
|
5284
|
+
/** Every legacy key with a value the reader understands, per broker. */
|
|
5285
|
+
function legacyFlags(stored) {
|
|
5286
|
+
const out = [];
|
|
5287
|
+
for (const [storeKey, value] of Object.entries(stored)) for (const flag of ["exportTo", "mediaRole"]) {
|
|
5288
|
+
const prefix = PREFIX[flag];
|
|
5289
|
+
if (!storeKey.startsWith(prefix) || storeKey.length === prefix.length) continue;
|
|
5290
|
+
if (validValue(flag, value)) out.push({
|
|
5291
|
+
storeKey,
|
|
5292
|
+
brokerId: storeKey.slice(prefix.length),
|
|
5293
|
+
flag,
|
|
5294
|
+
value
|
|
5295
|
+
});
|
|
5296
|
+
}
|
|
5297
|
+
return out;
|
|
5298
|
+
}
|
|
5299
|
+
/** The one integration per broker; two integrations linking one broker are an ambiguity. */
|
|
5300
|
+
function instanceByBroker(rows) {
|
|
5301
|
+
const out = /* @__PURE__ */ new Map();
|
|
5302
|
+
for (const row of rows) {
|
|
5303
|
+
if (row.brokerId === null) continue;
|
|
5304
|
+
const other = out.get(row.brokerId);
|
|
5305
|
+
if (other !== void 0) throw new Error(`homeassistant-export: integrations ${other.integrationId} and ${row.integrationId} both link broker ${row.brokerId} — nothing copied`);
|
|
5306
|
+
out.set(row.brokerId, row);
|
|
5307
|
+
}
|
|
5308
|
+
return out;
|
|
5309
|
+
}
|
|
5310
|
+
async function copyFlagsToIntegrations(ports) {
|
|
5311
|
+
const legacy = legacyFlags(await ports.readStored());
|
|
5312
|
+
if (legacy.length === 0) return {
|
|
5313
|
+
outcome: "already-done",
|
|
5314
|
+
summary: { reason: "no-legacy-flags" }
|
|
5315
|
+
};
|
|
5316
|
+
const byBroker = instanceByBroker(await ports.listInstances());
|
|
5317
|
+
const pending = /* @__PURE__ */ new Map();
|
|
5318
|
+
let kept = 0;
|
|
5319
|
+
let skipped = 0;
|
|
5320
|
+
for (const entry of legacy) {
|
|
5321
|
+
const instance = byBroker.get(entry.brokerId);
|
|
5322
|
+
if (instance === void 0) {
|
|
5323
|
+
skipped += 1;
|
|
5324
|
+
continue;
|
|
5325
|
+
}
|
|
5326
|
+
if (entry.flag in instance.settings) {
|
|
5327
|
+
kept += 1;
|
|
5328
|
+
continue;
|
|
5329
|
+
}
|
|
5330
|
+
pending.set(instance.integrationId, {
|
|
5331
|
+
...pending.get(instance.integrationId),
|
|
5332
|
+
[entry.flag]: entry.value
|
|
5333
|
+
});
|
|
5334
|
+
}
|
|
5335
|
+
let copied = 0;
|
|
5336
|
+
let gone = 0;
|
|
5337
|
+
for (const [integrationId, patch] of pending) if (await ports.patchInstanceSettings(integrationId, patch)) copied += Object.keys(patch).length;
|
|
5338
|
+
else gone += 1;
|
|
5339
|
+
return {
|
|
5340
|
+
outcome: copied === 0 ? "already-done" : "done",
|
|
5341
|
+
summary: {
|
|
5342
|
+
copied,
|
|
5343
|
+
kept,
|
|
5344
|
+
skipped,
|
|
5345
|
+
gone
|
|
5346
|
+
}
|
|
5347
|
+
};
|
|
5348
|
+
}
|
|
5349
|
+
async function planDropPerBrokerFlags(ports) {
|
|
5350
|
+
const legacy = legacyFlags(await ports.readStored());
|
|
5351
|
+
if (legacy.length === 0) return { edits: [] };
|
|
5352
|
+
const byBroker = instanceByBroker(await ports.listInstances());
|
|
5353
|
+
const rowId = (key) => `${HA_EXPORT_ADDON_ID}.${key}`;
|
|
5354
|
+
const drop = [];
|
|
5355
|
+
for (const entry of legacy) {
|
|
5356
|
+
const instance = byBroker.get(entry.brokerId);
|
|
5357
|
+
if (instance === void 0) continue;
|
|
5358
|
+
if (!(entry.flag in instance.settings)) throw new Error(`homeassistant-export: ${entry.storeKey} is not dropped, integration ${instance.integrationId} does not show ${entry.flag}`);
|
|
5359
|
+
if (instance.settings[entry.flag] === entry.value) drop.push(entry);
|
|
5360
|
+
}
|
|
5361
|
+
if (drop.length === 0) return { edits: [] };
|
|
5362
|
+
return { edits: [{
|
|
5363
|
+
op: "deleteKeys",
|
|
5364
|
+
scope: ADDON_SETTINGS_TABLE,
|
|
5365
|
+
keys: drop.map((entry) => rowId(entry.storeKey)),
|
|
5366
|
+
precondition: require_ha_instance_settings.preconditionOf(Object.fromEntries(drop.map((entry) => [rowId(entry.storeKey), {
|
|
5367
|
+
addonId: HA_EXPORT_ADDON_ID,
|
|
5368
|
+
key: entry.storeKey,
|
|
5369
|
+
value: entry.value
|
|
5370
|
+
}])))
|
|
5371
|
+
}] };
|
|
5372
|
+
}
|
|
5373
|
+
//#endregion
|
|
5374
|
+
//#region src/ha-export/migrations/index.ts
|
|
5375
|
+
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
5376
|
+
var ADDON_MIGRATIONS = [
|
|
5377
|
+
{
|
|
5378
|
+
meta: {
|
|
5379
|
+
id: "homeassistant-export/2026-11-02-drop-public-base-url",
|
|
5380
|
+
owner: "homeassistant-export",
|
|
5381
|
+
level: "addon-state",
|
|
5382
|
+
seq: 1,
|
|
5383
|
+
dependsOn: [],
|
|
5384
|
+
store: "settings",
|
|
5385
|
+
scope: "cluster",
|
|
5386
|
+
onFailure: "tolerate",
|
|
5387
|
+
bodyKind: "plan",
|
|
5388
|
+
description: "Delete the retired publicBaseUrl setting; each instance now chooses a hub address role",
|
|
5389
|
+
fixtureMark: "the reconcile interval the migration does not know"
|
|
5390
|
+
},
|
|
5391
|
+
plan: planDropPublicBaseUrl
|
|
5392
|
+
},
|
|
5393
|
+
{
|
|
5394
|
+
meta: {
|
|
5395
|
+
id: "homeassistant-export/2026-11-03-per-broker-flags-to-integration",
|
|
5396
|
+
owner: "homeassistant-export",
|
|
5397
|
+
level: "addon-state",
|
|
5398
|
+
seq: 2,
|
|
5399
|
+
dependsOn: [],
|
|
5400
|
+
store: "settings",
|
|
5401
|
+
scope: "cluster",
|
|
5402
|
+
onFailure: "tolerate",
|
|
5403
|
+
bodyKind: "procedure",
|
|
5404
|
+
description: "Copy each exportTo:<broker> and mediaRole:<broker> key to the settings of the integration that links the broker",
|
|
5405
|
+
fixtureMark: "the broker link stored beside the flags of an integration"
|
|
5406
|
+
},
|
|
5407
|
+
procedure: copyFlagsToIntegrations
|
|
5408
|
+
},
|
|
5409
|
+
{
|
|
5410
|
+
meta: {
|
|
5411
|
+
id: "homeassistant-export/2026-11-03-drop-per-broker-flags",
|
|
5412
|
+
owner: "homeassistant-export",
|
|
5413
|
+
level: "addon-state",
|
|
5414
|
+
seq: 3,
|
|
5415
|
+
dependsOn: ["homeassistant-export/2026-11-03-per-broker-flags-to-integration"],
|
|
5416
|
+
store: "settings",
|
|
5417
|
+
scope: "cluster",
|
|
5418
|
+
onFailure: "tolerate",
|
|
5419
|
+
bodyKind: "plan",
|
|
5420
|
+
description: "Delete the per-broker keys once the integration shows the same flag",
|
|
5421
|
+
fixtureMark: "a legacy flag parked when the integration holds a different one"
|
|
5422
|
+
},
|
|
5423
|
+
plan: planDropPerBrokerFlags
|
|
5424
|
+
}
|
|
5425
|
+
];
|
|
5426
|
+
//#endregion
|
|
5427
|
+
//#region src/ha-export/instance-flags.ts
|
|
5428
|
+
var exportKey = (brokerId) => `exportTo:${brokerId}`;
|
|
5429
|
+
var KEYS = [{
|
|
5430
|
+
flag: "exportTo",
|
|
5431
|
+
keyOf: exportKey
|
|
5432
|
+
}, {
|
|
5433
|
+
flag: "mediaRole",
|
|
5434
|
+
keyOf: mediaRoleKey
|
|
5435
|
+
}];
|
|
5436
|
+
function splitInstanceKeys(patch, instances) {
|
|
5437
|
+
const claimed = /* @__PURE__ */ new Set();
|
|
5438
|
+
const toInstances = /* @__PURE__ */ new Map();
|
|
5439
|
+
for (const row of instances) {
|
|
5440
|
+
if (row.brokerId === null) continue;
|
|
5441
|
+
for (const { flag, keyOf } of KEYS) {
|
|
5442
|
+
const key = keyOf(row.brokerId);
|
|
5443
|
+
if (!(key in patch)) continue;
|
|
5444
|
+
claimed.add(key);
|
|
5445
|
+
toInstances.set(row.integrationId, {
|
|
5446
|
+
...toInstances.get(row.integrationId),
|
|
5447
|
+
[flag]: patch[key]
|
|
5448
|
+
});
|
|
5449
|
+
}
|
|
5450
|
+
}
|
|
5451
|
+
return {
|
|
5452
|
+
toInstances,
|
|
5453
|
+
rest: Object.fromEntries(Object.entries(patch).filter(([key]) => !claimed.has(key)))
|
|
5454
|
+
};
|
|
5455
|
+
}
|
|
5456
|
+
/** The form's dynamic keys carrying each instance's own value. */
|
|
5457
|
+
function instanceOverlay(instances) {
|
|
5458
|
+
const overlay = {};
|
|
5459
|
+
for (const row of instances) {
|
|
5460
|
+
if (row.brokerId === null) continue;
|
|
5461
|
+
for (const { flag, keyOf } of KEYS) {
|
|
5462
|
+
const value = row.settings[flag];
|
|
5463
|
+
if (value !== void 0) overlay[keyOf(row.brokerId)] = value;
|
|
5464
|
+
}
|
|
5465
|
+
}
|
|
5466
|
+
return overlay;
|
|
5467
|
+
}
|
|
5468
|
+
//#endregion
|
|
5262
5469
|
//#region src/ha-export/node-reads.ts
|
|
5263
5470
|
var UNREAD = {
|
|
5264
5471
|
footageUsedBytes: null,
|
|
@@ -5970,7 +6177,7 @@ function reduceLastTriggers(rows) {
|
|
|
5970
6177
|
function macroOf(className) {
|
|
5971
6178
|
const lower = className.toLowerCase();
|
|
5972
6179
|
if (EXPORTED_MACROS.includes(lower)) return lower;
|
|
5973
|
-
const mapped =
|
|
6180
|
+
const mapped = require_ha_instance_settings.COCO_TO_MACRO.mapping[lower];
|
|
5974
6181
|
return typeof mapped === "string" ? mapped : null;
|
|
5975
6182
|
}
|
|
5976
6183
|
function iso(timestamp) {
|
|
@@ -7119,7 +7326,7 @@ var ExportedEntities = class {
|
|
|
7119
7326
|
this.ports.logger.warn("homeassistant-export: exported entities not reconciled — a list was unanswered", { meta: { error: errMsg$1(err) } });
|
|
7120
7327
|
return;
|
|
7121
7328
|
}
|
|
7122
|
-
const { dropped } =
|
|
7329
|
+
const { dropped } = require_ha_instance_settings.pruneDeletedEntities([...this.state.actions.map((e) => ({
|
|
7123
7330
|
kind: "action",
|
|
7124
7331
|
actionId: e.actionId
|
|
7125
7332
|
})), ...this.state.targets.map((e) => ({
|
|
@@ -7191,7 +7398,7 @@ var ExportedEntities = class {
|
|
|
7191
7398
|
const action = this.state.actions.find((e) => actionEntity(e.actionId) === entity);
|
|
7192
7399
|
if (action !== void 0) return {
|
|
7193
7400
|
handled: true,
|
|
7194
|
-
applied: (await
|
|
7401
|
+
applied: (await require_ha_instance_settings.runExportedAction(this.ports.api, this.ports.logger, {
|
|
7195
7402
|
exporter: EXPORTER,
|
|
7196
7403
|
actionId: action.actionId
|
|
7197
7404
|
})).status === "started"
|
|
@@ -7199,7 +7406,7 @@ var ExportedEntities = class {
|
|
|
7199
7406
|
const target = this.state.targets.find((e) => targetEntity(e.targetId) === entity);
|
|
7200
7407
|
if (target !== void 0) return {
|
|
7201
7408
|
handled: true,
|
|
7202
|
-
applied: (await
|
|
7409
|
+
applied: (await require_ha_instance_settings.sendToExportedTarget(this.ports.api, this.ports.logger, {
|
|
7203
7410
|
exporter: EXPORTER,
|
|
7204
7411
|
targetId: target.targetId,
|
|
7205
7412
|
notification: { body: value }
|
|
@@ -7250,7 +7457,7 @@ var SUPPORTED_DEVICE_KINDS = [
|
|
|
7250
7457
|
"image",
|
|
7251
7458
|
"pet-feeder",
|
|
7252
7459
|
"hub",
|
|
7253
|
-
...
|
|
7460
|
+
...require_ha_instance_settings.PLUG_APPLIANCE_TYPES
|
|
7254
7461
|
];
|
|
7255
7462
|
//#endregion
|
|
7256
7463
|
//#region src/ha-export/ha-export.addon.ts
|
|
@@ -7320,7 +7527,7 @@ var DEFAULT_CONFIG = {
|
|
|
7320
7527
|
targets: []
|
|
7321
7528
|
}
|
|
7322
7529
|
};
|
|
7323
|
-
var HaExportAddon = class extends
|
|
7530
|
+
var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
7324
7531
|
links = /* @__PURE__ */ new Map();
|
|
7325
7532
|
/** deviceKey → the device it addresses. Rebuilt on every reconcile. */
|
|
7326
7533
|
exported = /* @__PURE__ */ new Map();
|
|
@@ -7426,8 +7633,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
7426
7633
|
super({ ...DEFAULT_CONFIG });
|
|
7427
7634
|
}
|
|
7428
7635
|
async onInitialize() {
|
|
7429
|
-
await this.runMigrations(
|
|
7430
|
-
|
|
7636
|
+
await this.runMigrations(require_ha_instance_settings.defineAddonMigrations(ADDON_MIGRATIONS, {
|
|
7637
|
+
readStored: async () => this.resolveGlobalStore(),
|
|
7638
|
+
...this.instancePorts()
|
|
7639
|
+
}));
|
|
7640
|
+
const secret = this.state("haExportMediaSecret", require_ha_instance_settings.string(), "");
|
|
7431
7641
|
const existing = await secret.get();
|
|
7432
7642
|
if (existing.length === 0) {
|
|
7433
7643
|
const minted = randomSecret();
|
|
@@ -7460,41 +7670,56 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
7460
7670
|
this.exportedEntities = exportedEntities;
|
|
7461
7671
|
return [
|
|
7462
7672
|
{
|
|
7463
|
-
capability:
|
|
7673
|
+
capability: require_ha_instance_settings.deviceExportCapability,
|
|
7464
7674
|
provider: {
|
|
7465
|
-
getStatus: async () => this.buildStatus(),
|
|
7466
|
-
|
|
7467
|
-
|
|
7468
|
-
|
|
7469
|
-
|
|
7675
|
+
getStatus: async (input) => this.buildStatus(this.brokerScope(require_ha_instance_settings.statusInstanceOf(input))),
|
|
7676
|
+
listInstances: async () => this.config.knownBrokers.map((broker) => ({
|
|
7677
|
+
instanceId: broker.id,
|
|
7678
|
+
name: broker.name,
|
|
7679
|
+
enabled: this.enabledBrokerIds().includes(broker.id),
|
|
7680
|
+
kind: "home-assistant"
|
|
7470
7681
|
})),
|
|
7471
|
-
|
|
7472
|
-
|
|
7682
|
+
listSupportedDeviceKinds: async ({ instanceId }) => {
|
|
7683
|
+
this.brokerScope(instanceId);
|
|
7684
|
+
return [...SUPPORTED_DEVICE_KINDS];
|
|
7685
|
+
},
|
|
7686
|
+
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.brokerScope(instanceId)),
|
|
7687
|
+
exposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, true),
|
|
7688
|
+
unexposeDevice: async ({ instanceId, deviceId }) => this.setBrokerMembership(instanceId, deviceId, false),
|
|
7473
7689
|
listSupportedEntityKinds: async () => [
|
|
7474
7690
|
"device",
|
|
7475
7691
|
"action",
|
|
7476
7692
|
"notifier"
|
|
7477
7693
|
],
|
|
7478
|
-
listExposedEntities: async () => {
|
|
7694
|
+
listExposedEntities: async ({ instanceId }) => {
|
|
7695
|
+
const scope = this.brokerScope(instanceId);
|
|
7479
7696
|
await exportedEntities.pruneDeleted();
|
|
7480
|
-
return [...
|
|
7697
|
+
return [...this.exposedDeviceRows(scope).map((row) => ({
|
|
7481
7698
|
entity: {
|
|
7482
7699
|
kind: "device",
|
|
7483
|
-
deviceId
|
|
7700
|
+
deviceId: row.deviceId
|
|
7484
7701
|
},
|
|
7485
|
-
exposedAs:
|
|
7702
|
+
exposedAs: row.exposedAs
|
|
7486
7703
|
})), ...exportedEntities.list()];
|
|
7487
7704
|
},
|
|
7488
|
-
exposeEntity: async ({ entity }) =>
|
|
7489
|
-
|
|
7705
|
+
exposeEntity: async ({ instanceId, entity }) => {
|
|
7706
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, true);
|
|
7707
|
+
this.brokerScope(instanceId);
|
|
7708
|
+
return exportedEntities.expose(entity);
|
|
7709
|
+
},
|
|
7710
|
+
unexposeEntity: async ({ instanceId, entity }) => {
|
|
7711
|
+
if (entity.kind === "device") return this.setBrokerMembership(instanceId, entity.deviceId, false);
|
|
7712
|
+
this.brokerScope(instanceId);
|
|
7713
|
+
return exportedEntities.unexpose(entity);
|
|
7714
|
+
},
|
|
7490
7715
|
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
7491
7716
|
getDeviceLiveContribution: async () => null,
|
|
7492
7717
|
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
7493
7718
|
}
|
|
7494
7719
|
},
|
|
7495
7720
|
{
|
|
7496
|
-
capability:
|
|
7497
|
-
provider:
|
|
7721
|
+
capability: require_ha_instance_settings.addonRoutesCapability,
|
|
7722
|
+
provider: require_ha_instance_settings.buildAddonRouteProvider(ADDON_ID, [{
|
|
7498
7723
|
method: "POST",
|
|
7499
7724
|
path: "/command",
|
|
7500
7725
|
access: "authenticated",
|
|
@@ -7505,27 +7730,56 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
7505
7730
|
}])
|
|
7506
7731
|
},
|
|
7507
7732
|
{
|
|
7508
|
-
capability:
|
|
7733
|
+
capability: require_ha_instance_settings.oauthIntegrationCapability,
|
|
7509
7734
|
provider: { getDescriptor: async () => HOME_ASSISTANT_OAUTH_INTEGRATION }
|
|
7510
7735
|
}
|
|
7511
7736
|
];
|
|
7512
7737
|
}
|
|
7738
|
+
/**
|
|
7739
|
+
* The form of the addon shows each instance's own switch and media role: the values sit on its
|
|
7740
|
+
* integration (D985), overlaid on the legacy keys the store may still hold.
|
|
7741
|
+
*/
|
|
7742
|
+
async getGlobalSettings(overlay, cap, nodeId) {
|
|
7743
|
+
const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read for the form", { meta: { error } }));
|
|
7744
|
+
return super.getGlobalSettings({
|
|
7745
|
+
...overlay,
|
|
7746
|
+
...instanceOverlay(rows)
|
|
7747
|
+
}, cap, nodeId);
|
|
7748
|
+
}
|
|
7749
|
+
/**
|
|
7750
|
+
* An operator write: the `exportTo:` / `mediaRole:` keys go to the integration of their broker
|
|
7751
|
+
* (D985), everything else to the addon store as before. A key whose broker has no integration
|
|
7752
|
+
* stays in the store.
|
|
7753
|
+
*/
|
|
7754
|
+
async updateGlobalSettingsAsOperator(patch, nodeId) {
|
|
7755
|
+
const ports = this.instancePorts();
|
|
7756
|
+
const { toInstances, rest } = splitInstanceKeys(patch, await ports.listInstances());
|
|
7757
|
+
for (const [integrationId, flags] of toInstances) {
|
|
7758
|
+
if (await ports.patchInstanceSettings(integrationId, flags)) continue;
|
|
7759
|
+
throw new Error(`ha-export: integration ${integrationId} is gone — the setting was not saved`);
|
|
7760
|
+
}
|
|
7761
|
+
if (Object.keys(rest).length > 0 || toInstances.size === 0) {
|
|
7762
|
+
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7763
|
+
return;
|
|
7764
|
+
}
|
|
7765
|
+
await this.onConfigChanged();
|
|
7766
|
+
}
|
|
7513
7767
|
async onConfigChanged() {
|
|
7514
7768
|
for (const link of this.links.values()) link.client.resetBackoff();
|
|
7515
7769
|
await this.reconcile("config-changed");
|
|
7516
7770
|
this.startTimer();
|
|
7517
7771
|
}
|
|
7518
7772
|
wireListeners() {
|
|
7519
|
-
this.subscribe({ category:
|
|
7773
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceStateChanged }, (event) => {
|
|
7520
7774
|
this.onSliceChanged(event.data);
|
|
7521
7775
|
});
|
|
7522
|
-
this.subscribe({ category:
|
|
7776
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceOnline }, (event) => {
|
|
7523
7777
|
this.onOnlineChanged(event.data, true);
|
|
7524
7778
|
});
|
|
7525
|
-
this.subscribe({ category:
|
|
7779
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceOffline }, (event) => {
|
|
7526
7780
|
this.onOnlineChanged(event.data, false);
|
|
7527
7781
|
});
|
|
7528
|
-
this.subscribe({ category:
|
|
7782
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.MotionOnMotionChanged }, (event) => {
|
|
7529
7783
|
this.onMotion(event.data);
|
|
7530
7784
|
});
|
|
7531
7785
|
/**
|
|
@@ -7534,13 +7788,13 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
7534
7788
|
* the entity existed and effectively never moved. It also carries the
|
|
7535
7789
|
* window LEVEL, which nothing else on the export path does.
|
|
7536
7790
|
*/
|
|
7537
|
-
this.subscribe({ category:
|
|
7791
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
7538
7792
|
this.onAudioWindow(event.data);
|
|
7539
7793
|
});
|
|
7540
|
-
this.subscribe({ category:
|
|
7794
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.PipelineAnalyticsTrackLifecycle }, (event) => {
|
|
7541
7795
|
this.onTrackLifecycle(event.data);
|
|
7542
7796
|
});
|
|
7543
|
-
this.subscribe({ category:
|
|
7797
|
+
this.subscribe({ category: require_ha_instance_settings.EventCategory.ZoneAnalyticsOccupancyChanged }, (event) => {
|
|
7544
7798
|
this.onZoneOccupancy(event.data);
|
|
7545
7799
|
});
|
|
7546
7800
|
/**
|
|
@@ -7549,11 +7803,11 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
7549
7803
|
* rather than a state push.
|
|
7550
7804
|
*/
|
|
7551
7805
|
for (const category of [
|
|
7552
|
-
|
|
7553
|
-
|
|
7554
|
-
|
|
7555
|
-
|
|
7556
|
-
|
|
7806
|
+
require_ha_instance_settings.EventCategory.DeviceRegistered,
|
|
7807
|
+
require_ha_instance_settings.EventCategory.DeviceUnregistered,
|
|
7808
|
+
require_ha_instance_settings.EventCategory.DeviceBindingsChanged,
|
|
7809
|
+
require_ha_instance_settings.EventCategory.DeviceMetaChanged,
|
|
7810
|
+
require_ha_instance_settings.EventCategory.BrokerStatusChanged
|
|
7557
7811
|
]) this.subscribe({ category }, () => {
|
|
7558
7812
|
this.reconcileSoon();
|
|
7559
7813
|
});
|
|
@@ -8665,7 +8919,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
8665
8919
|
*/
|
|
8666
8920
|
capSlices: snapshot,
|
|
8667
8921
|
itemEntities: items.entities,
|
|
8668
|
-
...
|
|
8922
|
+
...require_ha_instance_settings.isReadingQuantity(readingQuantity) ? { readingQuantity } : {}
|
|
8669
8923
|
},
|
|
8670
8924
|
heldBack
|
|
8671
8925
|
};
|
|
@@ -8899,7 +9153,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
8899
9153
|
maskEnabled: privacySlice["enabled"] === true,
|
|
8900
9154
|
audioEnabled: typeof privacySlice["audioEnabled"] === "boolean" ? privacySlice["audioEnabled"] : null
|
|
8901
9155
|
};
|
|
8902
|
-
const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId },
|
|
9156
|
+
const brokerAudioMuted = sourceNodeId === null ? null : await this.ctx.api.streamBroker.getDeviceAudioMute.query({ deviceId }, require_ha_instance_settings.nodePin(sourceNodeId)).then((r) => r.muted).catch((err) => {
|
|
8903
9157
|
this.ctx.logger.warn("ha-export: could not read broker audio mute, the broker-audio function degrades", {
|
|
8904
9158
|
tags: { deviceId },
|
|
8905
9159
|
meta: {
|
|
@@ -8950,7 +9204,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
8950
9204
|
* reasoning.
|
|
8951
9205
|
*/
|
|
8952
9206
|
async refreshBrokers() {
|
|
8953
|
-
const haBrokers = await
|
|
9207
|
+
const haBrokers = await require_ha_instance_settings.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
|
|
8954
9208
|
const known = haBrokers.map((broker) => ({
|
|
8955
9209
|
id: broker.id,
|
|
8956
9210
|
name: broker.name
|
|
@@ -9149,7 +9403,20 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
9149
9403
|
enabled = [];
|
|
9150
9404
|
async refreshEnabledBrokers(known) {
|
|
9151
9405
|
const store = await this.resolveGlobalStore();
|
|
9152
|
-
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id));
|
|
9406
|
+
this.enabled = enabledBrokerIds(store, known.map((broker) => broker.id), await this.readInstanceSettings());
|
|
9407
|
+
}
|
|
9408
|
+
/** The hub's `integrations` capability, as the one reader and writer of an instance's settings. */
|
|
9409
|
+
instancePorts() {
|
|
9410
|
+
return require_ha_instance_settings.haInstanceSettingsPortsOver(this.ctx.api.integrations);
|
|
9411
|
+
}
|
|
9412
|
+
/**
|
|
9413
|
+
* Each broker's integration settings (D985): the switch and the media role of an instance.
|
|
9414
|
+
* A read that fails answers nothing, so every broker falls back to its legacy key and the
|
|
9415
|
+
* failure is logged — never read as "everything off".
|
|
9416
|
+
*/
|
|
9417
|
+
async readInstanceSettings() {
|
|
9418
|
+
const rows = await require_ha_instance_settings.listInstancesOrNothing(this.instancePorts(), (error) => this.ctx.logger.warn("ha-export: the instance settings could not be read — the legacy per-broker keys answer", { meta: { error } }));
|
|
9419
|
+
return rows.length === 0 ? NO_INSTANCE_SETTINGS : require_ha_instance_settings.settingsByBroker(rows);
|
|
9153
9420
|
}
|
|
9154
9421
|
enabledBrokerIds() {
|
|
9155
9422
|
return this.enabled;
|
|
@@ -9310,7 +9577,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
9310
9577
|
}
|
|
9311
9578
|
switch (command.kind) {
|
|
9312
9579
|
case "camera-function": {
|
|
9313
|
-
const parsed =
|
|
9580
|
+
const parsed = require_ha_instance_settings.CameraSwitchIdSchema.safeParse(command.switchId);
|
|
9314
9581
|
if (!parsed.success) {
|
|
9315
9582
|
this.ctx.logger.warn("ha-export: dropping a command for an unknown camera function", {
|
|
9316
9583
|
tags: { deviceId: command.deviceId },
|
|
@@ -9550,7 +9817,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
9550
9817
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
9551
9818
|
let bases;
|
|
9552
9819
|
try {
|
|
9553
|
-
bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query());
|
|
9820
|
+
bases = mediaBasesByBroker(await this.resolveGlobalStore(), brokerIds, await this.ctx.api.localNetwork.getAddresses.query(), await this.readInstanceSettings());
|
|
9554
9821
|
} catch (err) {
|
|
9555
9822
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
9556
9823
|
tags: { deviceId },
|
|
@@ -9568,20 +9835,44 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
9568
9835
|
return bases;
|
|
9569
9836
|
}
|
|
9570
9837
|
/**
|
|
9571
|
-
*
|
|
9572
|
-
*
|
|
9573
|
-
*
|
|
9574
|
-
* and has no notion of a broker; per-broker granularity lives in the
|
|
9575
|
-
* derived Export panel, over the same store. There is exactly one
|
|
9576
|
-
* authority either way.
|
|
9838
|
+
* Every broker this addon knows, enabled or not: a membership write may
|
|
9839
|
+
* target a broker that is switched off (its devices resume when it is turned
|
|
9840
|
+
* back on). A cold `knownBrokers` still counts the enabled ids.
|
|
9577
9841
|
*/
|
|
9578
|
-
|
|
9579
|
-
|
|
9580
|
-
|
|
9842
|
+
knownBrokerIds() {
|
|
9843
|
+
return new Set([...this.config.knownBrokers.map((b) => b.id), ...this.enabledBrokerIds()]);
|
|
9844
|
+
}
|
|
9845
|
+
/**
|
|
9846
|
+
* The brokers a `device-export` read answers over: the named instance, or
|
|
9847
|
+
* (absent) every enabled broker. A broker this addon does not know is
|
|
9848
|
+
* refused, never answered empty.
|
|
9849
|
+
*/
|
|
9850
|
+
brokerScope(instanceId) {
|
|
9851
|
+
if (instanceId === void 0) return this.enabledBrokerIds();
|
|
9852
|
+
if (!this.knownBrokerIds().has(instanceId)) throw new require_ha_instance_settings.ExportInstanceUnknownError("provider-homeassistant", instanceId);
|
|
9853
|
+
return [instanceId];
|
|
9854
|
+
}
|
|
9855
|
+
exposedDeviceRows(scope) {
|
|
9856
|
+
return exposedDeviceIds(this.config.membership, scope).map((deviceId) => ({
|
|
9857
|
+
deviceId,
|
|
9858
|
+
exposedAs: brokersExposing(this.config.membership, deviceId).map((brokerId) => this.brokerName(brokerId)).join(", ")
|
|
9859
|
+
}));
|
|
9860
|
+
}
|
|
9861
|
+
/**
|
|
9862
|
+
* A cap-level expose/unexpose changes ONE broker's membership — the named
|
|
9863
|
+
* instance. There is no write without a target and no "everywhere": the
|
|
9864
|
+
* other brokers' membership is not touched.
|
|
9865
|
+
*/
|
|
9866
|
+
async setBrokerMembership(instanceId, deviceId, exposed) {
|
|
9867
|
+
this.brokerScope(instanceId);
|
|
9868
|
+
const next = setExposed(this.config.membership, instanceId, deviceId, exposed);
|
|
9581
9869
|
if (next === this.config.membership) {
|
|
9582
9870
|
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
9583
9871
|
tags: { deviceId: Number(deviceId) },
|
|
9584
|
-
meta: {
|
|
9872
|
+
meta: {
|
|
9873
|
+
exposed,
|
|
9874
|
+
brokerId: instanceId
|
|
9875
|
+
}
|
|
9585
9876
|
});
|
|
9586
9877
|
return;
|
|
9587
9878
|
}
|
|
@@ -9590,7 +9881,7 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
9590
9881
|
tags: { deviceId: Number(deviceId) },
|
|
9591
9882
|
meta: {
|
|
9592
9883
|
exposed,
|
|
9593
|
-
|
|
9884
|
+
brokerId: instanceId
|
|
9594
9885
|
}
|
|
9595
9886
|
});
|
|
9596
9887
|
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
@@ -9744,19 +10035,20 @@ var HaExportAddon = class extends require_ha_broker_source.BaseAddon {
|
|
|
9744
10035
|
await this.reconcile("device-panel");
|
|
9745
10036
|
return { success: true };
|
|
9746
10037
|
}
|
|
9747
|
-
buildStatus() {
|
|
9748
|
-
const enabled =
|
|
9749
|
-
const
|
|
9750
|
-
const
|
|
10038
|
+
buildStatus(scope) {
|
|
10039
|
+
const enabled = scope;
|
|
10040
|
+
const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
|
|
10041
|
+
const healthy = links.filter((link) => link.client.healthy).length;
|
|
10042
|
+
const linkFailure = links.map((link) => link.client.failure).filter((failure) => failure !== null)[0]?.text;
|
|
9751
10043
|
return {
|
|
9752
|
-
linkState: enabled.length === 0 ? "unlinked" : healthy ===
|
|
10044
|
+
linkState: enabled.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
|
|
9753
10045
|
exposedDeviceCount: exposedDeviceIds(this.config.membership, enabled).length,
|
|
9754
10046
|
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
9755
10047
|
setup: {
|
|
9756
10048
|
fields: [
|
|
9757
10049
|
{
|
|
9758
10050
|
label: "Home Assistant instances",
|
|
9759
|
-
value: `${healthy} / ${
|
|
10051
|
+
value: `${healthy} / ${links.length} reachable`
|
|
9760
10052
|
},
|
|
9761
10053
|
{
|
|
9762
10054
|
label: "Last push failure",
|