@camstack/addon-provider-homeassistant 1.2.657 → 1.2.659
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 +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/ha-export/ha-export.addon.js +610 -382
- package/dist/ha-export/ha-export.addon.mjs +613 -374
- package/dist/{ha-instance-settings-DJXP_6Rg.js → ha-instance-settings-CRvQ5rH6.js} +15 -1
- package/dist/{ha-instance-settings-CxalgfRa.mjs → ha-instance-settings-qhCNJbbn.mjs} +15 -1
- package/i18n/en.json +18 -4
- package/i18n/it.json +18 -4
- package/package.json +5 -2
|
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
enumerable: true
|
|
25
25
|
}) : target, mod));
|
|
26
26
|
//#endregion
|
|
27
|
-
const require_ha_instance_settings = require("../ha-instance-settings-
|
|
27
|
+
const require_ha_instance_settings = require("../ha-instance-settings-CRvQ5rH6.js");
|
|
28
28
|
let node_crypto = require("node:crypto");
|
|
29
29
|
require("node:zlib");
|
|
30
30
|
let node_fs = require("node:fs");
|
|
@@ -5090,13 +5090,21 @@ function verifyExpiringUrl(input) {
|
|
|
5090
5090
|
/** The hub address roles a Home Assistant media link may be minted on. */
|
|
5091
5091
|
var HA_MEDIA_ROLES = ["internal", "publicEndpoint"];
|
|
5092
5092
|
/**
|
|
5093
|
-
* The base a media link is minted on: the hub's role (D734)
|
|
5094
|
-
*
|
|
5095
|
-
* when
|
|
5096
|
-
*
|
|
5093
|
+
* The base a media link is minted on: the hub's role (D734) the broker chose. Links reach
|
|
5094
|
+
* Home Assistant notifications on phones away from home, so a new broker chooses `publicEndpoint`;
|
|
5095
|
+
* when that resolves to nothing the `internal` address answers instead (a LAN Home Assistant still
|
|
5096
|
+
* gets an image). A broker that chose `internal` never falls back to public. Ranked by
|
|
5097
|
+
* `local-network`, never loopback (`null` when nothing resolves): a link Home Assistant cannot
|
|
5098
|
+
* open is worse than no image.
|
|
5097
5099
|
*/
|
|
5098
|
-
function haMediaBaseUrl(book, role = "
|
|
5099
|
-
|
|
5100
|
+
function haMediaBaseUrl(book, role = "publicEndpoint") {
|
|
5101
|
+
const chosen = book.roles[role].resolved;
|
|
5102
|
+
if (chosen !== null || role !== "publicEndpoint") return chosen;
|
|
5103
|
+
return book.roles.internal.resolved;
|
|
5104
|
+
}
|
|
5105
|
+
/** True when `haMediaBaseUrl` answered with the internal address because the public endpoint was chosen but unset. */
|
|
5106
|
+
function usedInternalFallback(book, role) {
|
|
5107
|
+
return role === "publicEndpoint" && book.roles.publicEndpoint.resolved === null && book.roles.internal.resolved !== null;
|
|
5100
5108
|
}
|
|
5101
5109
|
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5102
5110
|
function mediaBasesByBroker(brokerIds, book, roleOf) {
|
|
@@ -5231,6 +5239,246 @@ async function planDropPerBrokerFlags(ports) {
|
|
|
5231
5239
|
}] };
|
|
5232
5240
|
}
|
|
5233
5241
|
//#endregion
|
|
5242
|
+
//#region src/ha-export/target-records.ts
|
|
5243
|
+
/**
|
|
5244
|
+
* An export target (#422): one `ha-export-targets` record per place the export reaches. It names
|
|
5245
|
+
* the Home Assistant connection it pushes to (`connectionId`, a broker id), which hub address
|
|
5246
|
+
* role its image links use, how often it re-announces, and the devices exported to it.
|
|
5247
|
+
*
|
|
5248
|
+
* The record is the integration instance itself (`integration-instances`, role `services`): the
|
|
5249
|
+
* operator creates, edits, switches and deletes targets from the Connectivity page, and the ids
|
|
5250
|
+
* are minted here (`hae_001`, ...). Several targets may reference one connection.
|
|
5251
|
+
*
|
|
5252
|
+
* This addon is the only writer of `devices`; the instance door writes the rest. Both go through
|
|
5253
|
+
* `patch` (a merge), so neither overwrites the other, and the in-memory copy here is the
|
|
5254
|
+
* synchronous read the event path needs, reloaded after every change.
|
|
5255
|
+
*/
|
|
5256
|
+
var HA_EXPORT_TARGETS_KIND_NAME = "ha-export-targets";
|
|
5257
|
+
var HA_TARGET_KIND = "ha-target";
|
|
5258
|
+
var TARGET_ID_PREFIX = "hae_";
|
|
5259
|
+
/** The schema is checked against the interface by its annotation: a drifted field fails the typecheck. */
|
|
5260
|
+
var HaExportTargetBodySchema = require_ha_instance_settings.object({
|
|
5261
|
+
name: require_ha_instance_settings.string().min(1),
|
|
5262
|
+
connectionId: require_ha_instance_settings.string(),
|
|
5263
|
+
mediaRole: require_ha_instance_settings._enum(HA_MEDIA_ROLES),
|
|
5264
|
+
reconcileIntervalSec: require_ha_instance_settings.number(),
|
|
5265
|
+
devices: require_ha_instance_settings.array(require_ha_instance_settings.string()),
|
|
5266
|
+
enabled: require_ha_instance_settings.boolean().optional()
|
|
5267
|
+
});
|
|
5268
|
+
var haExportTargetsKind = require_ha_instance_settings.defineEntryKind(HA_EXPORT_TARGETS_KIND_NAME, {
|
|
5269
|
+
title: "Home Assistant export targets",
|
|
5270
|
+
nameField: "name",
|
|
5271
|
+
schema: HaExportTargetBodySchema,
|
|
5272
|
+
writeTrigger: "an operator adds, edits, switches or deletes an export target, or exports a device to it"
|
|
5273
|
+
});
|
|
5274
|
+
/** What a target starts as: on, public endpoint, five-minute cadence, no device, no connection yet. */
|
|
5275
|
+
function newTargetBody(name) {
|
|
5276
|
+
return {
|
|
5277
|
+
name,
|
|
5278
|
+
connectionId: "",
|
|
5279
|
+
mediaRole: "publicEndpoint",
|
|
5280
|
+
reconcileIntervalSec: 300,
|
|
5281
|
+
devices: [],
|
|
5282
|
+
enabled: true
|
|
5283
|
+
};
|
|
5284
|
+
}
|
|
5285
|
+
/** The id after the highest `hae_NNN` among `existing`. */
|
|
5286
|
+
function nextTargetId(existing) {
|
|
5287
|
+
let highest = 0;
|
|
5288
|
+
for (const id of existing) {
|
|
5289
|
+
if (!id.startsWith("hae_")) continue;
|
|
5290
|
+
const n = Number(id.slice(4));
|
|
5291
|
+
if (Number.isInteger(n) && n > highest) highest = n;
|
|
5292
|
+
}
|
|
5293
|
+
return `${TARGET_ID_PREFIX}${String(highest + 1).padStart(3, "0")}`;
|
|
5294
|
+
}
|
|
5295
|
+
/** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
|
|
5296
|
+
async function readAllTargets(handle) {
|
|
5297
|
+
const rows = /* @__PURE__ */ new Map();
|
|
5298
|
+
let cursor;
|
|
5299
|
+
do {
|
|
5300
|
+
const page = await handle.query({
|
|
5301
|
+
limit: 1e3,
|
|
5302
|
+
...cursor === void 0 ? {} : { cursor }
|
|
5303
|
+
});
|
|
5304
|
+
for (const row of page.records) rows.set(row.id, row.data);
|
|
5305
|
+
cursor = page.nextCursor ?? void 0;
|
|
5306
|
+
} while (cursor !== void 0);
|
|
5307
|
+
return rows;
|
|
5308
|
+
}
|
|
5309
|
+
function membershipOf$1(rows) {
|
|
5310
|
+
const out = {};
|
|
5311
|
+
for (const [id, row] of rows) if (row.devices.length > 0) out[id] = row.devices;
|
|
5312
|
+
return out;
|
|
5313
|
+
}
|
|
5314
|
+
function sameList(a, b) {
|
|
5315
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
5316
|
+
}
|
|
5317
|
+
/**
|
|
5318
|
+
* The in-memory copy of the target records. `membership()` keeps its identity until a device
|
|
5319
|
+
* list changes.
|
|
5320
|
+
*/
|
|
5321
|
+
var ExportTargets = class ExportTargets {
|
|
5322
|
+
handle;
|
|
5323
|
+
rows;
|
|
5324
|
+
memberships;
|
|
5325
|
+
constructor(handle, rows) {
|
|
5326
|
+
this.handle = handle;
|
|
5327
|
+
this.rows = rows;
|
|
5328
|
+
this.memberships = membershipOf$1(rows);
|
|
5329
|
+
}
|
|
5330
|
+
/** A failed read throws: serving an empty set would unexport every device on the next pass. */
|
|
5331
|
+
static async load(handle) {
|
|
5332
|
+
return new ExportTargets(handle, await readAllTargets(handle));
|
|
5333
|
+
}
|
|
5334
|
+
/** Re-read the records after the instance door changed one; a failed read keeps the old copy. */
|
|
5335
|
+
async reload() {
|
|
5336
|
+
this.adopt(await readAllTargets(this.handle));
|
|
5337
|
+
}
|
|
5338
|
+
ids() {
|
|
5339
|
+
return [...this.rows.keys()];
|
|
5340
|
+
}
|
|
5341
|
+
has(id) {
|
|
5342
|
+
return this.rows.has(id);
|
|
5343
|
+
}
|
|
5344
|
+
get(id) {
|
|
5345
|
+
return this.rows.get(id);
|
|
5346
|
+
}
|
|
5347
|
+
name(id) {
|
|
5348
|
+
return this.rows.get(id)?.name ?? id;
|
|
5349
|
+
}
|
|
5350
|
+
/** The target's own switch; an absent field means on. */
|
|
5351
|
+
isEnabled(id) {
|
|
5352
|
+
const row = this.rows.get(id);
|
|
5353
|
+
return row !== void 0 && row.enabled !== false;
|
|
5354
|
+
}
|
|
5355
|
+
connectionId(id) {
|
|
5356
|
+
return this.rows.get(id)?.connectionId ?? "";
|
|
5357
|
+
}
|
|
5358
|
+
mediaRole(id) {
|
|
5359
|
+
return this.rows.get(id)?.mediaRole ?? "publicEndpoint";
|
|
5360
|
+
}
|
|
5361
|
+
/** The cadence of the quickest enabled target (the addon runs one timer); the default when none. */
|
|
5362
|
+
reconcileIntervalSec() {
|
|
5363
|
+
const seconds = [...this.rows.values()].filter((row) => row.enabled !== false).map((row) => row.reconcileIntervalSec);
|
|
5364
|
+
if (seconds.length === 0) return 300;
|
|
5365
|
+
return Math.max(30, Math.min(...seconds));
|
|
5366
|
+
}
|
|
5367
|
+
/** targetId → exported device ids, for the targets that export at least one. */
|
|
5368
|
+
membership() {
|
|
5369
|
+
return this.memberships;
|
|
5370
|
+
}
|
|
5371
|
+
/**
|
|
5372
|
+
* Persist the device lists that differ from the held ones. A target that does not exist is
|
|
5373
|
+
* never created here (an id the records do not hold is refused by the caller).
|
|
5374
|
+
*/
|
|
5375
|
+
async setMembership(next) {
|
|
5376
|
+
let rows = this.rows;
|
|
5377
|
+
for (const id of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
|
|
5378
|
+
const current = rows.get(id);
|
|
5379
|
+
if (current === void 0) continue;
|
|
5380
|
+
const devices = next[id] ?? [];
|
|
5381
|
+
if (sameList(current.devices, devices)) continue;
|
|
5382
|
+
await this.handle.patch(id, { devices: [...devices] });
|
|
5383
|
+
rows = new Map(rows).set(id, {
|
|
5384
|
+
...current,
|
|
5385
|
+
devices: [...devices]
|
|
5386
|
+
});
|
|
5387
|
+
this.adopt(rows);
|
|
5388
|
+
}
|
|
5389
|
+
}
|
|
5390
|
+
adopt(rows) {
|
|
5391
|
+
const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
|
|
5392
|
+
this.rows = rows;
|
|
5393
|
+
if (devicesChanged) this.memberships = membershipOf$1(rows);
|
|
5394
|
+
}
|
|
5395
|
+
};
|
|
5396
|
+
//#endregion
|
|
5397
|
+
//#region src/ha-export/migrations/targets-from-links.ts
|
|
5398
|
+
var RECONCILE_INTERVAL_KEY = "reconcileIntervalSec";
|
|
5399
|
+
var KNOWN_BROKERS_KEY = "knownBrokers";
|
|
5400
|
+
var sameDevices$1 = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
|
|
5401
|
+
function intervalOf(stored) {
|
|
5402
|
+
const value = stored[RECONCILE_INTERVAL_KEY];
|
|
5403
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 300;
|
|
5404
|
+
}
|
|
5405
|
+
function matches(target, expected) {
|
|
5406
|
+
return target.name === expected.name && target.connectionId === expected.connectionId && target.mediaRole === expected.mediaRole && target.reconcileIntervalSec === expected.reconcileIntervalSec && target.enabled === expected.enabled && sameDevices$1(target.devices, expected.devices);
|
|
5407
|
+
}
|
|
5408
|
+
async function moveLinksToTargets(ports) {
|
|
5409
|
+
const links = await ports.readLinks();
|
|
5410
|
+
if (links.size === 0) return {
|
|
5411
|
+
outcome: "already-done",
|
|
5412
|
+
summary: { moved: 0 }
|
|
5413
|
+
};
|
|
5414
|
+
const targets = await ports.readTargets();
|
|
5415
|
+
const reconcileIntervalSec = intervalOf(await ports.readStored());
|
|
5416
|
+
const taken = new Set([...targets.values()].map((target) => target.connectionId));
|
|
5417
|
+
const ids = new Set(targets.keys());
|
|
5418
|
+
const created = /* @__PURE__ */ new Map();
|
|
5419
|
+
for (const brokerId of [...links.keys()].sort()) {
|
|
5420
|
+
if (taken.has(brokerId)) continue;
|
|
5421
|
+
const link = links.get(brokerId);
|
|
5422
|
+
if (link === void 0) continue;
|
|
5423
|
+
const body = {
|
|
5424
|
+
name: link.name,
|
|
5425
|
+
connectionId: brokerId,
|
|
5426
|
+
mediaRole: link.mediaRole,
|
|
5427
|
+
reconcileIntervalSec,
|
|
5428
|
+
devices: [...link.devices],
|
|
5429
|
+
enabled: link.exportTo
|
|
5430
|
+
};
|
|
5431
|
+
const id = nextTargetId(ids);
|
|
5432
|
+
if (await ports.putTarget(id, body) === "exists") throw new Error(`the export target id ${id} is already taken; nothing was cleared`);
|
|
5433
|
+
ids.add(id);
|
|
5434
|
+
created.set(id, body);
|
|
5435
|
+
}
|
|
5436
|
+
if (created.size === 0) return {
|
|
5437
|
+
outcome: "already-done",
|
|
5438
|
+
summary: { moved: 0 }
|
|
5439
|
+
};
|
|
5440
|
+
const held = await ports.readTargets();
|
|
5441
|
+
for (const [id, expected] of created) {
|
|
5442
|
+
const row = held.get(id);
|
|
5443
|
+
if (row === void 0 || !matches(row, expected)) throw new Error(`the export target ${id} of connection ${expected.connectionId} does not match its link record; nothing was cleared`);
|
|
5444
|
+
}
|
|
5445
|
+
return {
|
|
5446
|
+
outcome: "done",
|
|
5447
|
+
summary: {
|
|
5448
|
+
moved: created.size,
|
|
5449
|
+
devices: [...created.values()].reduce((sum, target) => sum + target.devices.length, 0)
|
|
5450
|
+
}
|
|
5451
|
+
};
|
|
5452
|
+
}
|
|
5453
|
+
async function dropLinksAndCache(ports) {
|
|
5454
|
+
const links = await ports.readLinks();
|
|
5455
|
+
const stored = await ports.readStored();
|
|
5456
|
+
const targets = await ports.readTargets();
|
|
5457
|
+
const movable = [];
|
|
5458
|
+
const parked = [];
|
|
5459
|
+
for (const [brokerId, link] of links) {
|
|
5460
|
+
const target = [...targets.values()].find((candidate) => candidate.connectionId === brokerId);
|
|
5461
|
+
if (target !== void 0 && sameDevices$1(target.devices, link.devices)) movable.push(brokerId);
|
|
5462
|
+
else parked.push(brokerId);
|
|
5463
|
+
}
|
|
5464
|
+
if (parked.length > 0) ports.warn(`homeassistant-export: the link records of ${parked.join(", ")} are kept: no export target holds the same devices`);
|
|
5465
|
+
if (movable.length > 0) await ports.deleteLinks(movable);
|
|
5466
|
+
const keys = parked.length > 0 ? [] : [RECONCILE_INTERVAL_KEY, KNOWN_BROKERS_KEY].filter((key) => key in stored);
|
|
5467
|
+
if (keys.length > 0) await ports.clearSettingKeys(keys);
|
|
5468
|
+
if (movable.length === 0 && keys.length === 0) return {
|
|
5469
|
+
outcome: "already-done",
|
|
5470
|
+
summary: { parked: parked.length }
|
|
5471
|
+
};
|
|
5472
|
+
return {
|
|
5473
|
+
outcome: "done",
|
|
5474
|
+
summary: {
|
|
5475
|
+
deleted: movable.length,
|
|
5476
|
+
parked: parked.length,
|
|
5477
|
+
keys: keys.length
|
|
5478
|
+
}
|
|
5479
|
+
};
|
|
5480
|
+
}
|
|
5481
|
+
//#endregion
|
|
5234
5482
|
//#region src/ha-export/migrations/links-to-record.ts
|
|
5235
5483
|
/**
|
|
5236
5484
|
* What the export carries to a Home Assistant instance moves into the instance's
|
|
@@ -5255,7 +5503,7 @@ var MEMBERSHIP_KEY = "membership";
|
|
|
5255
5503
|
var EXPORT_PREFIX = "exportTo:";
|
|
5256
5504
|
var ROLE_PREFIX = "mediaRole:";
|
|
5257
5505
|
var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
|
|
5258
|
-
function membershipOf
|
|
5506
|
+
function membershipOf(value) {
|
|
5259
5507
|
const out = /* @__PURE__ */ new Map();
|
|
5260
5508
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
|
|
5261
5509
|
for (const [brokerId, devices] of Object.entries(value)) {
|
|
@@ -5278,7 +5526,9 @@ function integrationByBroker(rows, warn) {
|
|
|
5278
5526
|
}
|
|
5279
5527
|
/** Every broker the old shape has something for, with the value the old reader would have used. */
|
|
5280
5528
|
function legacyLinks(stored, rows, warn) {
|
|
5281
|
-
const membership = membershipOf
|
|
5529
|
+
const membership = membershipOf(stored[MEMBERSHIP_KEY]);
|
|
5530
|
+
const unreadable = rows.find((row) => Object.keys(row.settings).length === 0);
|
|
5531
|
+
if (unreadable !== void 0 && membership.size > 0) throw new Error(`the settings of integration ${unreadable.integrationId} read as empty; the export switch cannot be carried from an unreadable source, nothing was written`);
|
|
5282
5532
|
const integrations = integrationByBroker(rows, warn);
|
|
5283
5533
|
const brokers = new Set(membership.keys());
|
|
5284
5534
|
for (const key of Object.keys(stored)) for (const prefix of [EXPORT_PREFIX, ROLE_PREFIX]) if (key.startsWith(prefix) && key.length > prefix.length) brokers.add(key.slice(prefix.length));
|
|
@@ -5364,6 +5614,8 @@ async function planDropMovedLinks(ports) {
|
|
|
5364
5614
|
//#endregion
|
|
5365
5615
|
//#region src/ha-export/migrations/index.ts
|
|
5366
5616
|
var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
|
|
5617
|
+
var DROP_MOVED_LINKS_ID = "homeassistant-export/2026-11-04-drop-moved-links";
|
|
5618
|
+
var TARGETS_FROM_LINKS_ID = "homeassistant-export/2026-11-04-targets-from-links";
|
|
5367
5619
|
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
5368
5620
|
var ADDON_MIGRATIONS = [
|
|
5369
5621
|
{
|
|
@@ -5432,7 +5684,7 @@ var ADDON_MIGRATIONS = [
|
|
|
5432
5684
|
},
|
|
5433
5685
|
{
|
|
5434
5686
|
meta: {
|
|
5435
|
-
id:
|
|
5687
|
+
id: DROP_MOVED_LINKS_ID,
|
|
5436
5688
|
owner: "homeassistant-export",
|
|
5437
5689
|
level: "addon-state",
|
|
5438
5690
|
seq: 5,
|
|
@@ -5445,19 +5697,47 @@ var ADDON_MIGRATIONS = [
|
|
|
5445
5697
|
fixtureMark: "the reconcile interval the migration does not know"
|
|
5446
5698
|
},
|
|
5447
5699
|
plan: planDropMovedLinks
|
|
5700
|
+
},
|
|
5701
|
+
{
|
|
5702
|
+
meta: {
|
|
5703
|
+
id: TARGETS_FROM_LINKS_ID,
|
|
5704
|
+
owner: "homeassistant-export",
|
|
5705
|
+
level: "addon-state",
|
|
5706
|
+
seq: 6,
|
|
5707
|
+
dependsOn: [LINKS_TO_RECORD_ID],
|
|
5708
|
+
store: "settings",
|
|
5709
|
+
scope: "cluster",
|
|
5710
|
+
onFailure: "block-owner",
|
|
5711
|
+
bodyKind: "procedure",
|
|
5712
|
+
description: "Turn each ha-export-links record into an export target under a minted id that names its Home Assistant connection, carrying the switch, media role, devices and reconcile interval; read back before anything is deleted",
|
|
5713
|
+
fixtureMark: "the exported entities the migration does not touch"
|
|
5714
|
+
},
|
|
5715
|
+
procedure: moveLinksToTargets
|
|
5716
|
+
},
|
|
5717
|
+
{
|
|
5718
|
+
meta: {
|
|
5719
|
+
id: "homeassistant-export/2026-11-04-drop-links-and-cache",
|
|
5720
|
+
owner: "homeassistant-export",
|
|
5721
|
+
level: "addon-state",
|
|
5722
|
+
seq: 7,
|
|
5723
|
+
dependsOn: [TARGETS_FROM_LINKS_ID],
|
|
5724
|
+
store: "settings",
|
|
5725
|
+
scope: "cluster",
|
|
5726
|
+
onFailure: "block-owner",
|
|
5727
|
+
bodyKind: "procedure",
|
|
5728
|
+
description: "Delete each ha-export-links record whose target holds the same devices, then the reconcileIntervalSec and knownBrokers settings",
|
|
5729
|
+
fixtureMark: "the exported entities the migration does not touch"
|
|
5730
|
+
},
|
|
5731
|
+
procedure: dropLinksAndCache
|
|
5448
5732
|
}
|
|
5449
5733
|
];
|
|
5450
5734
|
//#endregion
|
|
5451
|
-
//#region src/ha-export/
|
|
5735
|
+
//#region src/ha-export/migrations/legacy-links-kind.ts
|
|
5452
5736
|
/**
|
|
5453
|
-
*
|
|
5454
|
-
*
|
|
5455
|
-
*
|
|
5456
|
-
*
|
|
5457
|
-
*
|
|
5458
|
-
* It replaces the `membership` setting, the `exportTo:<brokerId>` / `mediaRole:<brokerId>` keys
|
|
5459
|
-
* and the `exportTo` / `mediaRole` values of the instance's integration. The migration moved
|
|
5460
|
-
* them; the addon reads this record and nothing else.
|
|
5737
|
+
* The `ha-export-links` record kind (planning#408 P4): one record per Home Assistant broker with
|
|
5738
|
+
* its export switch, media role and devices. Export targets (`ha-export-targets`) replaced it;
|
|
5739
|
+
* the kind is declared ONLY so the migrations can read it and delete its rows. No runtime code
|
|
5740
|
+
* outside `migrations/` names it (`no-legacy-links-reader.spec.ts`).
|
|
5461
5741
|
*/
|
|
5462
5742
|
var HA_EXPORT_LINKS_KIND_NAME = "ha-export-links";
|
|
5463
5743
|
var HaExportLinksBodySchema = require_ha_instance_settings.object({
|
|
@@ -5467,19 +5747,15 @@ var HaExportLinksBodySchema = require_ha_instance_settings.object({
|
|
|
5467
5747
|
devices: require_ha_instance_settings.array(require_ha_instance_settings.string())
|
|
5468
5748
|
});
|
|
5469
5749
|
var haExportLinksKind = require_ha_instance_settings.defineEntryKind(HA_EXPORT_LINKS_KIND_NAME, {
|
|
5470
|
-
title: "Home Assistant export links",
|
|
5750
|
+
title: "Home Assistant export links (retired)",
|
|
5471
5751
|
nameField: "name",
|
|
5472
5752
|
schema: HaExportLinksBodySchema,
|
|
5473
|
-
writeTrigger: "
|
|
5753
|
+
writeTrigger: "only the migrations that move the links into export targets write it"
|
|
5474
5754
|
});
|
|
5475
|
-
/**
|
|
5476
|
-
function
|
|
5477
|
-
|
|
5478
|
-
|
|
5479
|
-
exportTo: false,
|
|
5480
|
-
mediaRole: "internal",
|
|
5481
|
-
devices: []
|
|
5482
|
-
};
|
|
5755
|
+
/** Declare the retired kind and hand back its handle, for the migration ports. */
|
|
5756
|
+
async function declareLegacyLinks(records) {
|
|
5757
|
+
await records.declare({ [HA_EXPORT_LINKS_KIND_NAME]: haExportLinksKind });
|
|
5758
|
+
return records.kind(haExportLinksKind);
|
|
5483
5759
|
}
|
|
5484
5760
|
/** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
|
|
5485
5761
|
async function readAllLinks(handle) {
|
|
@@ -5495,123 +5771,12 @@ async function readAllLinks(handle) {
|
|
|
5495
5771
|
} while (cursor !== void 0);
|
|
5496
5772
|
return rows;
|
|
5497
5773
|
}
|
|
5498
|
-
function membershipOf(rows) {
|
|
5499
|
-
const out = {};
|
|
5500
|
-
for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
|
|
5501
|
-
return out;
|
|
5502
|
-
}
|
|
5503
|
-
/**
|
|
5504
|
-
* The in-memory copy of the records. This addon is their only writer, so the copy is the
|
|
5505
|
-
* synchronous read the event path needs; a write lands in the record first and in the copy
|
|
5506
|
-
* after it. `membership()` keeps its identity until a device list changes.
|
|
5507
|
-
*/
|
|
5508
|
-
var ExportLinks = class ExportLinks {
|
|
5509
|
-
handle;
|
|
5510
|
-
rows;
|
|
5511
|
-
memberships;
|
|
5512
|
-
constructor(handle, rows) {
|
|
5513
|
-
this.handle = handle;
|
|
5514
|
-
this.rows = rows;
|
|
5515
|
-
this.memberships = membershipOf(rows);
|
|
5516
|
-
}
|
|
5517
|
-
static async load(handle) {
|
|
5518
|
-
return new ExportLinks(handle, await readAllLinks(handle));
|
|
5519
|
-
}
|
|
5520
|
-
isEnabled(brokerId) {
|
|
5521
|
-
return this.rows.get(brokerId)?.exportTo === true;
|
|
5522
|
-
}
|
|
5523
|
-
mediaRole(brokerId) {
|
|
5524
|
-
return this.rows.get(brokerId)?.mediaRole ?? "internal";
|
|
5525
|
-
}
|
|
5526
|
-
/** brokerId → exported device ids, for the brokers that export at least one. */
|
|
5527
|
-
membership() {
|
|
5528
|
-
return this.memberships;
|
|
5529
|
-
}
|
|
5530
|
-
brokerIds() {
|
|
5531
|
-
return [...this.rows.keys()];
|
|
5532
|
-
}
|
|
5533
|
-
/** Change the switch and / or the role of one instance; creates the record on first use. */
|
|
5534
|
-
async setFlags(brokerId, name, patch) {
|
|
5535
|
-
const current = this.rows.get(brokerId) ?? defaultLinks(name);
|
|
5536
|
-
const next = {
|
|
5537
|
-
...current,
|
|
5538
|
-
exportTo: patch.exportTo ?? current.exportTo,
|
|
5539
|
-
mediaRole: patch.mediaRole ?? current.mediaRole
|
|
5540
|
-
};
|
|
5541
|
-
if (this.rows.has(brokerId) && sameLinks(current, next)) return;
|
|
5542
|
-
await this.write(brokerId, next);
|
|
5543
|
-
}
|
|
5544
|
-
/** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
|
|
5545
|
-
async setMembership(next, nameOf) {
|
|
5546
|
-
for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
|
|
5547
|
-
const devices = next[brokerId] ?? [];
|
|
5548
|
-
const current = this.rows.get(brokerId);
|
|
5549
|
-
if (sameList(current?.devices ?? [], devices)) continue;
|
|
5550
|
-
await this.write(brokerId, {
|
|
5551
|
-
...current ?? defaultLinks(nameOf(brokerId)),
|
|
5552
|
-
devices: [...devices]
|
|
5553
|
-
});
|
|
5554
|
-
}
|
|
5555
|
-
}
|
|
5556
|
-
/** Forget the instances that no longer exist; returns the ids it removed. */
|
|
5557
|
-
async prune(knownBrokerIds) {
|
|
5558
|
-
const known = new Set(knownBrokerIds);
|
|
5559
|
-
const gone = [...this.rows.keys()].filter((id) => !known.has(id));
|
|
5560
|
-
for (const id of gone) await this.handle.delete(id);
|
|
5561
|
-
if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
|
|
5562
|
-
return gone;
|
|
5563
|
-
}
|
|
5564
|
-
async write(brokerId, body) {
|
|
5565
|
-
await this.handle.put({
|
|
5566
|
-
id: brokerId,
|
|
5567
|
-
data: body
|
|
5568
|
-
});
|
|
5569
|
-
this.adopt(new Map(this.rows).set(brokerId, body));
|
|
5570
|
-
}
|
|
5571
|
-
adopt(rows) {
|
|
5572
|
-
const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
|
|
5573
|
-
this.rows = rows;
|
|
5574
|
-
if (devicesChanged) this.memberships = membershipOf(rows);
|
|
5575
|
-
}
|
|
5576
|
-
};
|
|
5577
|
-
function sameList(a, b) {
|
|
5578
|
-
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
5579
|
-
}
|
|
5580
|
-
function sameLinks(a, b) {
|
|
5581
|
-
return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
|
|
5582
|
-
}
|
|
5583
|
-
/**
|
|
5584
|
-
* The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
|
|
5585
|
-
* `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
|
|
5586
|
-
* records and a write is routed to the record of the broker they name.
|
|
5587
|
-
*/
|
|
5588
|
-
var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
|
|
5589
|
-
var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
|
|
5590
|
-
function parseLinkFormKey(key) {
|
|
5591
|
-
for (const flag of ["exportTo", "mediaRole"]) {
|
|
5592
|
-
const prefix = `${flag}:`;
|
|
5593
|
-
if (key.startsWith(prefix) && key.length > prefix.length) return {
|
|
5594
|
-
flag,
|
|
5595
|
-
brokerId: key.slice(prefix.length)
|
|
5596
|
-
};
|
|
5597
|
-
}
|
|
5598
|
-
return null;
|
|
5599
|
-
}
|
|
5600
|
-
/** The form's dynamic keys carrying each instance's own value. */
|
|
5601
|
-
function linkFormOverlay(links) {
|
|
5602
|
-
const overlay = {};
|
|
5603
|
-
for (const brokerId of links.brokerIds()) {
|
|
5604
|
-
overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
|
|
5605
|
-
overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
|
|
5606
|
-
}
|
|
5607
|
-
return overlay;
|
|
5608
|
-
}
|
|
5609
5774
|
//#endregion
|
|
5610
5775
|
//#region src/ha-export/migrations/bind-ports.ts
|
|
5611
5776
|
/**
|
|
5612
5777
|
* Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
|
|
5613
|
-
* `integrations` capability (the old home of the two flags, read by the migrations only)
|
|
5614
|
-
* `ha-export-links` kind.
|
|
5778
|
+
* `integrations` capability (the old home of the two flags, read by the migrations only), the
|
|
5779
|
+
* retired `ha-export-links` kind and the `ha-export-targets` kind the links move into.
|
|
5615
5780
|
*/
|
|
5616
5781
|
function haExportMigrationPorts(deps) {
|
|
5617
5782
|
return {
|
|
@@ -5631,10 +5796,118 @@ function haExportMigrationPorts(deps) {
|
|
|
5631
5796
|
}
|
|
5632
5797
|
return "created";
|
|
5633
5798
|
},
|
|
5634
|
-
readLinks: () => readAllLinks(deps.links)
|
|
5799
|
+
readLinks: () => readAllLinks(deps.links),
|
|
5800
|
+
readTargets: () => readAllTargets(deps.targets),
|
|
5801
|
+
putTarget: async (id, body) => {
|
|
5802
|
+
try {
|
|
5803
|
+
await deps.targets.put({
|
|
5804
|
+
id,
|
|
5805
|
+
data: body
|
|
5806
|
+
}, { ifRev: 0 });
|
|
5807
|
+
} catch (err) {
|
|
5808
|
+
if (err instanceof require_ha_instance_settings.RevConflictError) return "exists";
|
|
5809
|
+
throw err;
|
|
5810
|
+
}
|
|
5811
|
+
return "created";
|
|
5812
|
+
},
|
|
5813
|
+
deleteLinks: async (brokerIds) => {
|
|
5814
|
+
await deps.links.deleteMany(brokerIds);
|
|
5815
|
+
},
|
|
5816
|
+
clearSettingKeys: deps.clearSettingKeys
|
|
5635
5817
|
};
|
|
5636
5818
|
}
|
|
5637
5819
|
//#endregion
|
|
5820
|
+
//#region src/ha-export/target-form.ts
|
|
5821
|
+
var NS = "provider-homeassistant:globalSettings";
|
|
5822
|
+
function buildTargetForm() {
|
|
5823
|
+
return { sections: [{
|
|
5824
|
+
id: "ha-export-target",
|
|
5825
|
+
title: "Home Assistant target",
|
|
5826
|
+
titleKey: `${NS}.s_ha-export-target.title`,
|
|
5827
|
+
description: "Where the export pushes devices. A target with no existing connection pushes nothing and keeps its devices. Export needs the CamStack custom component installed in Home Assistant (HACS).",
|
|
5828
|
+
descriptionKey: `${NS}.s_ha-export-target.description`,
|
|
5829
|
+
columns: 1,
|
|
5830
|
+
fields: [
|
|
5831
|
+
{
|
|
5832
|
+
type: "text",
|
|
5833
|
+
key: "name",
|
|
5834
|
+
label: "Name",
|
|
5835
|
+
labelKey: `${NS}.s_ha-export-target.name.label`
|
|
5836
|
+
},
|
|
5837
|
+
{
|
|
5838
|
+
type: "broker-select",
|
|
5839
|
+
key: "connectionId",
|
|
5840
|
+
label: "Home Assistant connection",
|
|
5841
|
+
labelKey: `${NS}.s_ha-export-target.connectionId.label`,
|
|
5842
|
+
description: "The Home Assistant instance this target pushes to.",
|
|
5843
|
+
descriptionKey: `${NS}.s_ha-export-target.connectionId.description`,
|
|
5844
|
+
brokerKind: "home-assistant"
|
|
5845
|
+
},
|
|
5846
|
+
{
|
|
5847
|
+
type: "select",
|
|
5848
|
+
key: "mediaRole",
|
|
5849
|
+
label: "Image links",
|
|
5850
|
+
labelKey: `${NS}.s_ha-export-target.mediaRole.label`,
|
|
5851
|
+
description: "The hub address this Home Assistant fetches image links from. Public endpoint is the default, so notifications show images away from home; when none is set the internal address is used.",
|
|
5852
|
+
descriptionKey: `${NS}.s_ha-export-target.mediaRole.description`,
|
|
5853
|
+
options: [{
|
|
5854
|
+
value: "internal",
|
|
5855
|
+
label: "Internal (LAN address)"
|
|
5856
|
+
}, {
|
|
5857
|
+
value: "publicEndpoint",
|
|
5858
|
+
label: "Public endpoint (reachable from outside)"
|
|
5859
|
+
}],
|
|
5860
|
+
default: "publicEndpoint"
|
|
5861
|
+
}
|
|
5862
|
+
]
|
|
5863
|
+
}, {
|
|
5864
|
+
id: "ha-export-advanced",
|
|
5865
|
+
title: "Advanced",
|
|
5866
|
+
titleKey: `${NS}.s_ha-export-advanced.title`,
|
|
5867
|
+
style: "accordion",
|
|
5868
|
+
defaultCollapsed: true,
|
|
5869
|
+
columns: 1,
|
|
5870
|
+
fields: [{
|
|
5871
|
+
type: "number",
|
|
5872
|
+
key: "reconcileIntervalSec",
|
|
5873
|
+
label: "Reconcile interval (s)",
|
|
5874
|
+
labelKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.label`,
|
|
5875
|
+
description: `Full re-announce and re-push cadence; at least 30 s.`,
|
|
5876
|
+
descriptionKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.description`,
|
|
5877
|
+
default: 300
|
|
5878
|
+
}]
|
|
5879
|
+
}] };
|
|
5880
|
+
}
|
|
5881
|
+
//#endregion
|
|
5882
|
+
//#region src/ha-export/target-instances.ts
|
|
5883
|
+
/**
|
|
5884
|
+
* The export targets as `integration-instances` (role `services`, D996): the operator adds,
|
|
5885
|
+
* edits, switches and deletes targets from the Connectivity page. A target's id is MINTED
|
|
5886
|
+
* (`hae_001`, ...) and names its Home Assistant connection as a reference, so several targets
|
|
5887
|
+
* may push to one Home Assistant.
|
|
5888
|
+
*/
|
|
5889
|
+
function openTargetInstances(records, deps) {
|
|
5890
|
+
return new require_ha_instance_settings.RecordIntegrationInstances(records, haExportTargetsKind, {
|
|
5891
|
+
description: {
|
|
5892
|
+
role: "services",
|
|
5893
|
+
instanceMode: "multiple",
|
|
5894
|
+
kind: "target",
|
|
5895
|
+
canTest: false,
|
|
5896
|
+
kinds: [{
|
|
5897
|
+
id: HA_TARGET_KIND,
|
|
5898
|
+
label: "Home Assistant"
|
|
5899
|
+
}]
|
|
5900
|
+
},
|
|
5901
|
+
formSchema: () => buildTargetForm(),
|
|
5902
|
+
initialBody: (name) => newTargetBody(name),
|
|
5903
|
+
mintId: () => nextTargetId(deps.book().ids()),
|
|
5904
|
+
kindOf: () => HA_TARGET_KIND,
|
|
5905
|
+
health: (body, id) => ({ status: deps.health(id, body) }),
|
|
5906
|
+
configured: (body) => deps.connectionExists(body.connectionId),
|
|
5907
|
+
onChanged: deps.onChanged
|
|
5908
|
+
});
|
|
5909
|
+
}
|
|
5910
|
+
//#endregion
|
|
5638
5911
|
//#region src/ha-export/membership.ts
|
|
5639
5912
|
function devicesForBroker(membership, brokerId) {
|
|
5640
5913
|
return membership[brokerId] ?? [];
|
|
@@ -5661,14 +5934,6 @@ function setExposed(membership, brokerId, deviceId, exposed) {
|
|
|
5661
5934
|
[brokerId]: next
|
|
5662
5935
|
};
|
|
5663
5936
|
}
|
|
5664
|
-
/** Same brokers, same order, same names — nothing for the form to re-render. */
|
|
5665
|
-
function sameBrokers(a, b) {
|
|
5666
|
-
if (a.length !== b.length) return false;
|
|
5667
|
-
return a.every((broker, index) => {
|
|
5668
|
-
const other = b[index];
|
|
5669
|
-
return other !== void 0 && other.id === broker.id && other.name === broker.name;
|
|
5670
|
-
});
|
|
5671
|
-
}
|
|
5672
5937
|
//#endregion
|
|
5673
5938
|
//#region src/ha-export/node-reads.ts
|
|
5674
5939
|
var UNREAD = {
|
|
@@ -7722,16 +7987,15 @@ var PTZ_MOVE_BY_DIRECTION = {
|
|
|
7722
7987
|
zoom_in: { zoom: .1 },
|
|
7723
7988
|
zoom_out: { zoom: -.1 }
|
|
7724
7989
|
};
|
|
7725
|
-
var DEFAULT_CONFIG = {
|
|
7726
|
-
|
|
7727
|
-
|
|
7728
|
-
|
|
7729
|
-
actions: [],
|
|
7730
|
-
targets: []
|
|
7731
|
-
}
|
|
7732
|
-
};
|
|
7990
|
+
var DEFAULT_CONFIG = { exportedEntities: {
|
|
7991
|
+
actions: [],
|
|
7992
|
+
targets: []
|
|
7993
|
+
} };
|
|
7733
7994
|
var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
7734
7995
|
links = /* @__PURE__ */ new Map();
|
|
7996
|
+
instances = null;
|
|
7997
|
+
/** The Home Assistant connections (broker ids) the last `broker.list` answered. */
|
|
7998
|
+
connections = /* @__PURE__ */ new Set();
|
|
7735
7999
|
/** deviceKey → the device it addresses. Rebuilt on every reconcile. */
|
|
7736
8000
|
exported = /* @__PURE__ */ new Map();
|
|
7737
8001
|
/** numeric deviceId → deviceKey, for the event path. */
|
|
@@ -7838,15 +8102,26 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7838
8102
|
async onInitialize() {
|
|
7839
8103
|
const records = this.ctx.records;
|
|
7840
8104
|
if (records === void 0) throw new Error("ha-export: records are not available");
|
|
7841
|
-
await records.declare({ "ha-export-
|
|
7842
|
-
const
|
|
8105
|
+
await records.declare({ "ha-export-targets": haExportTargetsKind });
|
|
8106
|
+
const targetsHandle = records.kind(haExportTargetsKind);
|
|
8107
|
+
const legacyLinks = await declareLegacyLinks(records);
|
|
7843
8108
|
await this.runMigrations(require_ha_instance_settings.defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
|
|
7844
8109
|
readStored: async () => this.resolveGlobalStore(),
|
|
7845
8110
|
api: this.ctx.api,
|
|
7846
|
-
links:
|
|
8111
|
+
links: legacyLinks,
|
|
8112
|
+
targets: targetsHandle,
|
|
8113
|
+
clearSettingKeys: async (keys) => {
|
|
8114
|
+
await this.updateGlobalSettingsAsOperator(Object.fromEntries(keys.map((key) => [key, null])));
|
|
8115
|
+
},
|
|
7847
8116
|
warn: (message) => this.ctx.logger.warn(message)
|
|
7848
8117
|
})));
|
|
7849
|
-
this.
|
|
8118
|
+
this.targetBook = await ExportTargets.load(targetsHandle);
|
|
8119
|
+
this.instances = openTargetInstances(records, {
|
|
8120
|
+
book: () => this.book(),
|
|
8121
|
+
connectionExists: (connectionId) => this.connectionExists(connectionId),
|
|
8122
|
+
health: (id, body) => this.targetHealth(id, body),
|
|
8123
|
+
onChanged: (id, change) => this.onTargetChanged(id, change)
|
|
8124
|
+
});
|
|
7850
8125
|
const secret = this.state("haExportMediaSecret", require_ha_instance_settings.string(), "");
|
|
7851
8126
|
const existing = await secret.get();
|
|
7852
8127
|
if (existing.length === 0) {
|
|
@@ -7882,27 +8157,27 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7882
8157
|
{
|
|
7883
8158
|
capability: require_ha_instance_settings.deviceExportCapability,
|
|
7884
8159
|
provider: {
|
|
7885
|
-
getStatus: async (input) => this.buildStatus(this.
|
|
7886
|
-
listInstances: async () => this.
|
|
7887
|
-
instanceId:
|
|
7888
|
-
name:
|
|
7889
|
-
enabled: this.
|
|
7890
|
-
kind:
|
|
8160
|
+
getStatus: async (input) => this.buildStatus(this.targetScope(require_ha_instance_settings.statusInstanceOf(input))),
|
|
8161
|
+
listInstances: async () => this.book().ids().map((id) => ({
|
|
8162
|
+
instanceId: id,
|
|
8163
|
+
name: this.book().name(id),
|
|
8164
|
+
enabled: this.book().isEnabled(id),
|
|
8165
|
+
kind: HA_TARGET_KIND
|
|
7891
8166
|
})),
|
|
7892
8167
|
listSupportedDeviceKinds: async ({ instanceId }) => {
|
|
7893
|
-
this.
|
|
8168
|
+
this.targetScope(instanceId);
|
|
7894
8169
|
return [...SUPPORTED_DEVICE_KINDS];
|
|
7895
8170
|
},
|
|
7896
|
-
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.
|
|
7897
|
-
exposeDevice: async ({ instanceId, deviceId }) => this.
|
|
7898
|
-
unexposeDevice: async ({ instanceId, deviceId }) => this.
|
|
8171
|
+
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.targetScope(instanceId)),
|
|
8172
|
+
exposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, true),
|
|
8173
|
+
unexposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, false),
|
|
7899
8174
|
listSupportedEntityKinds: async () => [
|
|
7900
8175
|
"device",
|
|
7901
8176
|
"action",
|
|
7902
8177
|
"notifier"
|
|
7903
8178
|
],
|
|
7904
8179
|
listExposedEntities: async ({ instanceId }) => {
|
|
7905
|
-
const scope = this.
|
|
8180
|
+
const scope = this.targetScope(instanceId);
|
|
7906
8181
|
await exportedEntities.pruneDeleted();
|
|
7907
8182
|
return [...this.exposedDeviceRows(scope).map((row) => ({
|
|
7908
8183
|
entity: {
|
|
@@ -7913,13 +8188,13 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7913
8188
|
})), ...exportedEntities.list()];
|
|
7914
8189
|
},
|
|
7915
8190
|
exposeEntity: async ({ instanceId, entity }) => {
|
|
7916
|
-
if (entity.kind === "device") return this.
|
|
7917
|
-
this.
|
|
8191
|
+
if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, true);
|
|
8192
|
+
this.targetScope(instanceId);
|
|
7918
8193
|
return exportedEntities.expose(entity);
|
|
7919
8194
|
},
|
|
7920
8195
|
unexposeEntity: async ({ instanceId, entity }) => {
|
|
7921
|
-
if (entity.kind === "device") return this.
|
|
7922
|
-
this.
|
|
8196
|
+
if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, false);
|
|
8197
|
+
this.targetScope(instanceId);
|
|
7923
8198
|
return exportedEntities.unexpose(entity);
|
|
7924
8199
|
},
|
|
7925
8200
|
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
@@ -7927,6 +8202,10 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7927
8202
|
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
7928
8203
|
}
|
|
7929
8204
|
},
|
|
8205
|
+
{
|
|
8206
|
+
capability: require_ha_instance_settings.integrationInstancesCapability,
|
|
8207
|
+
provider: this.requireInstances().toProvider()
|
|
8208
|
+
},
|
|
7930
8209
|
{
|
|
7931
8210
|
capability: require_ha_instance_settings.addonRoutesCapability,
|
|
7932
8211
|
provider: require_ha_instance_settings.buildAddonRouteProvider(ADDON_ID, [{
|
|
@@ -7945,53 +8224,28 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
7945
8224
|
}
|
|
7946
8225
|
];
|
|
7947
8226
|
}
|
|
7948
|
-
|
|
7949
|
-
|
|
7950
|
-
|
|
7951
|
-
|
|
7952
|
-
|
|
7953
|
-
return super.getGlobalSettings({
|
|
7954
|
-
...overlay,
|
|
7955
|
-
...linkFormOverlay(this.book())
|
|
7956
|
-
}, cap, nodeId);
|
|
8227
|
+
async onConfigChanged() {
|
|
8228
|
+
if (this.targetBook === null) return;
|
|
8229
|
+
for (const link of this.links.values()) link.client.resetBackoff();
|
|
8230
|
+
await this.reconcile("config-changed");
|
|
8231
|
+
this.startTimer();
|
|
7957
8232
|
}
|
|
7958
8233
|
/**
|
|
7959
|
-
*
|
|
7960
|
-
*
|
|
7961
|
-
*
|
|
8234
|
+
* A target was created, edited, switched or deleted through `integration-instances`: reload
|
|
8235
|
+
* the records, then apply them (a new connection opens, a removed one closes, a changed
|
|
8236
|
+
* cadence restarts the timer). A deleted target's devices simply stop being exported.
|
|
7962
8237
|
*/
|
|
7963
|
-
async
|
|
7964
|
-
|
|
7965
|
-
|
|
7966
|
-
|
|
7967
|
-
|
|
7968
|
-
|
|
7969
|
-
rest[key] = value;
|
|
7970
|
-
continue;
|
|
7971
|
-
}
|
|
7972
|
-
this.brokerScope(field.brokerId);
|
|
7973
|
-
const entry = flags.get(field.brokerId) ?? {};
|
|
7974
|
-
if (field.flag === "exportTo") {
|
|
7975
|
-
if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
|
|
7976
|
-
entry.exportTo = value;
|
|
7977
|
-
} else {
|
|
7978
|
-
const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
|
|
7979
|
-
if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
|
|
7980
|
-
entry.mediaRole = role;
|
|
7981
|
-
}
|
|
7982
|
-
flags.set(field.brokerId, entry);
|
|
7983
|
-
}
|
|
7984
|
-
for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
|
|
7985
|
-
if (Object.keys(rest).length > 0 || flags.size === 0) {
|
|
7986
|
-
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7987
|
-
return;
|
|
7988
|
-
}
|
|
8238
|
+
async onTargetChanged(id, change) {
|
|
8239
|
+
await this.book().reload();
|
|
8240
|
+
this.ctx.logger.info("ha-export: an export target changed", { meta: {
|
|
8241
|
+
targetId: id,
|
|
8242
|
+
change
|
|
8243
|
+
} });
|
|
7989
8244
|
await this.onConfigChanged();
|
|
7990
8245
|
}
|
|
7991
|
-
|
|
7992
|
-
|
|
7993
|
-
|
|
7994
|
-
this.startTimer();
|
|
8246
|
+
requireInstances() {
|
|
8247
|
+
if (this.instances === null) throw new Error("ha-export: the target instances are not open");
|
|
8248
|
+
return this.instances;
|
|
7995
8249
|
}
|
|
7996
8250
|
wireListeners() {
|
|
7997
8251
|
this.subscribe({ category: require_ha_instance_settings.EventCategory.DeviceStateChanged }, (event) => {
|
|
@@ -8247,6 +8501,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
8247
8501
|
}
|
|
8248
8502
|
/** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
|
|
8249
8503
|
warnedCapFields = /* @__PURE__ */ new Set();
|
|
8504
|
+
warnedMediaFallback = /* @__PURE__ */ new Set();
|
|
8250
8505
|
warnOnce(deviceId, capName, field, line) {
|
|
8251
8506
|
const key = `${deviceId}:${capName}:${field}`;
|
|
8252
8507
|
if (this.warnedCapFields.has(key)) return;
|
|
@@ -8483,7 +8738,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
8483
8738
|
const fullResync = this.resync.take(reason);
|
|
8484
8739
|
let readsHeld = false;
|
|
8485
8740
|
await this.refreshComponentSupport();
|
|
8486
|
-
const enabled = this.
|
|
8741
|
+
const enabled = this.enabledTargetIds();
|
|
8487
8742
|
const devices = await this.ctx.api.deviceManager.listAll.query({});
|
|
8488
8743
|
const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
|
|
8489
8744
|
const byId = new Map(devices.map((device) => [device.id, device]));
|
|
@@ -9429,71 +9684,63 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9429
9684
|
*/
|
|
9430
9685
|
async refreshBrokers() {
|
|
9431
9686
|
const haBrokers = await require_ha_instance_settings.listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
|
|
9432
|
-
const known = haBrokers.map((broker) => ({
|
|
9433
|
-
id: broker.id,
|
|
9434
|
-
name: broker.name
|
|
9435
|
-
}));
|
|
9436
9687
|
/**
|
|
9437
|
-
* NEVER
|
|
9438
|
-
*
|
|
9439
|
-
* `broker.list` is a fan-out union, so it answers `[]` for the whole window
|
|
9440
|
-
* in which `provider-homeassistant` has not finished registering — and it
|
|
9441
|
-
* registers its caps only at the END of an `initialize()` that restores 239
|
|
9442
|
-
* devices. An empty list there means "I do not know yet", never "the
|
|
9443
|
-
* operator deleted their instance", and the two were indistinguishable to
|
|
9444
|
-
* this code: every boot pruned the membership to nothing and PERSISTED it,
|
|
9445
|
-
* so the operator's exported cameras silently switched themselves off. Seen
|
|
9446
|
-
* live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
|
|
9447
|
-
* `devices=2` and both switches back to `false` with no line anywhere.
|
|
9688
|
+
* NEVER treat an empty enumeration as "the connections are gone".
|
|
9448
9689
|
*
|
|
9449
|
-
*
|
|
9450
|
-
*
|
|
9451
|
-
*
|
|
9452
|
-
*
|
|
9690
|
+
* `broker.list` is a fan-out union, so it answers `[]` for the whole window in which
|
|
9691
|
+
* `provider-homeassistant` has not finished registering. An empty list there means "I do not
|
|
9692
|
+
* know yet", never "the operator deleted their instance". A target whose connection is not in
|
|
9693
|
+
* the list is UNCONFIGURED for this pass: it pushes nothing, and its devices stay in its
|
|
9694
|
+
* record, so the next pass that sees the connection resumes them. Nothing is ever pruned or
|
|
9695
|
+
* unexposed from here (D49; 2026-08-09, 2026-08-24).
|
|
9453
9696
|
*/
|
|
9454
|
-
|
|
9455
|
-
|
|
9456
|
-
|
|
9457
|
-
const
|
|
9458
|
-
if (
|
|
9697
|
+
this.connections = new Set(haBrokers.map((broker) => broker.id));
|
|
9698
|
+
this.refreshEnabledTargets();
|
|
9699
|
+
for (const id of this.book().ids()) {
|
|
9700
|
+
const target = this.book().get(id);
|
|
9701
|
+
if (target === void 0 || !this.book().isEnabled(id)) continue;
|
|
9702
|
+
if (!this.connectionExists(target.connectionId)) this.ctx.logger.warn(haBrokers.length === 0 ? "ha-export: no Home Assistant connection answered, so this export target is unconfigured for now; its devices stay in place" : "ha-export: the Home Assistant connection of this export target does not exist, so it is unconfigured; its devices stay in place", { meta: {
|
|
9703
|
+
targetId: id,
|
|
9704
|
+
connectionId: target.connectionId
|
|
9705
|
+
} });
|
|
9459
9706
|
}
|
|
9460
|
-
|
|
9461
|
-
|
|
9462
|
-
|
|
9463
|
-
* operator's export list). The records are read at boot and a failed read stops it.
|
|
9464
|
-
*/
|
|
9465
|
-
if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
|
|
9466
|
-
this.refreshEnabledBrokers(known);
|
|
9467
|
-
const wanted = new Set(this.enabledBrokerIds());
|
|
9468
|
-
for (const [brokerId, link] of this.links) {
|
|
9469
|
-
if (wanted.has(brokerId)) continue;
|
|
9707
|
+
const wanted = new Set(this.enabledTargetIds());
|
|
9708
|
+
for (const [targetId, link] of this.links) {
|
|
9709
|
+
if (wanted.has(targetId) && link.connectionId === this.book().connectionId(targetId)) continue;
|
|
9470
9710
|
await link.client.dispose();
|
|
9471
|
-
this.links.delete(
|
|
9472
|
-
this.componentSupport.forget(
|
|
9473
|
-
this.ctx.logger.info("ha-export: stopped exporting to a
|
|
9474
|
-
|
|
9475
|
-
|
|
9476
|
-
|
|
9477
|
-
|
|
9478
|
-
|
|
9711
|
+
this.links.delete(targetId);
|
|
9712
|
+
this.componentSupport.forget(targetId);
|
|
9713
|
+
this.ctx.logger.info("ha-export: stopped exporting to a target", { meta: {
|
|
9714
|
+
targetId,
|
|
9715
|
+
connectionId: link.connectionId
|
|
9716
|
+
} });
|
|
9717
|
+
}
|
|
9718
|
+
for (const targetId of wanted) {
|
|
9719
|
+
if (this.links.has(targetId)) continue;
|
|
9720
|
+
const connectionId = this.book().connectionId(targetId);
|
|
9721
|
+
const broker = haBrokers.find((candidate) => candidate.id === connectionId);
|
|
9722
|
+
if (broker === void 0) continue;
|
|
9723
|
+
const resolved = await this.resolveBrokerConnection(connectionId, broker.addonId);
|
|
9479
9724
|
if (resolved === null) continue;
|
|
9480
9725
|
const client = new PushClient({
|
|
9481
9726
|
transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
|
|
9482
9727
|
logger: this.ctx.logger,
|
|
9483
|
-
brokerId:
|
|
9484
|
-
onLinkRestored: (
|
|
9728
|
+
brokerId: targetId,
|
|
9729
|
+
onLinkRestored: (restoredId) => this.reachability.request(restoredId)
|
|
9485
9730
|
});
|
|
9486
9731
|
client.start();
|
|
9487
9732
|
this.resync.markGap("broker-connected");
|
|
9488
|
-
this.links.set(
|
|
9489
|
-
brokerId:
|
|
9490
|
-
|
|
9733
|
+
this.links.set(targetId, {
|
|
9734
|
+
brokerId: targetId,
|
|
9735
|
+
connectionId,
|
|
9736
|
+
name: this.book().name(targetId),
|
|
9491
9737
|
baseUrl: resolved.baseUrl,
|
|
9492
9738
|
token: resolved.token,
|
|
9493
9739
|
client
|
|
9494
9740
|
});
|
|
9495
9741
|
this.ctx.logger.info("ha-export: exporting to Home Assistant", { meta: {
|
|
9496
|
-
|
|
9742
|
+
targetId,
|
|
9743
|
+
connectionId,
|
|
9497
9744
|
baseUrl: resolved.baseUrl
|
|
9498
9745
|
} });
|
|
9499
9746
|
}
|
|
@@ -9607,35 +9854,62 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
9607
9854
|
}
|
|
9608
9855
|
}
|
|
9609
9856
|
/**
|
|
9610
|
-
* The
|
|
9611
|
-
*
|
|
9612
|
-
* NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
|
|
9857
|
+
* The targets that push, as of the last {@link refreshEnabledTargets}: switched on AND with a
|
|
9858
|
+
* connection the hub knows. NOT read from `this.config`: the switch is a fact of the target's
|
|
9613
9859
|
* record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
|
|
9614
9860
|
* and before the per-device panel renders, never written to.
|
|
9615
9861
|
*/
|
|
9616
9862
|
enabled = [];
|
|
9617
|
-
/** The `ha-export-
|
|
9618
|
-
|
|
9863
|
+
/** The `ha-export-targets` records, loaded at boot after the migrations. */
|
|
9864
|
+
targetBook = null;
|
|
9619
9865
|
book() {
|
|
9620
|
-
if (this.
|
|
9621
|
-
return this.
|
|
9866
|
+
if (this.targetBook === null) throw new Error("ha-export: the export targets are not loaded yet");
|
|
9867
|
+
return this.targetBook;
|
|
9622
9868
|
}
|
|
9623
|
-
/**
|
|
9869
|
+
/** targetId → exported device ids, from the records. */
|
|
9624
9870
|
membership() {
|
|
9625
9871
|
return this.book().membership();
|
|
9626
9872
|
}
|
|
9627
|
-
/** Persist a membership change: each
|
|
9873
|
+
/** Persist a membership change: each target's record first, the in-memory copy after it. */
|
|
9628
9874
|
async persistMembership(next) {
|
|
9629
|
-
await this.book().setMembership(next
|
|
9875
|
+
await this.book().setMembership(next);
|
|
9876
|
+
}
|
|
9877
|
+
connectionExists(connectionId) {
|
|
9878
|
+
return connectionId.length > 0 && this.connections.has(connectionId);
|
|
9630
9879
|
}
|
|
9631
|
-
|
|
9632
|
-
this.enabled =
|
|
9880
|
+
refreshEnabledTargets() {
|
|
9881
|
+
this.enabled = this.book().ids().filter((id) => this.book().isEnabled(id) && this.connectionExists(this.book().connectionId(id)));
|
|
9633
9882
|
}
|
|
9634
|
-
|
|
9883
|
+
enabledTargetIds() {
|
|
9635
9884
|
return this.enabled;
|
|
9636
9885
|
}
|
|
9637
|
-
|
|
9638
|
-
return this.
|
|
9886
|
+
targetName(targetId) {
|
|
9887
|
+
return this.book().name(targetId);
|
|
9888
|
+
}
|
|
9889
|
+
/** The Status column of the instances table: why a target is, or is not, pushing. */
|
|
9890
|
+
targetHealth(id, body) {
|
|
9891
|
+
if (body.enabled === false) return {
|
|
9892
|
+
state: "off",
|
|
9893
|
+
label: "Off"
|
|
9894
|
+
};
|
|
9895
|
+
if (!this.connectionExists(body.connectionId)) return {
|
|
9896
|
+
state: "error",
|
|
9897
|
+
label: "Unconfigured",
|
|
9898
|
+
detail: body.connectionId.length === 0 ? "Pick the Home Assistant connection this target pushes to" : "The Home Assistant connection of this target does not exist"
|
|
9899
|
+
};
|
|
9900
|
+
const link = this.links.get(id);
|
|
9901
|
+
if (link === void 0) return {
|
|
9902
|
+
state: "unknown",
|
|
9903
|
+
label: "Not connected yet"
|
|
9904
|
+
};
|
|
9905
|
+
return link.client.healthy ? {
|
|
9906
|
+
state: "ok",
|
|
9907
|
+
label: "Connected"
|
|
9908
|
+
} : {
|
|
9909
|
+
state: "error",
|
|
9910
|
+
label: "Unreachable",
|
|
9911
|
+
detail: link.client.failure?.text ?? "unreachable"
|
|
9912
|
+
};
|
|
9639
9913
|
}
|
|
9640
9914
|
async handleCommandRoute(body, reply) {
|
|
9641
9915
|
const topic = readString(body, "topic");
|
|
@@ -10023,14 +10297,24 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10023
10297
|
}
|
|
10024
10298
|
/**
|
|
10025
10299
|
* Each broker's media base, read per use: the palette's role the broker chose
|
|
10026
|
-
* (the
|
|
10300
|
+
* (the export target record's `mediaRole`, `publicEndpoint` by default, internal when none is set). A role that resolves to nothing
|
|
10027
10301
|
* is `null` and says so — the image entities then carry no value, which reads as
|
|
10028
10302
|
* empty rather than as a broken link.
|
|
10029
10303
|
*/
|
|
10030
10304
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
10031
10305
|
let bases;
|
|
10032
10306
|
try {
|
|
10033
|
-
|
|
10307
|
+
const answer = await this.ctx.api.localNetwork.getAddresses.query();
|
|
10308
|
+
bases = mediaBasesByBroker(brokerIds, answer, (brokerId) => this.book().mediaRole(brokerId));
|
|
10309
|
+
for (const brokerId of brokerIds) {
|
|
10310
|
+
if (this.warnedMediaFallback.has(brokerId)) continue;
|
|
10311
|
+
if (!usedInternalFallback(answer, this.book().mediaRole(brokerId))) continue;
|
|
10312
|
+
this.warnedMediaFallback.add(brokerId);
|
|
10313
|
+
this.ctx.logger.info("ha-export: no public endpoint is set, so image links use the internal address", {
|
|
10314
|
+
tags: { deviceId },
|
|
10315
|
+
meta: { brokerId }
|
|
10316
|
+
});
|
|
10317
|
+
}
|
|
10034
10318
|
} catch (err) {
|
|
10035
10319
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
10036
10320
|
tags: { deviceId },
|
|
@@ -10048,27 +10332,26 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10048
10332
|
return bases;
|
|
10049
10333
|
}
|
|
10050
10334
|
/**
|
|
10051
|
-
* Every
|
|
10052
|
-
*
|
|
10053
|
-
* back on). A cold `knownBrokers` still counts the enabled ids.
|
|
10335
|
+
* Every target this addon holds, enabled or not: a membership write may target one that is
|
|
10336
|
+
* switched off or unconfigured (its devices resume when it is turned back on).
|
|
10054
10337
|
*/
|
|
10055
|
-
|
|
10056
|
-
return new Set(
|
|
10338
|
+
knownTargetIds() {
|
|
10339
|
+
return new Set(this.book().ids());
|
|
10057
10340
|
}
|
|
10058
10341
|
/**
|
|
10059
10342
|
* The brokers a `device-export` read answers over: the named instance, or
|
|
10060
10343
|
* (absent) every enabled broker. A broker this addon does not know is
|
|
10061
10344
|
* refused, never answered empty.
|
|
10062
10345
|
*/
|
|
10063
|
-
|
|
10064
|
-
if (instanceId === void 0) return this.
|
|
10065
|
-
if (!this.
|
|
10346
|
+
targetScope(instanceId) {
|
|
10347
|
+
if (instanceId === void 0) return this.enabledTargetIds();
|
|
10348
|
+
if (!this.knownTargetIds().has(instanceId)) throw new require_ha_instance_settings.ExportInstanceUnknownError("provider-homeassistant", instanceId);
|
|
10066
10349
|
return [instanceId];
|
|
10067
10350
|
}
|
|
10068
10351
|
exposedDeviceRows(scope) {
|
|
10069
10352
|
return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
|
|
10070
10353
|
deviceId,
|
|
10071
|
-
exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.
|
|
10354
|
+
exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.targetName(brokerId)).join(", ")
|
|
10072
10355
|
}));
|
|
10073
10356
|
}
|
|
10074
10357
|
/**
|
|
@@ -10076,15 +10359,15 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10076
10359
|
* instance. There is no write without a target and no "everywhere": the
|
|
10077
10360
|
* other brokers' membership is not touched.
|
|
10078
10361
|
*/
|
|
10079
|
-
async
|
|
10080
|
-
this.
|
|
10362
|
+
async setTargetMembership(instanceId, deviceId, exposed) {
|
|
10363
|
+
this.targetScope(instanceId);
|
|
10081
10364
|
const next = setExposed(this.membership(), instanceId, deviceId, exposed);
|
|
10082
10365
|
if (next === this.membership()) {
|
|
10083
10366
|
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
10084
10367
|
tags: { deviceId: Number(deviceId) },
|
|
10085
10368
|
meta: {
|
|
10086
10369
|
exposed,
|
|
10087
|
-
|
|
10370
|
+
targetId: instanceId
|
|
10088
10371
|
}
|
|
10089
10372
|
});
|
|
10090
10373
|
return;
|
|
@@ -10094,93 +10377,29 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10094
10377
|
tags: { deviceId: Number(deviceId) },
|
|
10095
10378
|
meta: {
|
|
10096
10379
|
exposed,
|
|
10097
|
-
|
|
10380
|
+
targetId: instanceId
|
|
10098
10381
|
}
|
|
10099
10382
|
});
|
|
10100
10383
|
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
10101
10384
|
}
|
|
10102
|
-
globalSettingsSchema() {
|
|
10103
|
-
const brokerFields = this.config.knownBrokers.map((broker) => ({
|
|
10104
|
-
type: "boolean",
|
|
10105
|
-
key: exportToFormKey(broker.id),
|
|
10106
|
-
label: broker.name,
|
|
10107
|
-
style: "switch",
|
|
10108
|
-
default: false
|
|
10109
|
-
}));
|
|
10110
|
-
return { sections: [
|
|
10111
|
-
{
|
|
10112
|
-
id: "ha-export-brokers",
|
|
10113
|
-
title: "Home Assistant instances",
|
|
10114
|
-
titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
|
|
10115
|
-
description: brokerFields.length > 0 ? "An instance that is off receives nothing; its exported devices resume when it is turned back on. Export needs the CamStack custom component installed in Home Assistant (HACS)." : "No Home Assistant broker is registered yet.",
|
|
10116
|
-
columns: 1,
|
|
10117
|
-
fields: brokerFields.length > 0 ? brokerFields : [{
|
|
10118
|
-
type: "info",
|
|
10119
|
-
key: "ha-export:__no-brokers",
|
|
10120
|
-
label: "No brokers",
|
|
10121
|
-
labelKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.ha-export___no-brokers.label",
|
|
10122
|
-
content: "Register a Home Assistant broker to enable the export.",
|
|
10123
|
-
variant: "warning"
|
|
10124
|
-
}]
|
|
10125
|
-
},
|
|
10126
|
-
...this.config.knownBrokers.length > 0 ? [{
|
|
10127
|
-
id: "ha-export-transport",
|
|
10128
|
-
title: "Connection",
|
|
10129
|
-
titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
|
|
10130
|
-
description: "The hub address each Home Assistant instance fetches image links from. Internal is the address flagged Internal on the Network page; choose Public endpoint for an instance outside your network.",
|
|
10131
|
-
columns: 1,
|
|
10132
|
-
fields: this.config.knownBrokers.map((broker) => ({
|
|
10133
|
-
type: "select",
|
|
10134
|
-
key: mediaRoleFormKey(broker.id),
|
|
10135
|
-
label: `Image links for ${broker.name}`,
|
|
10136
|
-
options: [{
|
|
10137
|
-
value: "internal",
|
|
10138
|
-
label: "Internal (LAN address)"
|
|
10139
|
-
}, {
|
|
10140
|
-
value: "publicEndpoint",
|
|
10141
|
-
label: "Public endpoint (reachable from outside)"
|
|
10142
|
-
}],
|
|
10143
|
-
default: "internal"
|
|
10144
|
-
}))
|
|
10145
|
-
}] : [],
|
|
10146
|
-
{
|
|
10147
|
-
id: "ha-export-advanced",
|
|
10148
|
-
title: "Advanced",
|
|
10149
|
-
titleKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.title",
|
|
10150
|
-
style: "accordion",
|
|
10151
|
-
defaultCollapsed: true,
|
|
10152
|
-
columns: 1,
|
|
10153
|
-
fields: [{
|
|
10154
|
-
type: "number",
|
|
10155
|
-
key: "reconcileIntervalSec",
|
|
10156
|
-
label: "Reconcile interval (s)",
|
|
10157
|
-
labelKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.label",
|
|
10158
|
-
description: "Full re-announce and re-push cadence.",
|
|
10159
|
-
descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.description",
|
|
10160
|
-
default: DEFAULT_CONFIG.reconcileIntervalSec
|
|
10161
|
-
}]
|
|
10162
|
-
}
|
|
10163
|
-
] };
|
|
10164
|
-
}
|
|
10165
10385
|
/**
|
|
10166
10386
|
* Per-device Export panel — one switch PER BROKER.
|
|
10167
10387
|
*
|
|
10168
|
-
* Field keys are namespaced `haExport:<deviceId>:<
|
|
10388
|
+
* Field keys are namespaced `haExport:<deviceId>:<targetId>`: the
|
|
10169
10389
|
* device-details form flattens fields by key across every contributing
|
|
10170
10390
|
* section, so a bare `enabled` collides with the Alexa and HomeKit
|
|
10171
10391
|
* exporters' identically-named fields and the operator sees another
|
|
10172
10392
|
* exporter's value in this one's toggle.
|
|
10173
10393
|
*/
|
|
10174
10394
|
async buildContribution(deviceId) {
|
|
10175
|
-
this.refreshEnabledBrokers(this.config.knownBrokers);
|
|
10176
10395
|
const idStr = String(deviceId);
|
|
10177
|
-
const enabled = this.
|
|
10178
|
-
const fields = enabled.map((
|
|
10396
|
+
const enabled = this.book().ids().filter((id) => this.book().isEnabled(id));
|
|
10397
|
+
const fields = enabled.map((targetId) => ({
|
|
10179
10398
|
type: "boolean",
|
|
10180
|
-
key: `haExport:${deviceId}:${
|
|
10181
|
-
label: `Export to ${this.
|
|
10399
|
+
key: `haExport:${deviceId}:${targetId}`,
|
|
10400
|
+
label: `Export to ${this.targetName(targetId)}`,
|
|
10182
10401
|
style: "switch",
|
|
10183
|
-
value: isExposed(this.membership(),
|
|
10402
|
+
value: isExposed(this.membership(), targetId, idStr),
|
|
10184
10403
|
immediate: true
|
|
10185
10404
|
}));
|
|
10186
10405
|
return {
|
|
@@ -10194,7 +10413,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10194
10413
|
id: "ha-export",
|
|
10195
10414
|
title: "Home Assistant",
|
|
10196
10415
|
titleKey: "provider-homeassistant:contribution.s_ha-export.title",
|
|
10197
|
-
description: enabled.length > 0 ? "Each
|
|
10416
|
+
description: enabled.length > 0 ? "Each target receives only the devices switched on for it." : "No Home Assistant export target is enabled.",
|
|
10198
10417
|
tab: "export",
|
|
10199
10418
|
columns: 1,
|
|
10200
10419
|
order: 30,
|
|
@@ -10202,7 +10421,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10202
10421
|
type: "info",
|
|
10203
10422
|
key: `haExport:${deviceId}:__none`,
|
|
10204
10423
|
label: "Not configured",
|
|
10205
|
-
content: "
|
|
10424
|
+
content: "Add an export target on the Connectivity page first.",
|
|
10206
10425
|
variant: "info"
|
|
10207
10426
|
}]
|
|
10208
10427
|
}]
|
|
@@ -10215,10 +10434,10 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10215
10434
|
/**
|
|
10216
10435
|
* Driven by the PATCH KEYS, not by the enabled-broker list.
|
|
10217
10436
|
*
|
|
10218
|
-
* The key already carries the broker (`haExport:<deviceId>:<
|
|
10437
|
+
* The key already carries the broker (`haExport:<deviceId>:<targetId>`) —
|
|
10219
10438
|
* the operator could not have been shown the switch otherwise — so reading
|
|
10220
10439
|
* the broker from it needs no other state to be warm. Iterating
|
|
10221
|
-
* `
|
|
10440
|
+
* `enabledTargetIds()` instead made this depend on a read-through cache,
|
|
10222
10441
|
* and when that cache was cold the loop matched nothing, `touched` stayed
|
|
10223
10442
|
* false, and the method returned `{success: true}` HAVING DONE NOTHING.
|
|
10224
10443
|
* The admin UI showed the switch flip and it silently did not persist;
|
|
@@ -10228,9 +10447,16 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10228
10447
|
const prefix = `haExport:${deviceId}:`;
|
|
10229
10448
|
for (const [key, value] of Object.entries(patch)) {
|
|
10230
10449
|
if (!key.startsWith(prefix)) continue;
|
|
10231
|
-
const
|
|
10232
|
-
if (
|
|
10233
|
-
|
|
10450
|
+
const targetId = key.slice(prefix.length);
|
|
10451
|
+
if (targetId.length === 0) continue;
|
|
10452
|
+
if (!this.book().has(targetId)) {
|
|
10453
|
+
this.ctx.logger.warn("ha-export: a device patch names an export target that does not exist", {
|
|
10454
|
+
tags: { deviceId },
|
|
10455
|
+
meta: { targetId }
|
|
10456
|
+
});
|
|
10457
|
+
continue;
|
|
10458
|
+
}
|
|
10459
|
+
next = setExposed(next, targetId, idStr, value === true);
|
|
10234
10460
|
touched = true;
|
|
10235
10461
|
}
|
|
10236
10462
|
if (!touched) {
|
|
@@ -10241,9 +10467,9 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10241
10467
|
return { success: true };
|
|
10242
10468
|
}
|
|
10243
10469
|
await this.persistMembership(next);
|
|
10244
|
-
this.ctx.logger.info("ha-export: per-
|
|
10470
|
+
this.ctx.logger.info("ha-export: per-target export membership changed", {
|
|
10245
10471
|
tags: { deviceId },
|
|
10246
|
-
meta: {
|
|
10472
|
+
meta: { targets: brokersExposing(next, idStr) }
|
|
10247
10473
|
});
|
|
10248
10474
|
await this.reconcile("device-panel");
|
|
10249
10475
|
return { success: true };
|
|
@@ -10252,15 +10478,17 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10252
10478
|
const enabled = scope;
|
|
10253
10479
|
const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
|
|
10254
10480
|
const healthy = links.filter((link) => link.client.healthy).length;
|
|
10255
|
-
const
|
|
10481
|
+
const failures = links.map((link) => link.client.failure).filter((failure) => failure !== null);
|
|
10482
|
+
const unconfigured = scope.filter((id) => this.book().isEnabled(id) && !this.connectionExists(this.book().connectionId(id)));
|
|
10483
|
+
const linkFailure = failures[0]?.text ?? (unconfigured.length > 0 ? `The Home Assistant connection of ${unconfigured.map((id) => this.targetName(id)).join(", ")} is not set or does not exist` : void 0);
|
|
10256
10484
|
return {
|
|
10257
|
-
linkState:
|
|
10485
|
+
linkState: links.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
|
|
10258
10486
|
exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
|
|
10259
10487
|
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
10260
10488
|
setup: {
|
|
10261
10489
|
fields: [
|
|
10262
10490
|
{
|
|
10263
|
-
label: "
|
|
10491
|
+
label: "Export targets",
|
|
10264
10492
|
value: `${healthy} / ${links.length} reachable`
|
|
10265
10493
|
},
|
|
10266
10494
|
{
|
|
@@ -10286,7 +10514,7 @@ var HaExportAddon = class extends require_ha_instance_settings.BaseAddon {
|
|
|
10286
10514
|
}
|
|
10287
10515
|
startTimer() {
|
|
10288
10516
|
this.stopTimer();
|
|
10289
|
-
const ms =
|
|
10517
|
+
const ms = this.book().reconcileIntervalSec() * 1e3;
|
|
10290
10518
|
this.reconcileTimer = setInterval(() => void this.reconcile("periodic"), ms);
|
|
10291
10519
|
}
|
|
10292
10520
|
stopTimer() {
|