@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { F as buildAddonRouteProvider, It as statusInstanceOf, Jt as BaseAddon, Ot as runExportedAction, Pt as sendToExportedTarget, Qt as baseUnitOf, Sn as EventCategory, U as consumableFormOf, W as consumableKindOf, X as deviceExportCapability, Zt as RevConflictError, _ as HvacModeSchema, _t as oauthIntegrationCapability, a as AlarmArmModeSchema, an as nodePin, bt as preconditionOf, c as CAMERA_SWITCH_ORDER, cn as boolean, d as ConsumableFormSchema, en as defineAddonMigrations, f as ConsumableItemSchema, fn as object, g as FanDirectionSchema, gn as unknown, h as ExportInstanceUnknownError, i as listHomeAssistantBrokers, in as isReadingQuantity, l as COCO_TO_MACRO, mn as string, on as _enum, p as ConsumableStatusSchema, sn as array, t as haInstanceSettingsPortsOver, tn as defineEntryKind, u as CameraSwitchIdSchema, v as MediaPlayerRepeatSchema, w as addonRoutesCapability, wt as pruneDeletedEntities, y as PLUG_APPLIANCE_TYPES } from "../ha-instance-settings-
|
|
1
|
+
import { F as buildAddonRouteProvider, It as statusInstanceOf, Jt as BaseAddon, Ot as runExportedAction, Pt as sendToExportedTarget, Qt as baseUnitOf, Sn as EventCategory, U as consumableFormOf, W as consumableKindOf, X as deviceExportCapability, Zt as RevConflictError, _ as HvacModeSchema, _t as oauthIntegrationCapability, a as AlarmArmModeSchema, an as nodePin, bt as preconditionOf, c as CAMERA_SWITCH_ORDER, cn as boolean, ct as integrationInstancesCapability, d as ConsumableFormSchema, dn as number, en as defineAddonMigrations, f as ConsumableItemSchema, fn as object, g as FanDirectionSchema, gn as unknown, h as ExportInstanceUnknownError, i as listHomeAssistantBrokers, in as isReadingQuantity, l as COCO_TO_MACRO, mn as string, on as _enum, p as ConsumableStatusSchema, sn as array, t as haInstanceSettingsPortsOver, tn as defineEntryKind, u as CameraSwitchIdSchema, v as MediaPlayerRepeatSchema, w as addonRoutesCapability, wt as pruneDeletedEntities, x as RecordIntegrationInstances, y as PLUG_APPLIANCE_TYPES } from "../ha-instance-settings-qhCNJbbn.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import "node:zlib";
|
|
4
4
|
import "node:fs";
|
|
@@ -5055,13 +5055,21 @@ function verifyExpiringUrl(input) {
|
|
|
5055
5055
|
/** The hub address roles a Home Assistant media link may be minted on. */
|
|
5056
5056
|
var HA_MEDIA_ROLES = ["internal", "publicEndpoint"];
|
|
5057
5057
|
/**
|
|
5058
|
-
* The base a media link is minted on: the hub's role (D734)
|
|
5059
|
-
*
|
|
5060
|
-
* when
|
|
5061
|
-
*
|
|
5058
|
+
* The base a media link is minted on: the hub's role (D734) the broker chose. Links reach
|
|
5059
|
+
* Home Assistant notifications on phones away from home, so a new broker chooses `publicEndpoint`;
|
|
5060
|
+
* when that resolves to nothing the `internal` address answers instead (a LAN Home Assistant still
|
|
5061
|
+
* gets an image). A broker that chose `internal` never falls back to public. Ranked by
|
|
5062
|
+
* `local-network`, never loopback (`null` when nothing resolves): a link Home Assistant cannot
|
|
5063
|
+
* open is worse than no image.
|
|
5062
5064
|
*/
|
|
5063
|
-
function haMediaBaseUrl(book, role = "
|
|
5064
|
-
|
|
5065
|
+
function haMediaBaseUrl(book, role = "publicEndpoint") {
|
|
5066
|
+
const chosen = book.roles[role].resolved;
|
|
5067
|
+
if (chosen !== null || role !== "publicEndpoint") return chosen;
|
|
5068
|
+
return book.roles.internal.resolved;
|
|
5069
|
+
}
|
|
5070
|
+
/** True when `haMediaBaseUrl` answered with the internal address because the public endpoint was chosen but unset. */
|
|
5071
|
+
function usedInternalFallback(book, role) {
|
|
5072
|
+
return role === "publicEndpoint" && book.roles.publicEndpoint.resolved === null && book.roles.internal.resolved !== null;
|
|
5065
5073
|
}
|
|
5066
5074
|
/** brokerId → media base, each broker on ITS role. Pure: the caller reads the palette per use. */
|
|
5067
5075
|
function mediaBasesByBroker(brokerIds, book, roleOf) {
|
|
@@ -5196,6 +5204,244 @@ async function planDropPerBrokerFlags(ports) {
|
|
|
5196
5204
|
}] };
|
|
5197
5205
|
}
|
|
5198
5206
|
//#endregion
|
|
5207
|
+
//#region src/ha-export/target-records.ts
|
|
5208
|
+
/**
|
|
5209
|
+
* An export target (#422): one `ha-export-targets` record per place the export reaches. It names
|
|
5210
|
+
* the Home Assistant connection it pushes to (`connectionId`, a broker id), which hub address
|
|
5211
|
+
* role its image links use, how often it re-announces, and the devices exported to it.
|
|
5212
|
+
*
|
|
5213
|
+
* The record is the integration instance itself (`integration-instances`, role `services`): the
|
|
5214
|
+
* operator creates, edits, switches and deletes targets from the Connectivity page, and the ids
|
|
5215
|
+
* are minted here (`hae_001`, ...). Several targets may reference one connection.
|
|
5216
|
+
*
|
|
5217
|
+
* This addon is the only writer of `devices`; the instance door writes the rest. Both go through
|
|
5218
|
+
* `patch` (a merge), so neither overwrites the other, and the in-memory copy here is the
|
|
5219
|
+
* synchronous read the event path needs, reloaded after every change.
|
|
5220
|
+
*/
|
|
5221
|
+
var HA_EXPORT_TARGETS_KIND_NAME = "ha-export-targets";
|
|
5222
|
+
var HA_TARGET_KIND = "ha-target";
|
|
5223
|
+
var TARGET_ID_PREFIX = "hae_";
|
|
5224
|
+
var haExportTargetsKind = defineEntryKind(HA_EXPORT_TARGETS_KIND_NAME, {
|
|
5225
|
+
title: "Home Assistant export targets",
|
|
5226
|
+
nameField: "name",
|
|
5227
|
+
schema: object({
|
|
5228
|
+
name: string().min(1),
|
|
5229
|
+
connectionId: string(),
|
|
5230
|
+
mediaRole: _enum(HA_MEDIA_ROLES),
|
|
5231
|
+
reconcileIntervalSec: number(),
|
|
5232
|
+
devices: array(string()),
|
|
5233
|
+
enabled: boolean().optional()
|
|
5234
|
+
}),
|
|
5235
|
+
writeTrigger: "an operator adds, edits, switches or deletes an export target, or exports a device to it"
|
|
5236
|
+
});
|
|
5237
|
+
/** What a target starts as: on, public endpoint, five-minute cadence, no device, no connection yet. */
|
|
5238
|
+
function newTargetBody(name) {
|
|
5239
|
+
return {
|
|
5240
|
+
name,
|
|
5241
|
+
connectionId: "",
|
|
5242
|
+
mediaRole: "publicEndpoint",
|
|
5243
|
+
reconcileIntervalSec: 300,
|
|
5244
|
+
devices: [],
|
|
5245
|
+
enabled: true
|
|
5246
|
+
};
|
|
5247
|
+
}
|
|
5248
|
+
/** The id after the highest `hae_NNN` among `existing`. */
|
|
5249
|
+
function nextTargetId(existing) {
|
|
5250
|
+
let highest = 0;
|
|
5251
|
+
for (const id of existing) {
|
|
5252
|
+
if (!id.startsWith("hae_")) continue;
|
|
5253
|
+
const n = Number(id.slice(4));
|
|
5254
|
+
if (Number.isInteger(n) && n > highest) highest = n;
|
|
5255
|
+
}
|
|
5256
|
+
return `${TARGET_ID_PREFIX}${String(highest + 1).padStart(3, "0")}`;
|
|
5257
|
+
}
|
|
5258
|
+
/** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
|
|
5259
|
+
async function readAllTargets(handle) {
|
|
5260
|
+
const rows = /* @__PURE__ */ new Map();
|
|
5261
|
+
let cursor;
|
|
5262
|
+
do {
|
|
5263
|
+
const page = await handle.query({
|
|
5264
|
+
limit: 1e3,
|
|
5265
|
+
...cursor === void 0 ? {} : { cursor }
|
|
5266
|
+
});
|
|
5267
|
+
for (const row of page.records) rows.set(row.id, row.data);
|
|
5268
|
+
cursor = page.nextCursor ?? void 0;
|
|
5269
|
+
} while (cursor !== void 0);
|
|
5270
|
+
return rows;
|
|
5271
|
+
}
|
|
5272
|
+
function membershipOf$1(rows) {
|
|
5273
|
+
const out = {};
|
|
5274
|
+
for (const [id, row] of rows) if (row.devices.length > 0) out[id] = row.devices;
|
|
5275
|
+
return out;
|
|
5276
|
+
}
|
|
5277
|
+
function sameList(a, b) {
|
|
5278
|
+
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
5279
|
+
}
|
|
5280
|
+
/**
|
|
5281
|
+
* The in-memory copy of the target records. `membership()` keeps its identity until a device
|
|
5282
|
+
* list changes.
|
|
5283
|
+
*/
|
|
5284
|
+
var ExportTargets = class ExportTargets {
|
|
5285
|
+
handle;
|
|
5286
|
+
rows;
|
|
5287
|
+
memberships;
|
|
5288
|
+
constructor(handle, rows) {
|
|
5289
|
+
this.handle = handle;
|
|
5290
|
+
this.rows = rows;
|
|
5291
|
+
this.memberships = membershipOf$1(rows);
|
|
5292
|
+
}
|
|
5293
|
+
/** A failed read throws: serving an empty set would unexport every device on the next pass. */
|
|
5294
|
+
static async load(handle) {
|
|
5295
|
+
return new ExportTargets(handle, await readAllTargets(handle));
|
|
5296
|
+
}
|
|
5297
|
+
/** Re-read the records after the instance door changed one; a failed read keeps the old copy. */
|
|
5298
|
+
async reload() {
|
|
5299
|
+
this.adopt(await readAllTargets(this.handle));
|
|
5300
|
+
}
|
|
5301
|
+
ids() {
|
|
5302
|
+
return [...this.rows.keys()];
|
|
5303
|
+
}
|
|
5304
|
+
has(id) {
|
|
5305
|
+
return this.rows.has(id);
|
|
5306
|
+
}
|
|
5307
|
+
get(id) {
|
|
5308
|
+
return this.rows.get(id);
|
|
5309
|
+
}
|
|
5310
|
+
name(id) {
|
|
5311
|
+
return this.rows.get(id)?.name ?? id;
|
|
5312
|
+
}
|
|
5313
|
+
/** The target's own switch; an absent field means on. */
|
|
5314
|
+
isEnabled(id) {
|
|
5315
|
+
const row = this.rows.get(id);
|
|
5316
|
+
return row !== void 0 && row.enabled !== false;
|
|
5317
|
+
}
|
|
5318
|
+
connectionId(id) {
|
|
5319
|
+
return this.rows.get(id)?.connectionId ?? "";
|
|
5320
|
+
}
|
|
5321
|
+
mediaRole(id) {
|
|
5322
|
+
return this.rows.get(id)?.mediaRole ?? "publicEndpoint";
|
|
5323
|
+
}
|
|
5324
|
+
/** The cadence of the quickest enabled target (the addon runs one timer); the default when none. */
|
|
5325
|
+
reconcileIntervalSec() {
|
|
5326
|
+
const seconds = [...this.rows.values()].filter((row) => row.enabled !== false).map((row) => row.reconcileIntervalSec);
|
|
5327
|
+
if (seconds.length === 0) return 300;
|
|
5328
|
+
return Math.max(30, Math.min(...seconds));
|
|
5329
|
+
}
|
|
5330
|
+
/** targetId → exported device ids, for the targets that export at least one. */
|
|
5331
|
+
membership() {
|
|
5332
|
+
return this.memberships;
|
|
5333
|
+
}
|
|
5334
|
+
/**
|
|
5335
|
+
* Persist the device lists that differ from the held ones. A target that does not exist is
|
|
5336
|
+
* never created here (an id the records do not hold is refused by the caller).
|
|
5337
|
+
*/
|
|
5338
|
+
async setMembership(next) {
|
|
5339
|
+
let rows = this.rows;
|
|
5340
|
+
for (const id of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
|
|
5341
|
+
const current = rows.get(id);
|
|
5342
|
+
if (current === void 0) continue;
|
|
5343
|
+
const devices = next[id] ?? [];
|
|
5344
|
+
if (sameList(current.devices, devices)) continue;
|
|
5345
|
+
await this.handle.patch(id, { devices: [...devices] });
|
|
5346
|
+
rows = new Map(rows).set(id, {
|
|
5347
|
+
...current,
|
|
5348
|
+
devices: [...devices]
|
|
5349
|
+
});
|
|
5350
|
+
this.adopt(rows);
|
|
5351
|
+
}
|
|
5352
|
+
}
|
|
5353
|
+
adopt(rows) {
|
|
5354
|
+
const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
|
|
5355
|
+
this.rows = rows;
|
|
5356
|
+
if (devicesChanged) this.memberships = membershipOf$1(rows);
|
|
5357
|
+
}
|
|
5358
|
+
};
|
|
5359
|
+
//#endregion
|
|
5360
|
+
//#region src/ha-export/migrations/targets-from-links.ts
|
|
5361
|
+
var RECONCILE_INTERVAL_KEY = "reconcileIntervalSec";
|
|
5362
|
+
var KNOWN_BROKERS_KEY = "knownBrokers";
|
|
5363
|
+
var sameDevices$1 = (a, b) => a.length === b.length && a.every((device, index) => device === b[index]);
|
|
5364
|
+
function intervalOf(stored) {
|
|
5365
|
+
const value = stored[RECONCILE_INTERVAL_KEY];
|
|
5366
|
+
return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : 300;
|
|
5367
|
+
}
|
|
5368
|
+
function matches(target, expected) {
|
|
5369
|
+
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);
|
|
5370
|
+
}
|
|
5371
|
+
async function moveLinksToTargets(ports) {
|
|
5372
|
+
const links = await ports.readLinks();
|
|
5373
|
+
if (links.size === 0) return {
|
|
5374
|
+
outcome: "already-done",
|
|
5375
|
+
summary: { moved: 0 }
|
|
5376
|
+
};
|
|
5377
|
+
const targets = await ports.readTargets();
|
|
5378
|
+
const reconcileIntervalSec = intervalOf(await ports.readStored());
|
|
5379
|
+
const taken = new Set([...targets.values()].map((target) => target.connectionId));
|
|
5380
|
+
const ids = new Set(targets.keys());
|
|
5381
|
+
const created = /* @__PURE__ */ new Map();
|
|
5382
|
+
for (const brokerId of [...links.keys()].sort()) {
|
|
5383
|
+
if (taken.has(brokerId)) continue;
|
|
5384
|
+
const link = links.get(brokerId);
|
|
5385
|
+
if (link === void 0) continue;
|
|
5386
|
+
const body = {
|
|
5387
|
+
name: link.name,
|
|
5388
|
+
connectionId: brokerId,
|
|
5389
|
+
mediaRole: link.mediaRole,
|
|
5390
|
+
reconcileIntervalSec,
|
|
5391
|
+
devices: [...link.devices],
|
|
5392
|
+
enabled: link.exportTo
|
|
5393
|
+
};
|
|
5394
|
+
const id = nextTargetId(ids);
|
|
5395
|
+
if (await ports.putTarget(id, body) === "exists") throw new Error(`the export target id ${id} is already taken; nothing was cleared`);
|
|
5396
|
+
ids.add(id);
|
|
5397
|
+
created.set(id, body);
|
|
5398
|
+
}
|
|
5399
|
+
if (created.size === 0) return {
|
|
5400
|
+
outcome: "already-done",
|
|
5401
|
+
summary: { moved: 0 }
|
|
5402
|
+
};
|
|
5403
|
+
const held = await ports.readTargets();
|
|
5404
|
+
for (const [id, expected] of created) {
|
|
5405
|
+
const row = held.get(id);
|
|
5406
|
+
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`);
|
|
5407
|
+
}
|
|
5408
|
+
return {
|
|
5409
|
+
outcome: "done",
|
|
5410
|
+
summary: {
|
|
5411
|
+
moved: created.size,
|
|
5412
|
+
devices: [...created.values()].reduce((sum, target) => sum + target.devices.length, 0)
|
|
5413
|
+
}
|
|
5414
|
+
};
|
|
5415
|
+
}
|
|
5416
|
+
async function dropLinksAndCache(ports) {
|
|
5417
|
+
const links = await ports.readLinks();
|
|
5418
|
+
const stored = await ports.readStored();
|
|
5419
|
+
const targets = await ports.readTargets();
|
|
5420
|
+
const movable = [];
|
|
5421
|
+
const parked = [];
|
|
5422
|
+
for (const [brokerId, link] of links) {
|
|
5423
|
+
const target = [...targets.values()].find((candidate) => candidate.connectionId === brokerId);
|
|
5424
|
+
if (target !== void 0 && sameDevices$1(target.devices, link.devices)) movable.push(brokerId);
|
|
5425
|
+
else parked.push(brokerId);
|
|
5426
|
+
}
|
|
5427
|
+
if (parked.length > 0) ports.warn(`homeassistant-export: the link records of ${parked.join(", ")} are kept: no export target holds the same devices`);
|
|
5428
|
+
if (movable.length > 0) await ports.deleteLinks(movable);
|
|
5429
|
+
const keys = parked.length > 0 ? [] : [RECONCILE_INTERVAL_KEY, KNOWN_BROKERS_KEY].filter((key) => key in stored);
|
|
5430
|
+
if (keys.length > 0) await ports.clearSettingKeys(keys);
|
|
5431
|
+
if (movable.length === 0 && keys.length === 0) return {
|
|
5432
|
+
outcome: "already-done",
|
|
5433
|
+
summary: { parked: parked.length }
|
|
5434
|
+
};
|
|
5435
|
+
return {
|
|
5436
|
+
outcome: "done",
|
|
5437
|
+
summary: {
|
|
5438
|
+
deleted: movable.length,
|
|
5439
|
+
parked: parked.length,
|
|
5440
|
+
keys: keys.length
|
|
5441
|
+
}
|
|
5442
|
+
};
|
|
5443
|
+
}
|
|
5444
|
+
//#endregion
|
|
5199
5445
|
//#region src/ha-export/migrations/links-to-record.ts
|
|
5200
5446
|
/**
|
|
5201
5447
|
* What the export carries to a Home Assistant instance moves into the instance's
|
|
@@ -5220,7 +5466,7 @@ var MEMBERSHIP_KEY = "membership";
|
|
|
5220
5466
|
var EXPORT_PREFIX = "exportTo:";
|
|
5221
5467
|
var ROLE_PREFIX = "mediaRole:";
|
|
5222
5468
|
var roleOf = (value) => HA_MEDIA_ROLES.find((role) => role === value);
|
|
5223
|
-
function membershipOf
|
|
5469
|
+
function membershipOf(value) {
|
|
5224
5470
|
const out = /* @__PURE__ */ new Map();
|
|
5225
5471
|
if (typeof value !== "object" || value === null || Array.isArray(value)) return out;
|
|
5226
5472
|
for (const [brokerId, devices] of Object.entries(value)) {
|
|
@@ -5243,7 +5489,9 @@ function integrationByBroker(rows, warn) {
|
|
|
5243
5489
|
}
|
|
5244
5490
|
/** Every broker the old shape has something for, with the value the old reader would have used. */
|
|
5245
5491
|
function legacyLinks(stored, rows, warn) {
|
|
5246
|
-
const membership = membershipOf
|
|
5492
|
+
const membership = membershipOf(stored[MEMBERSHIP_KEY]);
|
|
5493
|
+
const unreadable = rows.find((row) => Object.keys(row.settings).length === 0);
|
|
5494
|
+
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`);
|
|
5247
5495
|
const integrations = integrationByBroker(rows, warn);
|
|
5248
5496
|
const brokers = new Set(membership.keys());
|
|
5249
5497
|
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));
|
|
@@ -5329,6 +5577,8 @@ async function planDropMovedLinks(ports) {
|
|
|
5329
5577
|
//#endregion
|
|
5330
5578
|
//#region src/ha-export/migrations/index.ts
|
|
5331
5579
|
var LINKS_TO_RECORD_ID = "homeassistant-export/2026-11-04-links-to-record";
|
|
5580
|
+
var DROP_MOVED_LINKS_ID = "homeassistant-export/2026-11-04-drop-moved-links";
|
|
5581
|
+
var TARGETS_FROM_LINKS_ID = "homeassistant-export/2026-11-04-targets-from-links";
|
|
5332
5582
|
/** The `homeassistant-export` catalogue (D987): offered to the engine when the addon initialises. */
|
|
5333
5583
|
var ADDON_MIGRATIONS = [
|
|
5334
5584
|
{
|
|
@@ -5397,7 +5647,7 @@ var ADDON_MIGRATIONS = [
|
|
|
5397
5647
|
},
|
|
5398
5648
|
{
|
|
5399
5649
|
meta: {
|
|
5400
|
-
id:
|
|
5650
|
+
id: DROP_MOVED_LINKS_ID,
|
|
5401
5651
|
owner: "homeassistant-export",
|
|
5402
5652
|
level: "addon-state",
|
|
5403
5653
|
seq: 5,
|
|
@@ -5410,10 +5660,51 @@ var ADDON_MIGRATIONS = [
|
|
|
5410
5660
|
fixtureMark: "the reconcile interval the migration does not know"
|
|
5411
5661
|
},
|
|
5412
5662
|
plan: planDropMovedLinks
|
|
5663
|
+
},
|
|
5664
|
+
{
|
|
5665
|
+
meta: {
|
|
5666
|
+
id: TARGETS_FROM_LINKS_ID,
|
|
5667
|
+
owner: "homeassistant-export",
|
|
5668
|
+
level: "addon-state",
|
|
5669
|
+
seq: 6,
|
|
5670
|
+
dependsOn: [LINKS_TO_RECORD_ID],
|
|
5671
|
+
store: "settings",
|
|
5672
|
+
scope: "cluster",
|
|
5673
|
+
onFailure: "block-owner",
|
|
5674
|
+
bodyKind: "procedure",
|
|
5675
|
+
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",
|
|
5676
|
+
fixtureMark: "the exported entities the migration does not touch"
|
|
5677
|
+
},
|
|
5678
|
+
procedure: moveLinksToTargets
|
|
5679
|
+
},
|
|
5680
|
+
{
|
|
5681
|
+
meta: {
|
|
5682
|
+
id: "homeassistant-export/2026-11-04-drop-links-and-cache",
|
|
5683
|
+
owner: "homeassistant-export",
|
|
5684
|
+
level: "addon-state",
|
|
5685
|
+
seq: 7,
|
|
5686
|
+
dependsOn: [TARGETS_FROM_LINKS_ID],
|
|
5687
|
+
store: "settings",
|
|
5688
|
+
scope: "cluster",
|
|
5689
|
+
onFailure: "block-owner",
|
|
5690
|
+
bodyKind: "procedure",
|
|
5691
|
+
description: "Delete each ha-export-links record whose target holds the same devices, then the reconcileIntervalSec and knownBrokers settings",
|
|
5692
|
+
fixtureMark: "the exported entities the migration does not touch"
|
|
5693
|
+
},
|
|
5694
|
+
procedure: dropLinksAndCache
|
|
5413
5695
|
}
|
|
5414
5696
|
];
|
|
5415
|
-
|
|
5416
|
-
|
|
5697
|
+
//#endregion
|
|
5698
|
+
//#region src/ha-export/migrations/legacy-links-kind.ts
|
|
5699
|
+
/**
|
|
5700
|
+
* The `ha-export-links` record kind (planning#408 P4): one record per Home Assistant broker with
|
|
5701
|
+
* its export switch, media role and devices. Export targets (`ha-export-targets`) replaced it;
|
|
5702
|
+
* the kind is declared ONLY so the migrations can read it and delete its rows. No runtime code
|
|
5703
|
+
* outside `migrations/` names it (`no-legacy-links-reader.spec.ts`).
|
|
5704
|
+
*/
|
|
5705
|
+
var HA_EXPORT_LINKS_KIND_NAME = "ha-export-links";
|
|
5706
|
+
var haExportLinksKind = defineEntryKind(HA_EXPORT_LINKS_KIND_NAME, {
|
|
5707
|
+
title: "Home Assistant export links (retired)",
|
|
5417
5708
|
nameField: "name",
|
|
5418
5709
|
schema: object({
|
|
5419
5710
|
name: string().min(1),
|
|
@@ -5421,16 +5712,12 @@ var haExportLinksKind = defineEntryKind("ha-export-links", {
|
|
|
5421
5712
|
mediaRole: _enum(HA_MEDIA_ROLES),
|
|
5422
5713
|
devices: array(string())
|
|
5423
5714
|
}),
|
|
5424
|
-
writeTrigger: "
|
|
5715
|
+
writeTrigger: "only the migrations that move the links into export targets write it"
|
|
5425
5716
|
});
|
|
5426
|
-
/**
|
|
5427
|
-
function
|
|
5428
|
-
|
|
5429
|
-
|
|
5430
|
-
exportTo: false,
|
|
5431
|
-
mediaRole: "internal",
|
|
5432
|
-
devices: []
|
|
5433
|
-
};
|
|
5717
|
+
/** Declare the retired kind and hand back its handle, for the migration ports. */
|
|
5718
|
+
async function declareLegacyLinks(records) {
|
|
5719
|
+
await records.declare({ [HA_EXPORT_LINKS_KIND_NAME]: haExportLinksKind });
|
|
5720
|
+
return records.kind(haExportLinksKind);
|
|
5434
5721
|
}
|
|
5435
5722
|
/** Every record of the kind, once; a failed read throws (the caller must not serve "nothing"). */
|
|
5436
5723
|
async function readAllLinks(handle) {
|
|
@@ -5446,123 +5733,12 @@ async function readAllLinks(handle) {
|
|
|
5446
5733
|
} while (cursor !== void 0);
|
|
5447
5734
|
return rows;
|
|
5448
5735
|
}
|
|
5449
|
-
function membershipOf(rows) {
|
|
5450
|
-
const out = {};
|
|
5451
|
-
for (const [brokerId, row] of rows) if (row.devices.length > 0) out[brokerId] = row.devices;
|
|
5452
|
-
return out;
|
|
5453
|
-
}
|
|
5454
|
-
/**
|
|
5455
|
-
* The in-memory copy of the records. This addon is their only writer, so the copy is the
|
|
5456
|
-
* synchronous read the event path needs; a write lands in the record first and in the copy
|
|
5457
|
-
* after it. `membership()` keeps its identity until a device list changes.
|
|
5458
|
-
*/
|
|
5459
|
-
var ExportLinks = class ExportLinks {
|
|
5460
|
-
handle;
|
|
5461
|
-
rows;
|
|
5462
|
-
memberships;
|
|
5463
|
-
constructor(handle, rows) {
|
|
5464
|
-
this.handle = handle;
|
|
5465
|
-
this.rows = rows;
|
|
5466
|
-
this.memberships = membershipOf(rows);
|
|
5467
|
-
}
|
|
5468
|
-
static async load(handle) {
|
|
5469
|
-
return new ExportLinks(handle, await readAllLinks(handle));
|
|
5470
|
-
}
|
|
5471
|
-
isEnabled(brokerId) {
|
|
5472
|
-
return this.rows.get(brokerId)?.exportTo === true;
|
|
5473
|
-
}
|
|
5474
|
-
mediaRole(brokerId) {
|
|
5475
|
-
return this.rows.get(brokerId)?.mediaRole ?? "internal";
|
|
5476
|
-
}
|
|
5477
|
-
/** brokerId → exported device ids, for the brokers that export at least one. */
|
|
5478
|
-
membership() {
|
|
5479
|
-
return this.memberships;
|
|
5480
|
-
}
|
|
5481
|
-
brokerIds() {
|
|
5482
|
-
return [...this.rows.keys()];
|
|
5483
|
-
}
|
|
5484
|
-
/** Change the switch and / or the role of one instance; creates the record on first use. */
|
|
5485
|
-
async setFlags(brokerId, name, patch) {
|
|
5486
|
-
const current = this.rows.get(brokerId) ?? defaultLinks(name);
|
|
5487
|
-
const next = {
|
|
5488
|
-
...current,
|
|
5489
|
-
exportTo: patch.exportTo ?? current.exportTo,
|
|
5490
|
-
mediaRole: patch.mediaRole ?? current.mediaRole
|
|
5491
|
-
};
|
|
5492
|
-
if (this.rows.has(brokerId) && sameLinks(current, next)) return;
|
|
5493
|
-
await this.write(brokerId, next);
|
|
5494
|
-
}
|
|
5495
|
-
/** Persist the device lists that differ from the held ones; returns nothing to do as a no-op. */
|
|
5496
|
-
async setMembership(next, nameOf) {
|
|
5497
|
-
for (const brokerId of new Set([...Object.keys(next), ...Object.keys(this.memberships)])) {
|
|
5498
|
-
const devices = next[brokerId] ?? [];
|
|
5499
|
-
const current = this.rows.get(brokerId);
|
|
5500
|
-
if (sameList(current?.devices ?? [], devices)) continue;
|
|
5501
|
-
await this.write(brokerId, {
|
|
5502
|
-
...current ?? defaultLinks(nameOf(brokerId)),
|
|
5503
|
-
devices: [...devices]
|
|
5504
|
-
});
|
|
5505
|
-
}
|
|
5506
|
-
}
|
|
5507
|
-
/** Forget the instances that no longer exist; returns the ids it removed. */
|
|
5508
|
-
async prune(knownBrokerIds) {
|
|
5509
|
-
const known = new Set(knownBrokerIds);
|
|
5510
|
-
const gone = [...this.rows.keys()].filter((id) => !known.has(id));
|
|
5511
|
-
for (const id of gone) await this.handle.delete(id);
|
|
5512
|
-
if (gone.length > 0) this.adopt(new Map([...this.rows].filter(([id]) => known.has(id))));
|
|
5513
|
-
return gone;
|
|
5514
|
-
}
|
|
5515
|
-
async write(brokerId, body) {
|
|
5516
|
-
await this.handle.put({
|
|
5517
|
-
id: brokerId,
|
|
5518
|
-
data: body
|
|
5519
|
-
});
|
|
5520
|
-
this.adopt(new Map(this.rows).set(brokerId, body));
|
|
5521
|
-
}
|
|
5522
|
-
adopt(rows) {
|
|
5523
|
-
const devicesChanged = [...new Set([...rows.keys(), ...this.rows.keys()])].some((id) => !sameList(rows.get(id)?.devices ?? [], this.rows.get(id)?.devices ?? []));
|
|
5524
|
-
this.rows = rows;
|
|
5525
|
-
if (devicesChanged) this.memberships = membershipOf(rows);
|
|
5526
|
-
}
|
|
5527
|
-
};
|
|
5528
|
-
function sameList(a, b) {
|
|
5529
|
-
return a.length === b.length && a.every((value, index) => value === b[index]);
|
|
5530
|
-
}
|
|
5531
|
-
function sameLinks(a, b) {
|
|
5532
|
-
return a.exportTo === b.exportTo && a.mediaRole === b.mediaRole && sameList(a.devices, b.devices);
|
|
5533
|
-
}
|
|
5534
|
-
/**
|
|
5535
|
-
* The settings form names an instance's two facts as the dynamic keys `exportTo:<brokerId>` and
|
|
5536
|
-
* `mediaRole:<brokerId>`. They are form fields, not stored keys: a read overlays them from the
|
|
5537
|
-
* records and a write is routed to the record of the broker they name.
|
|
5538
|
-
*/
|
|
5539
|
-
var exportToFormKey = (brokerId) => `exportTo:${brokerId}`;
|
|
5540
|
-
var mediaRoleFormKey = (brokerId) => `mediaRole:${brokerId}`;
|
|
5541
|
-
function parseLinkFormKey(key) {
|
|
5542
|
-
for (const flag of ["exportTo", "mediaRole"]) {
|
|
5543
|
-
const prefix = `${flag}:`;
|
|
5544
|
-
if (key.startsWith(prefix) && key.length > prefix.length) return {
|
|
5545
|
-
flag,
|
|
5546
|
-
brokerId: key.slice(prefix.length)
|
|
5547
|
-
};
|
|
5548
|
-
}
|
|
5549
|
-
return null;
|
|
5550
|
-
}
|
|
5551
|
-
/** The form's dynamic keys carrying each instance's own value. */
|
|
5552
|
-
function linkFormOverlay(links) {
|
|
5553
|
-
const overlay = {};
|
|
5554
|
-
for (const brokerId of links.brokerIds()) {
|
|
5555
|
-
overlay[exportToFormKey(brokerId)] = links.isEnabled(brokerId);
|
|
5556
|
-
overlay[mediaRoleFormKey(brokerId)] = links.mediaRole(brokerId);
|
|
5557
|
-
}
|
|
5558
|
-
return overlay;
|
|
5559
|
-
}
|
|
5560
5736
|
//#endregion
|
|
5561
5737
|
//#region src/ha-export/migrations/bind-ports.ts
|
|
5562
5738
|
/**
|
|
5563
5739
|
* Binds the `homeassistant-export` catalogue to the addon's stores: the settings, the hub's
|
|
5564
|
-
* `integrations` capability (the old home of the two flags, read by the migrations only)
|
|
5565
|
-
* `ha-export-links` kind.
|
|
5740
|
+
* `integrations` capability (the old home of the two flags, read by the migrations only), the
|
|
5741
|
+
* retired `ha-export-links` kind and the `ha-export-targets` kind the links move into.
|
|
5566
5742
|
*/
|
|
5567
5743
|
function haExportMigrationPorts(deps) {
|
|
5568
5744
|
return {
|
|
@@ -5582,10 +5758,118 @@ function haExportMigrationPorts(deps) {
|
|
|
5582
5758
|
}
|
|
5583
5759
|
return "created";
|
|
5584
5760
|
},
|
|
5585
|
-
readLinks: () => readAllLinks(deps.links)
|
|
5761
|
+
readLinks: () => readAllLinks(deps.links),
|
|
5762
|
+
readTargets: () => readAllTargets(deps.targets),
|
|
5763
|
+
putTarget: async (id, body) => {
|
|
5764
|
+
try {
|
|
5765
|
+
await deps.targets.put({
|
|
5766
|
+
id,
|
|
5767
|
+
data: body
|
|
5768
|
+
}, { ifRev: 0 });
|
|
5769
|
+
} catch (err) {
|
|
5770
|
+
if (err instanceof RevConflictError) return "exists";
|
|
5771
|
+
throw err;
|
|
5772
|
+
}
|
|
5773
|
+
return "created";
|
|
5774
|
+
},
|
|
5775
|
+
deleteLinks: async (brokerIds) => {
|
|
5776
|
+
await deps.links.deleteMany(brokerIds);
|
|
5777
|
+
},
|
|
5778
|
+
clearSettingKeys: deps.clearSettingKeys
|
|
5586
5779
|
};
|
|
5587
5780
|
}
|
|
5588
5781
|
//#endregion
|
|
5782
|
+
//#region src/ha-export/target-form.ts
|
|
5783
|
+
var NS = "provider-homeassistant:globalSettings";
|
|
5784
|
+
function buildTargetForm() {
|
|
5785
|
+
return { sections: [{
|
|
5786
|
+
id: "ha-export-target",
|
|
5787
|
+
title: "Home Assistant target",
|
|
5788
|
+
titleKey: `${NS}.s_ha-export-target.title`,
|
|
5789
|
+
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).",
|
|
5790
|
+
descriptionKey: `${NS}.s_ha-export-target.description`,
|
|
5791
|
+
columns: 1,
|
|
5792
|
+
fields: [
|
|
5793
|
+
{
|
|
5794
|
+
type: "text",
|
|
5795
|
+
key: "name",
|
|
5796
|
+
label: "Name",
|
|
5797
|
+
labelKey: `${NS}.s_ha-export-target.name.label`
|
|
5798
|
+
},
|
|
5799
|
+
{
|
|
5800
|
+
type: "broker-select",
|
|
5801
|
+
key: "connectionId",
|
|
5802
|
+
label: "Home Assistant connection",
|
|
5803
|
+
labelKey: `${NS}.s_ha-export-target.connectionId.label`,
|
|
5804
|
+
description: "The Home Assistant instance this target pushes to.",
|
|
5805
|
+
descriptionKey: `${NS}.s_ha-export-target.connectionId.description`,
|
|
5806
|
+
brokerKind: "home-assistant"
|
|
5807
|
+
},
|
|
5808
|
+
{
|
|
5809
|
+
type: "select",
|
|
5810
|
+
key: "mediaRole",
|
|
5811
|
+
label: "Image links",
|
|
5812
|
+
labelKey: `${NS}.s_ha-export-target.mediaRole.label`,
|
|
5813
|
+
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.",
|
|
5814
|
+
descriptionKey: `${NS}.s_ha-export-target.mediaRole.description`,
|
|
5815
|
+
options: [{
|
|
5816
|
+
value: "internal",
|
|
5817
|
+
label: "Internal (LAN address)"
|
|
5818
|
+
}, {
|
|
5819
|
+
value: "publicEndpoint",
|
|
5820
|
+
label: "Public endpoint (reachable from outside)"
|
|
5821
|
+
}],
|
|
5822
|
+
default: "publicEndpoint"
|
|
5823
|
+
}
|
|
5824
|
+
]
|
|
5825
|
+
}, {
|
|
5826
|
+
id: "ha-export-advanced",
|
|
5827
|
+
title: "Advanced",
|
|
5828
|
+
titleKey: `${NS}.s_ha-export-advanced.title`,
|
|
5829
|
+
style: "accordion",
|
|
5830
|
+
defaultCollapsed: true,
|
|
5831
|
+
columns: 1,
|
|
5832
|
+
fields: [{
|
|
5833
|
+
type: "number",
|
|
5834
|
+
key: "reconcileIntervalSec",
|
|
5835
|
+
label: "Reconcile interval (s)",
|
|
5836
|
+
labelKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.label`,
|
|
5837
|
+
description: `Full re-announce and re-push cadence; at least 30 s.`,
|
|
5838
|
+
descriptionKey: `${NS}.s_ha-export-advanced.reconcileIntervalSec.description`,
|
|
5839
|
+
default: 300
|
|
5840
|
+
}]
|
|
5841
|
+
}] };
|
|
5842
|
+
}
|
|
5843
|
+
//#endregion
|
|
5844
|
+
//#region src/ha-export/target-instances.ts
|
|
5845
|
+
/**
|
|
5846
|
+
* The export targets as `integration-instances` (role `services`, D996): the operator adds,
|
|
5847
|
+
* edits, switches and deletes targets from the Connectivity page. A target's id is MINTED
|
|
5848
|
+
* (`hae_001`, ...) and names its Home Assistant connection as a reference, so several targets
|
|
5849
|
+
* may push to one Home Assistant.
|
|
5850
|
+
*/
|
|
5851
|
+
function openTargetInstances(records, deps) {
|
|
5852
|
+
return new RecordIntegrationInstances(records, haExportTargetsKind, {
|
|
5853
|
+
description: {
|
|
5854
|
+
role: "services",
|
|
5855
|
+
instanceMode: "multiple",
|
|
5856
|
+
kind: "target",
|
|
5857
|
+
canTest: false,
|
|
5858
|
+
kinds: [{
|
|
5859
|
+
id: HA_TARGET_KIND,
|
|
5860
|
+
label: "Home Assistant"
|
|
5861
|
+
}]
|
|
5862
|
+
},
|
|
5863
|
+
formSchema: () => buildTargetForm(),
|
|
5864
|
+
initialBody: (name) => newTargetBody(name),
|
|
5865
|
+
mintId: () => nextTargetId(deps.book().ids()),
|
|
5866
|
+
kindOf: () => HA_TARGET_KIND,
|
|
5867
|
+
health: (body, id) => ({ status: deps.health(id, body) }),
|
|
5868
|
+
configured: (body) => deps.connectionExists(body.connectionId),
|
|
5869
|
+
onChanged: deps.onChanged
|
|
5870
|
+
});
|
|
5871
|
+
}
|
|
5872
|
+
//#endregion
|
|
5589
5873
|
//#region src/ha-export/membership.ts
|
|
5590
5874
|
function devicesForBroker(membership, brokerId) {
|
|
5591
5875
|
return membership[brokerId] ?? [];
|
|
@@ -5612,14 +5896,6 @@ function setExposed(membership, brokerId, deviceId, exposed) {
|
|
|
5612
5896
|
[brokerId]: next
|
|
5613
5897
|
};
|
|
5614
5898
|
}
|
|
5615
|
-
/** Same brokers, same order, same names — nothing for the form to re-render. */
|
|
5616
|
-
function sameBrokers(a, b) {
|
|
5617
|
-
if (a.length !== b.length) return false;
|
|
5618
|
-
return a.every((broker, index) => {
|
|
5619
|
-
const other = b[index];
|
|
5620
|
-
return other !== void 0 && other.id === broker.id && other.name === broker.name;
|
|
5621
|
-
});
|
|
5622
|
-
}
|
|
5623
5899
|
//#endregion
|
|
5624
5900
|
//#region src/ha-export/node-reads.ts
|
|
5625
5901
|
var UNREAD = {
|
|
@@ -7673,16 +7949,15 @@ var PTZ_MOVE_BY_DIRECTION = {
|
|
|
7673
7949
|
zoom_in: { zoom: .1 },
|
|
7674
7950
|
zoom_out: { zoom: -.1 }
|
|
7675
7951
|
};
|
|
7676
|
-
var DEFAULT_CONFIG = {
|
|
7677
|
-
|
|
7678
|
-
|
|
7679
|
-
|
|
7680
|
-
actions: [],
|
|
7681
|
-
targets: []
|
|
7682
|
-
}
|
|
7683
|
-
};
|
|
7952
|
+
var DEFAULT_CONFIG = { exportedEntities: {
|
|
7953
|
+
actions: [],
|
|
7954
|
+
targets: []
|
|
7955
|
+
} };
|
|
7684
7956
|
var HaExportAddon = class extends BaseAddon {
|
|
7685
7957
|
links = /* @__PURE__ */ new Map();
|
|
7958
|
+
instances = null;
|
|
7959
|
+
/** The Home Assistant connections (broker ids) the last `broker.list` answered. */
|
|
7960
|
+
connections = /* @__PURE__ */ new Set();
|
|
7686
7961
|
/** deviceKey → the device it addresses. Rebuilt on every reconcile. */
|
|
7687
7962
|
exported = /* @__PURE__ */ new Map();
|
|
7688
7963
|
/** numeric deviceId → deviceKey, for the event path. */
|
|
@@ -7789,15 +8064,26 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7789
8064
|
async onInitialize() {
|
|
7790
8065
|
const records = this.ctx.records;
|
|
7791
8066
|
if (records === void 0) throw new Error("ha-export: records are not available");
|
|
7792
|
-
await records.declare({ "ha-export-
|
|
7793
|
-
const
|
|
8067
|
+
await records.declare({ "ha-export-targets": haExportTargetsKind });
|
|
8068
|
+
const targetsHandle = records.kind(haExportTargetsKind);
|
|
8069
|
+
const legacyLinks = await declareLegacyLinks(records);
|
|
7794
8070
|
await this.runMigrations(defineAddonMigrations(ADDON_MIGRATIONS, haExportMigrationPorts({
|
|
7795
8071
|
readStored: async () => this.resolveGlobalStore(),
|
|
7796
8072
|
api: this.ctx.api,
|
|
7797
|
-
links:
|
|
8073
|
+
links: legacyLinks,
|
|
8074
|
+
targets: targetsHandle,
|
|
8075
|
+
clearSettingKeys: async (keys) => {
|
|
8076
|
+
await this.updateGlobalSettingsAsOperator(Object.fromEntries(keys.map((key) => [key, null])));
|
|
8077
|
+
},
|
|
7798
8078
|
warn: (message) => this.ctx.logger.warn(message)
|
|
7799
8079
|
})));
|
|
7800
|
-
this.
|
|
8080
|
+
this.targetBook = await ExportTargets.load(targetsHandle);
|
|
8081
|
+
this.instances = openTargetInstances(records, {
|
|
8082
|
+
book: () => this.book(),
|
|
8083
|
+
connectionExists: (connectionId) => this.connectionExists(connectionId),
|
|
8084
|
+
health: (id, body) => this.targetHealth(id, body),
|
|
8085
|
+
onChanged: (id, change) => this.onTargetChanged(id, change)
|
|
8086
|
+
});
|
|
7801
8087
|
const secret = this.state("haExportMediaSecret", string(), "");
|
|
7802
8088
|
const existing = await secret.get();
|
|
7803
8089
|
if (existing.length === 0) {
|
|
@@ -7833,27 +8119,27 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7833
8119
|
{
|
|
7834
8120
|
capability: deviceExportCapability,
|
|
7835
8121
|
provider: {
|
|
7836
|
-
getStatus: async (input) => this.buildStatus(this.
|
|
7837
|
-
listInstances: async () => this.
|
|
7838
|
-
instanceId:
|
|
7839
|
-
name:
|
|
7840
|
-
enabled: this.
|
|
7841
|
-
kind:
|
|
8122
|
+
getStatus: async (input) => this.buildStatus(this.targetScope(statusInstanceOf(input))),
|
|
8123
|
+
listInstances: async () => this.book().ids().map((id) => ({
|
|
8124
|
+
instanceId: id,
|
|
8125
|
+
name: this.book().name(id),
|
|
8126
|
+
enabled: this.book().isEnabled(id),
|
|
8127
|
+
kind: HA_TARGET_KIND
|
|
7842
8128
|
})),
|
|
7843
8129
|
listSupportedDeviceKinds: async ({ instanceId }) => {
|
|
7844
|
-
this.
|
|
8130
|
+
this.targetScope(instanceId);
|
|
7845
8131
|
return [...SUPPORTED_DEVICE_KINDS];
|
|
7846
8132
|
},
|
|
7847
|
-
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.
|
|
7848
|
-
exposeDevice: async ({ instanceId, deviceId }) => this.
|
|
7849
|
-
unexposeDevice: async ({ instanceId, deviceId }) => this.
|
|
8133
|
+
listExposedDevices: async ({ instanceId }) => this.exposedDeviceRows(this.targetScope(instanceId)),
|
|
8134
|
+
exposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, true),
|
|
8135
|
+
unexposeDevice: async ({ instanceId, deviceId }) => this.setTargetMembership(instanceId, deviceId, false),
|
|
7850
8136
|
listSupportedEntityKinds: async () => [
|
|
7851
8137
|
"device",
|
|
7852
8138
|
"action",
|
|
7853
8139
|
"notifier"
|
|
7854
8140
|
],
|
|
7855
8141
|
listExposedEntities: async ({ instanceId }) => {
|
|
7856
|
-
const scope = this.
|
|
8142
|
+
const scope = this.targetScope(instanceId);
|
|
7857
8143
|
await exportedEntities.pruneDeleted();
|
|
7858
8144
|
return [...this.exposedDeviceRows(scope).map((row) => ({
|
|
7859
8145
|
entity: {
|
|
@@ -7864,13 +8150,13 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7864
8150
|
})), ...exportedEntities.list()];
|
|
7865
8151
|
},
|
|
7866
8152
|
exposeEntity: async ({ instanceId, entity }) => {
|
|
7867
|
-
if (entity.kind === "device") return this.
|
|
7868
|
-
this.
|
|
8153
|
+
if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, true);
|
|
8154
|
+
this.targetScope(instanceId);
|
|
7869
8155
|
return exportedEntities.expose(entity);
|
|
7870
8156
|
},
|
|
7871
8157
|
unexposeEntity: async ({ instanceId, entity }) => {
|
|
7872
|
-
if (entity.kind === "device") return this.
|
|
7873
|
-
this.
|
|
8158
|
+
if (entity.kind === "device") return this.setTargetMembership(instanceId, entity.deviceId, false);
|
|
8159
|
+
this.targetScope(instanceId);
|
|
7874
8160
|
return exportedEntities.unexpose(entity);
|
|
7875
8161
|
},
|
|
7876
8162
|
getDeviceSettingsContribution: async ({ deviceId }) => this.buildContribution(deviceId),
|
|
@@ -7878,6 +8164,10 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7878
8164
|
applyDeviceSettingsPatch: async ({ deviceId, patch }) => this.applyDeviceSettingsPatch(deviceId, patch)
|
|
7879
8165
|
}
|
|
7880
8166
|
},
|
|
8167
|
+
{
|
|
8168
|
+
capability: integrationInstancesCapability,
|
|
8169
|
+
provider: this.requireInstances().toProvider()
|
|
8170
|
+
},
|
|
7881
8171
|
{
|
|
7882
8172
|
capability: addonRoutesCapability,
|
|
7883
8173
|
provider: buildAddonRouteProvider(ADDON_ID, [{
|
|
@@ -7896,53 +8186,28 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7896
8186
|
}
|
|
7897
8187
|
];
|
|
7898
8188
|
}
|
|
7899
|
-
|
|
7900
|
-
|
|
7901
|
-
|
|
7902
|
-
|
|
7903
|
-
|
|
7904
|
-
return super.getGlobalSettings({
|
|
7905
|
-
...overlay,
|
|
7906
|
-
...linkFormOverlay(this.book())
|
|
7907
|
-
}, cap, nodeId);
|
|
8189
|
+
async onConfigChanged() {
|
|
8190
|
+
if (this.targetBook === null) return;
|
|
8191
|
+
for (const link of this.links.values()) link.client.resetBackoff();
|
|
8192
|
+
await this.reconcile("config-changed");
|
|
8193
|
+
this.startTimer();
|
|
7908
8194
|
}
|
|
7909
8195
|
/**
|
|
7910
|
-
*
|
|
7911
|
-
*
|
|
7912
|
-
*
|
|
8196
|
+
* A target was created, edited, switched or deleted through `integration-instances`: reload
|
|
8197
|
+
* the records, then apply them (a new connection opens, a removed one closes, a changed
|
|
8198
|
+
* cadence restarts the timer). A deleted target's devices simply stop being exported.
|
|
7913
8199
|
*/
|
|
7914
|
-
async
|
|
7915
|
-
|
|
7916
|
-
|
|
7917
|
-
|
|
7918
|
-
|
|
7919
|
-
|
|
7920
|
-
rest[key] = value;
|
|
7921
|
-
continue;
|
|
7922
|
-
}
|
|
7923
|
-
this.brokerScope(field.brokerId);
|
|
7924
|
-
const entry = flags.get(field.brokerId) ?? {};
|
|
7925
|
-
if (field.flag === "exportTo") {
|
|
7926
|
-
if (typeof value !== "boolean") throw new Error(`ha-export: ${key} must be true or false`);
|
|
7927
|
-
entry.exportTo = value;
|
|
7928
|
-
} else {
|
|
7929
|
-
const role = HA_MEDIA_ROLES.find((candidate) => candidate === value);
|
|
7930
|
-
if (role === void 0) throw new Error(`ha-export: ${key} is not a known address role`);
|
|
7931
|
-
entry.mediaRole = role;
|
|
7932
|
-
}
|
|
7933
|
-
flags.set(field.brokerId, entry);
|
|
7934
|
-
}
|
|
7935
|
-
for (const [brokerId, change] of flags) await this.book().setFlags(brokerId, this.brokerName(brokerId), change);
|
|
7936
|
-
if (Object.keys(rest).length > 0 || flags.size === 0) {
|
|
7937
|
-
await super.updateGlobalSettingsAsOperator(rest, nodeId);
|
|
7938
|
-
return;
|
|
7939
|
-
}
|
|
8200
|
+
async onTargetChanged(id, change) {
|
|
8201
|
+
await this.book().reload();
|
|
8202
|
+
this.ctx.logger.info("ha-export: an export target changed", { meta: {
|
|
8203
|
+
targetId: id,
|
|
8204
|
+
change
|
|
8205
|
+
} });
|
|
7940
8206
|
await this.onConfigChanged();
|
|
7941
8207
|
}
|
|
7942
|
-
|
|
7943
|
-
|
|
7944
|
-
|
|
7945
|
-
this.startTimer();
|
|
8208
|
+
requireInstances() {
|
|
8209
|
+
if (this.instances === null) throw new Error("ha-export: the target instances are not open");
|
|
8210
|
+
return this.instances;
|
|
7946
8211
|
}
|
|
7947
8212
|
wireListeners() {
|
|
7948
8213
|
this.subscribe({ category: EventCategory.DeviceStateChanged }, (event) => {
|
|
@@ -8198,6 +8463,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8198
8463
|
}
|
|
8199
8464
|
/** deviceId:cap:field already reported. Bounded by devices × mapped caps. */
|
|
8200
8465
|
warnedCapFields = /* @__PURE__ */ new Set();
|
|
8466
|
+
warnedMediaFallback = /* @__PURE__ */ new Set();
|
|
8201
8467
|
warnOnce(deviceId, capName, field, line) {
|
|
8202
8468
|
const key = `${deviceId}:${capName}:${field}`;
|
|
8203
8469
|
if (this.warnedCapFields.has(key)) return;
|
|
@@ -8434,7 +8700,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8434
8700
|
const fullResync = this.resync.take(reason);
|
|
8435
8701
|
let readsHeld = false;
|
|
8436
8702
|
await this.refreshComponentSupport();
|
|
8437
|
-
const enabled = this.
|
|
8703
|
+
const enabled = this.enabledTargetIds();
|
|
8438
8704
|
const devices = await this.ctx.api.deviceManager.listAll.query({});
|
|
8439
8705
|
const snapshots = await this.ctx.api.deviceState.getAllSnapshots.query({});
|
|
8440
8706
|
const byId = new Map(devices.map((device) => [device.id, device]));
|
|
@@ -9380,71 +9646,63 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9380
9646
|
*/
|
|
9381
9647
|
async refreshBrokers() {
|
|
9382
9648
|
const haBrokers = await listHomeAssistantBrokers((input) => this.ctx.api.broker.list.query(input));
|
|
9383
|
-
const known = haBrokers.map((broker) => ({
|
|
9384
|
-
id: broker.id,
|
|
9385
|
-
name: broker.name
|
|
9386
|
-
}));
|
|
9387
9649
|
/**
|
|
9388
|
-
* NEVER
|
|
9389
|
-
*
|
|
9390
|
-
* `broker.list` is a fan-out union, so it answers `[]` for the whole window
|
|
9391
|
-
* in which `provider-homeassistant` has not finished registering — and it
|
|
9392
|
-
* registers its caps only at the END of an `initialize()` that restores 239
|
|
9393
|
-
* devices. An empty list there means "I do not know yet", never "the
|
|
9394
|
-
* operator deleted their instance", and the two were indistinguishable to
|
|
9395
|
-
* this code: every boot pruned the membership to nothing and PERSISTED it,
|
|
9396
|
-
* so the operator's exported cameras silently switched themselves off. Seen
|
|
9397
|
-
* live on 2026-08-09 — two cameras exposed, `devices=4`, addon redeployed,
|
|
9398
|
-
* `devices=2` and both switches back to `false` with no line anywhere.
|
|
9650
|
+
* NEVER treat an empty enumeration as "the connections are gone".
|
|
9399
9651
|
*
|
|
9400
|
-
*
|
|
9401
|
-
*
|
|
9402
|
-
*
|
|
9403
|
-
*
|
|
9652
|
+
* `broker.list` is a fan-out union, so it answers `[]` for the whole window in which
|
|
9653
|
+
* `provider-homeassistant` has not finished registering. An empty list there means "I do not
|
|
9654
|
+
* know yet", never "the operator deleted their instance". A target whose connection is not in
|
|
9655
|
+
* the list is UNCONFIGURED for this pass: it pushes nothing, and its devices stay in its
|
|
9656
|
+
* record, so the next pass that sees the connection resumes them. Nothing is ever pruned or
|
|
9657
|
+
* unexposed from here (D49; 2026-08-09, 2026-08-24).
|
|
9404
9658
|
*/
|
|
9405
|
-
|
|
9406
|
-
|
|
9407
|
-
|
|
9408
|
-
const
|
|
9409
|
-
if (
|
|
9659
|
+
this.connections = new Set(haBrokers.map((broker) => broker.id));
|
|
9660
|
+
this.refreshEnabledTargets();
|
|
9661
|
+
for (const id of this.book().ids()) {
|
|
9662
|
+
const target = this.book().get(id);
|
|
9663
|
+
if (target === void 0 || !this.book().isEnabled(id)) continue;
|
|
9664
|
+
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: {
|
|
9665
|
+
targetId: id,
|
|
9666
|
+
connectionId: target.connectionId
|
|
9667
|
+
} });
|
|
9410
9668
|
}
|
|
9411
|
-
|
|
9412
|
-
|
|
9413
|
-
|
|
9414
|
-
* operator's export list). The records are read at boot and a failed read stops it.
|
|
9415
|
-
*/
|
|
9416
|
-
if (!sameBrokers(known, this.config.knownBrokers)) await this.updateGlobalSettings({ knownBrokers: known });
|
|
9417
|
-
this.refreshEnabledBrokers(known);
|
|
9418
|
-
const wanted = new Set(this.enabledBrokerIds());
|
|
9419
|
-
for (const [brokerId, link] of this.links) {
|
|
9420
|
-
if (wanted.has(brokerId)) continue;
|
|
9669
|
+
const wanted = new Set(this.enabledTargetIds());
|
|
9670
|
+
for (const [targetId, link] of this.links) {
|
|
9671
|
+
if (wanted.has(targetId) && link.connectionId === this.book().connectionId(targetId)) continue;
|
|
9421
9672
|
await link.client.dispose();
|
|
9422
|
-
this.links.delete(
|
|
9423
|
-
this.componentSupport.forget(
|
|
9424
|
-
this.ctx.logger.info("ha-export: stopped exporting to a
|
|
9425
|
-
|
|
9426
|
-
|
|
9427
|
-
|
|
9428
|
-
|
|
9429
|
-
|
|
9673
|
+
this.links.delete(targetId);
|
|
9674
|
+
this.componentSupport.forget(targetId);
|
|
9675
|
+
this.ctx.logger.info("ha-export: stopped exporting to a target", { meta: {
|
|
9676
|
+
targetId,
|
|
9677
|
+
connectionId: link.connectionId
|
|
9678
|
+
} });
|
|
9679
|
+
}
|
|
9680
|
+
for (const targetId of wanted) {
|
|
9681
|
+
if (this.links.has(targetId)) continue;
|
|
9682
|
+
const connectionId = this.book().connectionId(targetId);
|
|
9683
|
+
const broker = haBrokers.find((candidate) => candidate.id === connectionId);
|
|
9684
|
+
if (broker === void 0) continue;
|
|
9685
|
+
const resolved = await this.resolveBrokerConnection(connectionId, broker.addonId);
|
|
9430
9686
|
if (resolved === null) continue;
|
|
9431
9687
|
const client = new PushClient({
|
|
9432
9688
|
transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
|
|
9433
9689
|
logger: this.ctx.logger,
|
|
9434
|
-
brokerId:
|
|
9435
|
-
onLinkRestored: (
|
|
9690
|
+
brokerId: targetId,
|
|
9691
|
+
onLinkRestored: (restoredId) => this.reachability.request(restoredId)
|
|
9436
9692
|
});
|
|
9437
9693
|
client.start();
|
|
9438
9694
|
this.resync.markGap("broker-connected");
|
|
9439
|
-
this.links.set(
|
|
9440
|
-
brokerId:
|
|
9441
|
-
|
|
9695
|
+
this.links.set(targetId, {
|
|
9696
|
+
brokerId: targetId,
|
|
9697
|
+
connectionId,
|
|
9698
|
+
name: this.book().name(targetId),
|
|
9442
9699
|
baseUrl: resolved.baseUrl,
|
|
9443
9700
|
token: resolved.token,
|
|
9444
9701
|
client
|
|
9445
9702
|
});
|
|
9446
9703
|
this.ctx.logger.info("ha-export: exporting to Home Assistant", { meta: {
|
|
9447
|
-
|
|
9704
|
+
targetId,
|
|
9705
|
+
connectionId,
|
|
9448
9706
|
baseUrl: resolved.baseUrl
|
|
9449
9707
|
} });
|
|
9450
9708
|
}
|
|
@@ -9558,35 +9816,62 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9558
9816
|
}
|
|
9559
9817
|
}
|
|
9560
9818
|
/**
|
|
9561
|
-
* The
|
|
9562
|
-
*
|
|
9563
|
-
* NOT read from `this.config`: the switch is a fact of the instance's `ha-export-links`
|
|
9819
|
+
* The targets that push, as of the last {@link refreshEnabledTargets}: switched on AND with a
|
|
9820
|
+
* connection the hub knows. NOT read from `this.config`: the switch is a fact of the target's
|
|
9564
9821
|
* record, and this is a read-through cache of it, refreshed at the top of every reconcile pass
|
|
9565
9822
|
* and before the per-device panel renders, never written to.
|
|
9566
9823
|
*/
|
|
9567
9824
|
enabled = [];
|
|
9568
|
-
/** The `ha-export-
|
|
9569
|
-
|
|
9825
|
+
/** The `ha-export-targets` records, loaded at boot after the migrations. */
|
|
9826
|
+
targetBook = null;
|
|
9570
9827
|
book() {
|
|
9571
|
-
if (this.
|
|
9572
|
-
return this.
|
|
9828
|
+
if (this.targetBook === null) throw new Error("ha-export: the export targets are not loaded yet");
|
|
9829
|
+
return this.targetBook;
|
|
9573
9830
|
}
|
|
9574
|
-
/**
|
|
9831
|
+
/** targetId → exported device ids, from the records. */
|
|
9575
9832
|
membership() {
|
|
9576
9833
|
return this.book().membership();
|
|
9577
9834
|
}
|
|
9578
|
-
/** Persist a membership change: each
|
|
9835
|
+
/** Persist a membership change: each target's record first, the in-memory copy after it. */
|
|
9579
9836
|
async persistMembership(next) {
|
|
9580
|
-
await this.book().setMembership(next
|
|
9837
|
+
await this.book().setMembership(next);
|
|
9838
|
+
}
|
|
9839
|
+
connectionExists(connectionId) {
|
|
9840
|
+
return connectionId.length > 0 && this.connections.has(connectionId);
|
|
9581
9841
|
}
|
|
9582
|
-
|
|
9583
|
-
this.enabled =
|
|
9842
|
+
refreshEnabledTargets() {
|
|
9843
|
+
this.enabled = this.book().ids().filter((id) => this.book().isEnabled(id) && this.connectionExists(this.book().connectionId(id)));
|
|
9584
9844
|
}
|
|
9585
|
-
|
|
9845
|
+
enabledTargetIds() {
|
|
9586
9846
|
return this.enabled;
|
|
9587
9847
|
}
|
|
9588
|
-
|
|
9589
|
-
return this.
|
|
9848
|
+
targetName(targetId) {
|
|
9849
|
+
return this.book().name(targetId);
|
|
9850
|
+
}
|
|
9851
|
+
/** The Status column of the instances table: why a target is, or is not, pushing. */
|
|
9852
|
+
targetHealth(id, body) {
|
|
9853
|
+
if (body.enabled === false) return {
|
|
9854
|
+
state: "off",
|
|
9855
|
+
label: "Off"
|
|
9856
|
+
};
|
|
9857
|
+
if (!this.connectionExists(body.connectionId)) return {
|
|
9858
|
+
state: "error",
|
|
9859
|
+
label: "Unconfigured",
|
|
9860
|
+
detail: body.connectionId.length === 0 ? "Pick the Home Assistant connection this target pushes to" : "The Home Assistant connection of this target does not exist"
|
|
9861
|
+
};
|
|
9862
|
+
const link = this.links.get(id);
|
|
9863
|
+
if (link === void 0) return {
|
|
9864
|
+
state: "unknown",
|
|
9865
|
+
label: "Not connected yet"
|
|
9866
|
+
};
|
|
9867
|
+
return link.client.healthy ? {
|
|
9868
|
+
state: "ok",
|
|
9869
|
+
label: "Connected"
|
|
9870
|
+
} : {
|
|
9871
|
+
state: "error",
|
|
9872
|
+
label: "Unreachable",
|
|
9873
|
+
detail: link.client.failure?.text ?? "unreachable"
|
|
9874
|
+
};
|
|
9590
9875
|
}
|
|
9591
9876
|
async handleCommandRoute(body, reply) {
|
|
9592
9877
|
const topic = readString(body, "topic");
|
|
@@ -9974,14 +10259,24 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9974
10259
|
}
|
|
9975
10260
|
/**
|
|
9976
10261
|
* Each broker's media base, read per use: the palette's role the broker chose
|
|
9977
|
-
* (the
|
|
10262
|
+
* (the export target record's `mediaRole`, `publicEndpoint` by default, internal when none is set). A role that resolves to nothing
|
|
9978
10263
|
* is `null` and says so — the image entities then carry no value, which reads as
|
|
9979
10264
|
* empty rather than as a broken link.
|
|
9980
10265
|
*/
|
|
9981
10266
|
async mediaBasesFor(brokerIds, deviceId) {
|
|
9982
10267
|
let bases;
|
|
9983
10268
|
try {
|
|
9984
|
-
|
|
10269
|
+
const answer = await this.ctx.api.localNetwork.getAddresses.query();
|
|
10270
|
+
bases = mediaBasesByBroker(brokerIds, answer, (brokerId) => this.book().mediaRole(brokerId));
|
|
10271
|
+
for (const brokerId of brokerIds) {
|
|
10272
|
+
if (this.warnedMediaFallback.has(brokerId)) continue;
|
|
10273
|
+
if (!usedInternalFallback(answer, this.book().mediaRole(brokerId))) continue;
|
|
10274
|
+
this.warnedMediaFallback.add(brokerId);
|
|
10275
|
+
this.ctx.logger.info("ha-export: no public endpoint is set, so image links use the internal address", {
|
|
10276
|
+
tags: { deviceId },
|
|
10277
|
+
meta: { brokerId }
|
|
10278
|
+
});
|
|
10279
|
+
}
|
|
9985
10280
|
} catch (err) {
|
|
9986
10281
|
this.ctx.logger.debug("ha-export: could not resolve the media base URLs", {
|
|
9987
10282
|
tags: { deviceId },
|
|
@@ -9999,27 +10294,26 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
9999
10294
|
return bases;
|
|
10000
10295
|
}
|
|
10001
10296
|
/**
|
|
10002
|
-
* Every
|
|
10003
|
-
*
|
|
10004
|
-
* back on). A cold `knownBrokers` still counts the enabled ids.
|
|
10297
|
+
* Every target this addon holds, enabled or not: a membership write may target one that is
|
|
10298
|
+
* switched off or unconfigured (its devices resume when it is turned back on).
|
|
10005
10299
|
*/
|
|
10006
|
-
|
|
10007
|
-
return new Set(
|
|
10300
|
+
knownTargetIds() {
|
|
10301
|
+
return new Set(this.book().ids());
|
|
10008
10302
|
}
|
|
10009
10303
|
/**
|
|
10010
10304
|
* The brokers a `device-export` read answers over: the named instance, or
|
|
10011
10305
|
* (absent) every enabled broker. A broker this addon does not know is
|
|
10012
10306
|
* refused, never answered empty.
|
|
10013
10307
|
*/
|
|
10014
|
-
|
|
10015
|
-
if (instanceId === void 0) return this.
|
|
10016
|
-
if (!this.
|
|
10308
|
+
targetScope(instanceId) {
|
|
10309
|
+
if (instanceId === void 0) return this.enabledTargetIds();
|
|
10310
|
+
if (!this.knownTargetIds().has(instanceId)) throw new ExportInstanceUnknownError("provider-homeassistant", instanceId);
|
|
10017
10311
|
return [instanceId];
|
|
10018
10312
|
}
|
|
10019
10313
|
exposedDeviceRows(scope) {
|
|
10020
10314
|
return exposedDeviceIds(this.membership(), scope).map((deviceId) => ({
|
|
10021
10315
|
deviceId,
|
|
10022
|
-
exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.
|
|
10316
|
+
exposedAs: brokersExposing(this.membership(), deviceId).map((brokerId) => this.targetName(brokerId)).join(", ")
|
|
10023
10317
|
}));
|
|
10024
10318
|
}
|
|
10025
10319
|
/**
|
|
@@ -10027,15 +10321,15 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10027
10321
|
* instance. There is no write without a target and no "everywhere": the
|
|
10028
10322
|
* other brokers' membership is not touched.
|
|
10029
10323
|
*/
|
|
10030
|
-
async
|
|
10031
|
-
this.
|
|
10324
|
+
async setTargetMembership(instanceId, deviceId, exposed) {
|
|
10325
|
+
this.targetScope(instanceId);
|
|
10032
10326
|
const next = setExposed(this.membership(), instanceId, deviceId, exposed);
|
|
10033
10327
|
if (next === this.membership()) {
|
|
10034
10328
|
this.ctx.logger.debug("ha-export: membership already in that state, nothing to do", {
|
|
10035
10329
|
tags: { deviceId: Number(deviceId) },
|
|
10036
10330
|
meta: {
|
|
10037
10331
|
exposed,
|
|
10038
|
-
|
|
10332
|
+
targetId: instanceId
|
|
10039
10333
|
}
|
|
10040
10334
|
});
|
|
10041
10335
|
return;
|
|
@@ -10045,93 +10339,29 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10045
10339
|
tags: { deviceId: Number(deviceId) },
|
|
10046
10340
|
meta: {
|
|
10047
10341
|
exposed,
|
|
10048
|
-
|
|
10342
|
+
targetId: instanceId
|
|
10049
10343
|
}
|
|
10050
10344
|
});
|
|
10051
10345
|
await this.reconcile(exposed ? "expose" : "unexpose");
|
|
10052
10346
|
}
|
|
10053
|
-
globalSettingsSchema() {
|
|
10054
|
-
const brokerFields = this.config.knownBrokers.map((broker) => ({
|
|
10055
|
-
type: "boolean",
|
|
10056
|
-
key: exportToFormKey(broker.id),
|
|
10057
|
-
label: broker.name,
|
|
10058
|
-
style: "switch",
|
|
10059
|
-
default: false
|
|
10060
|
-
}));
|
|
10061
|
-
return { sections: [
|
|
10062
|
-
{
|
|
10063
|
-
id: "ha-export-brokers",
|
|
10064
|
-
title: "Home Assistant instances",
|
|
10065
|
-
titleKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.title",
|
|
10066
|
-
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.",
|
|
10067
|
-
columns: 1,
|
|
10068
|
-
fields: brokerFields.length > 0 ? brokerFields : [{
|
|
10069
|
-
type: "info",
|
|
10070
|
-
key: "ha-export:__no-brokers",
|
|
10071
|
-
label: "No brokers",
|
|
10072
|
-
labelKey: "provider-homeassistant:globalSettings.s_ha-export-brokers.ha-export___no-brokers.label",
|
|
10073
|
-
content: "Register a Home Assistant broker to enable the export.",
|
|
10074
|
-
variant: "warning"
|
|
10075
|
-
}]
|
|
10076
|
-
},
|
|
10077
|
-
...this.config.knownBrokers.length > 0 ? [{
|
|
10078
|
-
id: "ha-export-transport",
|
|
10079
|
-
title: "Connection",
|
|
10080
|
-
titleKey: "provider-homeassistant:globalSettings.s_ha-export-transport.title",
|
|
10081
|
-
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.",
|
|
10082
|
-
columns: 1,
|
|
10083
|
-
fields: this.config.knownBrokers.map((broker) => ({
|
|
10084
|
-
type: "select",
|
|
10085
|
-
key: mediaRoleFormKey(broker.id),
|
|
10086
|
-
label: `Image links for ${broker.name}`,
|
|
10087
|
-
options: [{
|
|
10088
|
-
value: "internal",
|
|
10089
|
-
label: "Internal (LAN address)"
|
|
10090
|
-
}, {
|
|
10091
|
-
value: "publicEndpoint",
|
|
10092
|
-
label: "Public endpoint (reachable from outside)"
|
|
10093
|
-
}],
|
|
10094
|
-
default: "internal"
|
|
10095
|
-
}))
|
|
10096
|
-
}] : [],
|
|
10097
|
-
{
|
|
10098
|
-
id: "ha-export-advanced",
|
|
10099
|
-
title: "Advanced",
|
|
10100
|
-
titleKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.title",
|
|
10101
|
-
style: "accordion",
|
|
10102
|
-
defaultCollapsed: true,
|
|
10103
|
-
columns: 1,
|
|
10104
|
-
fields: [{
|
|
10105
|
-
type: "number",
|
|
10106
|
-
key: "reconcileIntervalSec",
|
|
10107
|
-
label: "Reconcile interval (s)",
|
|
10108
|
-
labelKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.label",
|
|
10109
|
-
description: "Full re-announce and re-push cadence.",
|
|
10110
|
-
descriptionKey: "provider-homeassistant:globalSettings.s_ha-export-advanced.reconcileIntervalSec.description",
|
|
10111
|
-
default: DEFAULT_CONFIG.reconcileIntervalSec
|
|
10112
|
-
}]
|
|
10113
|
-
}
|
|
10114
|
-
] };
|
|
10115
|
-
}
|
|
10116
10347
|
/**
|
|
10117
10348
|
* Per-device Export panel — one switch PER BROKER.
|
|
10118
10349
|
*
|
|
10119
|
-
* Field keys are namespaced `haExport:<deviceId>:<
|
|
10350
|
+
* Field keys are namespaced `haExport:<deviceId>:<targetId>`: the
|
|
10120
10351
|
* device-details form flattens fields by key across every contributing
|
|
10121
10352
|
* section, so a bare `enabled` collides with the Alexa and HomeKit
|
|
10122
10353
|
* exporters' identically-named fields and the operator sees another
|
|
10123
10354
|
* exporter's value in this one's toggle.
|
|
10124
10355
|
*/
|
|
10125
10356
|
async buildContribution(deviceId) {
|
|
10126
|
-
this.refreshEnabledBrokers(this.config.knownBrokers);
|
|
10127
10357
|
const idStr = String(deviceId);
|
|
10128
|
-
const enabled = this.
|
|
10129
|
-
const fields = enabled.map((
|
|
10358
|
+
const enabled = this.book().ids().filter((id) => this.book().isEnabled(id));
|
|
10359
|
+
const fields = enabled.map((targetId) => ({
|
|
10130
10360
|
type: "boolean",
|
|
10131
|
-
key: `haExport:${deviceId}:${
|
|
10132
|
-
label: `Export to ${this.
|
|
10361
|
+
key: `haExport:${deviceId}:${targetId}`,
|
|
10362
|
+
label: `Export to ${this.targetName(targetId)}`,
|
|
10133
10363
|
style: "switch",
|
|
10134
|
-
value: isExposed(this.membership(),
|
|
10364
|
+
value: isExposed(this.membership(), targetId, idStr),
|
|
10135
10365
|
immediate: true
|
|
10136
10366
|
}));
|
|
10137
10367
|
return {
|
|
@@ -10145,7 +10375,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10145
10375
|
id: "ha-export",
|
|
10146
10376
|
title: "Home Assistant",
|
|
10147
10377
|
titleKey: "provider-homeassistant:contribution.s_ha-export.title",
|
|
10148
|
-
description: enabled.length > 0 ? "Each
|
|
10378
|
+
description: enabled.length > 0 ? "Each target receives only the devices switched on for it." : "No Home Assistant export target is enabled.",
|
|
10149
10379
|
tab: "export",
|
|
10150
10380
|
columns: 1,
|
|
10151
10381
|
order: 30,
|
|
@@ -10153,7 +10383,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10153
10383
|
type: "info",
|
|
10154
10384
|
key: `haExport:${deviceId}:__none`,
|
|
10155
10385
|
label: "Not configured",
|
|
10156
|
-
content: "
|
|
10386
|
+
content: "Add an export target on the Connectivity page first.",
|
|
10157
10387
|
variant: "info"
|
|
10158
10388
|
}]
|
|
10159
10389
|
}]
|
|
@@ -10166,10 +10396,10 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10166
10396
|
/**
|
|
10167
10397
|
* Driven by the PATCH KEYS, not by the enabled-broker list.
|
|
10168
10398
|
*
|
|
10169
|
-
* The key already carries the broker (`haExport:<deviceId>:<
|
|
10399
|
+
* The key already carries the broker (`haExport:<deviceId>:<targetId>`) —
|
|
10170
10400
|
* the operator could not have been shown the switch otherwise — so reading
|
|
10171
10401
|
* the broker from it needs no other state to be warm. Iterating
|
|
10172
|
-
* `
|
|
10402
|
+
* `enabledTargetIds()` instead made this depend on a read-through cache,
|
|
10173
10403
|
* and when that cache was cold the loop matched nothing, `touched` stayed
|
|
10174
10404
|
* false, and the method returned `{success: true}` HAVING DONE NOTHING.
|
|
10175
10405
|
* The admin UI showed the switch flip and it silently did not persist;
|
|
@@ -10179,9 +10409,16 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10179
10409
|
const prefix = `haExport:${deviceId}:`;
|
|
10180
10410
|
for (const [key, value] of Object.entries(patch)) {
|
|
10181
10411
|
if (!key.startsWith(prefix)) continue;
|
|
10182
|
-
const
|
|
10183
|
-
if (
|
|
10184
|
-
|
|
10412
|
+
const targetId = key.slice(prefix.length);
|
|
10413
|
+
if (targetId.length === 0) continue;
|
|
10414
|
+
if (!this.book().has(targetId)) {
|
|
10415
|
+
this.ctx.logger.warn("ha-export: a device patch names an export target that does not exist", {
|
|
10416
|
+
tags: { deviceId },
|
|
10417
|
+
meta: { targetId }
|
|
10418
|
+
});
|
|
10419
|
+
continue;
|
|
10420
|
+
}
|
|
10421
|
+
next = setExposed(next, targetId, idStr, value === true);
|
|
10185
10422
|
touched = true;
|
|
10186
10423
|
}
|
|
10187
10424
|
if (!touched) {
|
|
@@ -10192,9 +10429,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10192
10429
|
return { success: true };
|
|
10193
10430
|
}
|
|
10194
10431
|
await this.persistMembership(next);
|
|
10195
|
-
this.ctx.logger.info("ha-export: per-
|
|
10432
|
+
this.ctx.logger.info("ha-export: per-target export membership changed", {
|
|
10196
10433
|
tags: { deviceId },
|
|
10197
|
-
meta: {
|
|
10434
|
+
meta: { targets: brokersExposing(next, idStr) }
|
|
10198
10435
|
});
|
|
10199
10436
|
await this.reconcile("device-panel");
|
|
10200
10437
|
return { success: true };
|
|
@@ -10203,15 +10440,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10203
10440
|
const enabled = scope;
|
|
10204
10441
|
const links = [...this.links.entries()].filter(([brokerId]) => scope.includes(brokerId)).map(([, link]) => link);
|
|
10205
10442
|
const healthy = links.filter((link) => link.client.healthy).length;
|
|
10206
|
-
const
|
|
10443
|
+
const failures = links.map((link) => link.client.failure).filter((failure) => failure !== null);
|
|
10444
|
+
const unconfigured = scope.filter((id) => this.book().isEnabled(id) && !this.connectionExists(this.book().connectionId(id)));
|
|
10445
|
+
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);
|
|
10207
10446
|
return {
|
|
10208
|
-
linkState:
|
|
10447
|
+
linkState: links.length === 0 ? "unlinked" : healthy === links.length ? "linked" : "error",
|
|
10209
10448
|
exposedDeviceCount: exposedDeviceIds(this.membership(), enabled).length,
|
|
10210
10449
|
...linkFailure !== void 0 ? { error: linkFailure } : this.lastError !== void 0 ? { error: this.lastError } : {},
|
|
10211
10450
|
setup: {
|
|
10212
10451
|
fields: [
|
|
10213
10452
|
{
|
|
10214
|
-
label: "
|
|
10453
|
+
label: "Export targets",
|
|
10215
10454
|
value: `${healthy} / ${links.length} reachable`
|
|
10216
10455
|
},
|
|
10217
10456
|
{
|
|
@@ -10237,7 +10476,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
10237
10476
|
}
|
|
10238
10477
|
startTimer() {
|
|
10239
10478
|
this.stopTimer();
|
|
10240
|
-
const ms =
|
|
10479
|
+
const ms = this.book().reconcileIntervalSec() * 1e3;
|
|
10241
10480
|
this.reconcileTimer = setInterval(() => void this.reconcile("periodic"), ms);
|
|
10242
10481
|
}
|
|
10243
10482
|
stopTimer() {
|