@camstack/addon-provider-homeassistant 1.2.15 → 1.2.16
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/dist/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/{dist-DdyXnBOa.mjs → dist-B04tcTnS.mjs} +37 -0
- package/dist/{dist-DJqSvADu.js → dist-DQdO_V3C.js} +37 -0
- package/dist/ha-export/ha-export.addon.js +276 -57
- package/dist/ha-export/ha-export.addon.mjs +275 -57
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
|
-
const require_dist = require("./dist-
|
|
2
|
+
const require_dist = require("./dist-DQdO_V3C.js");
|
|
3
3
|
let node_crypto = require("node:crypto");
|
|
4
4
|
let node_zlib = require("node:zlib");
|
|
5
5
|
//#region src/ha-device-source.ts
|
package/dist/addon.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as temperatureSensorCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as EventCategory, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, G as powerMeterCapability, H as notificationOutputCapability, I as imageCapability, J as pressureSensorCapability, K as prepareNotification, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as tamperCapability, R as lockControlCapability, S as colorCapability, St as url, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as smokeCapability, Y as scriptRunnerCapability, Z as switchCapability, _ as brokerCapability, _t as object, a as EnumSensorDateTimeFormatSchema, at as weatherCapability, b as carbonMonoxideCapability, bt as union, c as addonRoutesCapability, ct as DeviceFeature, d as ambientLightSensorCapability, dt as createEvent, et as updateCapability, f as automationControlCapability, ft as _enum, g as brightnessCapability, gt as number, h as binaryCapability, ht as literal, it as waterHeaterCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceRole, m as bestLocationMatch, mt as discriminatedUnion, n as BaseDeviceProvider, nt as valveCapability, o as TargetSchema, ot as errMsg, p as batteryCapability, pt as array, q as presenceCapability, rt as vibrationCapability, s as accessoriesCapability, t as BaseDevice, tt as vacuumControlCapability, u as alarmPanelCapability, ut as DeviceType, v as buildAddonRouteProvider, vt as record, w as contactCapability, x as climateControlCapability, xt as unknown, y as buttonCapability, yt as string, z as mediaPlayerCapability } from "./dist-
|
|
1
|
+
import { $ as temperatureSensorCapability, A as eventEmitterCapability, B as motionCapability, C as connectivityCapability, Ct as EventCategory, D as deviceAdoptionCapability, E as coverCapability, F as humiditySensorCapability, G as powerMeterCapability, H as notificationOutputCapability, I as imageCapability, J as pressureSensorCapability, K as prepareNotification, L as lawnMowerControlCapability, M as floodCapability, N as gasCapability, P as humidifierCapability, Q as tamperCapability, R as lockControlCapability, S as colorCapability, St as url, T as controlCapability, U as notifierCapability, V as normalizeUnit, W as numericSensorCapability, X as smokeCapability, Y as scriptRunnerCapability, Z as switchCapability, _ as brokerCapability, _t as object, a as EnumSensorDateTimeFormatSchema, at as weatherCapability, b as carbonMonoxideCapability, bt as union, c as addonRoutesCapability, ct as DeviceFeature, d as ambientLightSensorCapability, dt as createEvent, et as updateCapability, f as automationControlCapability, ft as _enum, g as brightnessCapability, gt as number, h as binaryCapability, ht as literal, it as waterHeaterCapability, j as fanControlCapability, k as enumSensorCapability, l as airQualitySensorCapability, lt as DeviceRole, m as bestLocationMatch, mt as discriminatedUnion, n as BaseDeviceProvider, nt as valveCapability, o as TargetSchema, ot as errMsg, p as batteryCapability, pt as array, q as presenceCapability, rt as vibrationCapability, s as accessoriesCapability, t as BaseDevice, tt as vacuumControlCapability, u as alarmPanelCapability, ut as DeviceType, v as buildAddonRouteProvider, vt as record, w as contactCapability, x as climateControlCapability, xt as unknown, y as buttonCapability, yt as string, z as mediaPlayerCapability } from "./dist-B04tcTnS.mjs";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
3
|
import { brotliCompressSync, deflateSync, gzipSync } from "node:zlib";
|
|
4
4
|
//#region src/ha-device-source.ts
|
|
@@ -18301,6 +18301,37 @@ DeviceType.Camera, method(object({
|
|
|
18301
18301
|
lastCapturedAt: number().nullable(),
|
|
18302
18302
|
cacheAgeMs: number().nullable(),
|
|
18303
18303
|
etag: string().nullable()
|
|
18304
|
+
}))), systemMethod(object({
|
|
18305
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18306
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18307
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18308
|
+
targets: array(object({
|
|
18309
|
+
deviceId: number(),
|
|
18310
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18311
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18312
|
+
width: number().int().positive().optional()
|
|
18313
|
+
})).min(1).max(200) }), array(object({
|
|
18314
|
+
deviceId: number(),
|
|
18315
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18316
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18317
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18318
|
+
url: string().nullable(),
|
|
18319
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18320
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18321
|
+
capturedAt: number().nullable(),
|
|
18322
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18323
|
+
ageMs: number().nullable(),
|
|
18324
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18325
|
+
expiresAt: number().nullable(),
|
|
18326
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18327
|
+
width: number().nullable(),
|
|
18328
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18329
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18330
|
+
neverCaptured: boolean(),
|
|
18331
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18332
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18333
|
+
* present it as current. */
|
|
18334
|
+
sleeping: boolean()
|
|
18304
18335
|
})));
|
|
18305
18336
|
/**
|
|
18306
18337
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -31570,6 +31601,12 @@ Object.freeze({
|
|
|
31570
31601
|
addonId: null,
|
|
31571
31602
|
access: "view"
|
|
31572
31603
|
},
|
|
31604
|
+
"snapshot.getSnapshotLinks": {
|
|
31605
|
+
capName: "snapshot",
|
|
31606
|
+
capScope: "device",
|
|
31607
|
+
addonId: null,
|
|
31608
|
+
access: "view"
|
|
31609
|
+
},
|
|
31573
31610
|
"snapshot.getSnapshotOverview": {
|
|
31574
31611
|
capName: "snapshot",
|
|
31575
31612
|
capScope: "device",
|
|
@@ -18301,6 +18301,37 @@ DeviceType.Camera, method(object({
|
|
|
18301
18301
|
lastCapturedAt: number().nullable(),
|
|
18302
18302
|
cacheAgeMs: number().nullable(),
|
|
18303
18303
|
etag: string().nullable()
|
|
18304
|
+
}))), systemMethod(object({
|
|
18305
|
+
/** The tiles a surface is actually rendering. One entry per (device,
|
|
18306
|
+
* width) the caller will paint — the width is snapped to the server's
|
|
18307
|
+
* ladder and becomes part of the link's SIGNED identity. */
|
|
18308
|
+
targets: array(object({
|
|
18309
|
+
deviceId: number(),
|
|
18310
|
+
/** Target width in px. Omit for the frame as captured — correct
|
|
18311
|
+
* for a full-bleed surface, wrong (and expensive) for a grid. */
|
|
18312
|
+
width: number().int().positive().optional()
|
|
18313
|
+
})).min(1).max(200) }), array(object({
|
|
18314
|
+
deviceId: number(),
|
|
18315
|
+
/** Root-relative signed path, or null when the link plane is not
|
|
18316
|
+
* served (no data-plane facility). Present even for a device that has
|
|
18317
|
+
* never captured — the request is what triggers the first one (D94). */
|
|
18318
|
+
url: string().nullable(),
|
|
18319
|
+
/** Epoch ms of the frame this link serves. Null = never captured.
|
|
18320
|
+
* THE honest age: the tRPC path carried none before this. */
|
|
18321
|
+
capturedAt: number().nullable(),
|
|
18322
|
+
/** Age of that frame at the moment the answer was built. */
|
|
18323
|
+
ageMs: number().nullable(),
|
|
18324
|
+
/** Epoch ms after which `url` stops verifying. */
|
|
18325
|
+
expiresAt: number().nullable(),
|
|
18326
|
+
/** Ladder rung the bytes are at; null = the frame as captured. */
|
|
18327
|
+
width: number().nullable(),
|
|
18328
|
+
/** The device has never produced a frame. An empty state, not a
|
|
18329
|
+
* failure — and never a reason to withhold the link (D94). */
|
|
18330
|
+
neverCaptured: boolean(),
|
|
18331
|
+
/** A sleeping battery camera: the frame is deliberately stale and will
|
|
18332
|
+
* NOT refresh in the background. A surface should say so rather than
|
|
18333
|
+
* present it as current. */
|
|
18334
|
+
sleeping: boolean()
|
|
18304
18335
|
})));
|
|
18305
18336
|
/**
|
|
18306
18337
|
* `sso-bridge` — internal hub-only cap that lets SSO-style auth
|
|
@@ -31570,6 +31601,12 @@ Object.freeze({
|
|
|
31570
31601
|
addonId: null,
|
|
31571
31602
|
access: "view"
|
|
31572
31603
|
},
|
|
31604
|
+
"snapshot.getSnapshotLinks": {
|
|
31605
|
+
capName: "snapshot",
|
|
31606
|
+
capScope: "device",
|
|
31607
|
+
addonId: null,
|
|
31608
|
+
access: "view"
|
|
31609
|
+
},
|
|
31573
31610
|
"snapshot.getSnapshotOverview": {
|
|
31574
31611
|
capName: "snapshot",
|
|
31575
31612
|
capScope: "device",
|
|
@@ -2,7 +2,8 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
|
|
5
|
+
//#endregion
|
|
6
|
+
const require_dist = require("../dist-DQdO_V3C.js");
|
|
6
7
|
let node_crypto = require("node:crypto");
|
|
7
8
|
//#region src/ha-export/topics.ts
|
|
8
9
|
/**
|
|
@@ -129,21 +130,25 @@ var ZONE_MACROS = [
|
|
|
129
130
|
"animal"
|
|
130
131
|
];
|
|
131
132
|
/**
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
133
|
+
* `enabled_by_default: false` is a DECISION, taken per entity, never
|
|
134
|
+
* inferred from its name or its platform.
|
|
135
|
+
*
|
|
136
|
+
* It used to be inferred — a name-and-platform heuristic written for the
|
|
137
|
+
* camera catalog (`endsWith('_detected')`, `platform === 'switch'`) and
|
|
138
|
+
* then applied to every device the catalog builds. On a camera it is
|
|
139
|
+
* right: three zones is ~73 entities and the fleet is ~880, so only what
|
|
140
|
+
* an operator automates on arrives enabled. On a DERIVED device it was
|
|
141
|
+
* simply wrong — every sensor, every number and the alarm panel matched
|
|
142
|
+
* nothing and shipped switched off, so a temperature sensor exported its
|
|
143
|
+
* temperature disabled and the operator found the thing they installed
|
|
144
|
+
* the integration for turned off. Measured on the live hub, the whole
|
|
145
|
+
* derived half is **228 entities across 115 devices — 2 on average, 3 at
|
|
146
|
+
* most**: there is no fan-out there to protect against.
|
|
147
|
+
*
|
|
148
|
+
* So the flag is a required field on every spec. A new entity — or the
|
|
149
|
+
* synthetic devices the design addendum still owes — cannot inherit a
|
|
150
|
+
* camera's pressure valve by accident.
|
|
135
151
|
*/
|
|
136
|
-
var ENABLED_BY_DEFAULT_SUFFIXES = ["detected"];
|
|
137
|
-
var ENABLED_BY_DEFAULT_ENTITIES = [
|
|
138
|
-
"triggered",
|
|
139
|
-
"online",
|
|
140
|
-
"last_image"
|
|
141
|
-
];
|
|
142
|
-
function isEnabledByDefault(entity, platform) {
|
|
143
|
-
if (platform === "switch" || platform === "button" || platform === "select") return true;
|
|
144
|
-
if (ENABLED_BY_DEFAULT_ENTITIES.includes(entity)) return true;
|
|
145
|
-
return ENABLED_BY_DEFAULT_SUFFIXES.some((suffix) => entity.endsWith(`_${suffix}`));
|
|
146
|
-
}
|
|
147
152
|
/**
|
|
148
153
|
* The snooze surface, as a `select`.
|
|
149
154
|
*
|
|
@@ -179,7 +184,6 @@ var PTZ_BUTTONS = [
|
|
|
179
184
|
];
|
|
180
185
|
function buildComponent(device, spec) {
|
|
181
186
|
const deviceKey = deviceKeyFor(device.stableId);
|
|
182
|
-
const enabled = isEnabledByDefault(spec.entity, spec.platform);
|
|
183
187
|
return {
|
|
184
188
|
platform: spec.platform,
|
|
185
189
|
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
@@ -191,7 +195,7 @@ function buildComponent(device, spec) {
|
|
|
191
195
|
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
192
196
|
...spec.options !== void 0 ? { options: spec.options } : {},
|
|
193
197
|
...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
|
|
194
|
-
...
|
|
198
|
+
...spec.enabledByDefault ? {} : { enabled_by_default: false },
|
|
195
199
|
...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
|
|
196
200
|
payload_on: "true",
|
|
197
201
|
payload_off: "false"
|
|
@@ -207,6 +211,11 @@ function deviceBlock(device) {
|
|
|
207
211
|
mdl: device.model ?? device.type
|
|
208
212
|
};
|
|
209
213
|
}
|
|
214
|
+
/**
|
|
215
|
+
* The fan-out, and the reason the valve exists: zones × zone-macros × 4.
|
|
216
|
+
* The detector is what an operator automates on; the other three are
|
|
217
|
+
* detail they can switch on per entity.
|
|
218
|
+
*/
|
|
210
219
|
function zoneSpecs(zone) {
|
|
211
220
|
const slug = toSlug(zone.id);
|
|
212
221
|
const specs = [];
|
|
@@ -217,24 +226,28 @@ function zoneSpecs(zone) {
|
|
|
217
226
|
platform: "binary_sensor",
|
|
218
227
|
label: `${label} detected`,
|
|
219
228
|
uniqueSuffix: `${zone.id}_${macro}_detected`,
|
|
220
|
-
deviceClass: "motion"
|
|
229
|
+
deviceClass: "motion",
|
|
230
|
+
enabledByDefault: true
|
|
221
231
|
}, {
|
|
222
232
|
entity: `${slug}_${macro}_last_image`,
|
|
223
233
|
platform: "image",
|
|
224
234
|
label: `${label} last image`,
|
|
225
|
-
uniqueSuffix: `${zone.id}_${macro}_last_image
|
|
235
|
+
uniqueSuffix: `${zone.id}_${macro}_last_image`,
|
|
236
|
+
enabledByDefault: false
|
|
226
237
|
}, {
|
|
227
238
|
entity: `${slug}_${macro}_last_detection`,
|
|
228
239
|
platform: "sensor",
|
|
229
240
|
label: `${label} last detection`,
|
|
230
241
|
uniqueSuffix: `${zone.id}_${macro}_last_detection`,
|
|
231
|
-
deviceClass: "timestamp"
|
|
242
|
+
deviceClass: "timestamp",
|
|
243
|
+
enabledByDefault: false
|
|
232
244
|
}, {
|
|
233
245
|
entity: `${slug}_${macro}_objects`,
|
|
234
246
|
platform: "sensor",
|
|
235
247
|
label: `${label} objects`,
|
|
236
248
|
uniqueSuffix: `${zone.id}_${macro}_objects`,
|
|
237
|
-
icon: "mdi:counter"
|
|
249
|
+
icon: "mdi:counter",
|
|
250
|
+
enabledByDefault: false
|
|
238
251
|
});
|
|
239
252
|
}
|
|
240
253
|
return specs;
|
|
@@ -245,26 +258,30 @@ function cameraSpecs(device) {
|
|
|
245
258
|
entity: "triggered",
|
|
246
259
|
platform: "binary_sensor",
|
|
247
260
|
label: "Triggered",
|
|
248
|
-
deviceClass: "motion"
|
|
261
|
+
deviceClass: "motion",
|
|
262
|
+
enabledByDefault: true
|
|
249
263
|
},
|
|
250
264
|
{
|
|
251
265
|
entity: "online",
|
|
252
266
|
platform: "binary_sensor",
|
|
253
267
|
label: "Online",
|
|
254
268
|
deviceClass: "connectivity",
|
|
255
|
-
entityCategory: "diagnostic"
|
|
269
|
+
entityCategory: "diagnostic",
|
|
270
|
+
enabledByDefault: true
|
|
256
271
|
},
|
|
257
272
|
{
|
|
258
273
|
entity: "last_image",
|
|
259
274
|
platform: "image",
|
|
260
|
-
label: "Last image"
|
|
275
|
+
label: "Last image",
|
|
276
|
+
enabledByDefault: true
|
|
261
277
|
},
|
|
262
278
|
{
|
|
263
279
|
entity: "last_detection",
|
|
264
280
|
platform: "sensor",
|
|
265
281
|
label: "Last detection",
|
|
266
282
|
deviceClass: "timestamp",
|
|
267
|
-
icon: "mdi:clock"
|
|
283
|
+
icon: "mdi:clock",
|
|
284
|
+
enabledByDefault: false
|
|
268
285
|
}
|
|
269
286
|
];
|
|
270
287
|
if (device.slices.includes("battery")) specs.push({
|
|
@@ -273,18 +290,21 @@ function cameraSpecs(device) {
|
|
|
273
290
|
label: "Battery",
|
|
274
291
|
deviceClass: "battery",
|
|
275
292
|
unit: "%",
|
|
276
|
-
entityCategory: "diagnostic"
|
|
293
|
+
entityCategory: "diagnostic",
|
|
294
|
+
enabledByDefault: false
|
|
277
295
|
}, {
|
|
278
296
|
entity: "charger",
|
|
279
297
|
platform: "binary_sensor",
|
|
280
298
|
label: "Charging",
|
|
281
299
|
deviceClass: "battery_charging",
|
|
282
|
-
entityCategory: "diagnostic"
|
|
300
|
+
entityCategory: "diagnostic",
|
|
301
|
+
enabledByDefault: false
|
|
283
302
|
}, {
|
|
284
303
|
entity: "sleeping",
|
|
285
304
|
platform: "binary_sensor",
|
|
286
305
|
label: "Sleeping",
|
|
287
|
-
entityCategory: "diagnostic"
|
|
306
|
+
entityCategory: "diagnostic",
|
|
307
|
+
enabledByDefault: false
|
|
288
308
|
});
|
|
289
309
|
/**
|
|
290
310
|
* One HA switch per AVAILABLE camera switch, rendered from
|
|
@@ -302,28 +322,32 @@ function cameraSpecs(device) {
|
|
|
302
322
|
entity: toSlug(sw.id),
|
|
303
323
|
platform: "switch",
|
|
304
324
|
label: sw.label,
|
|
305
|
-
writable: true
|
|
325
|
+
writable: true,
|
|
326
|
+
enabledByDefault: true
|
|
306
327
|
});
|
|
307
328
|
}
|
|
308
329
|
if (device.features.includes("rebootable")) specs.push({
|
|
309
330
|
entity: "reboot",
|
|
310
331
|
platform: "button",
|
|
311
332
|
label: "Reboot",
|
|
312
|
-
writable: true
|
|
333
|
+
writable: true,
|
|
334
|
+
enabledByDefault: true
|
|
313
335
|
});
|
|
314
336
|
if (device.boundCaps.includes("ptz")) {
|
|
315
337
|
for (const entity of PTZ_BUTTONS) specs.push({
|
|
316
338
|
entity,
|
|
317
339
|
platform: "button",
|
|
318
340
|
label: humanise(entity),
|
|
319
|
-
writable: true
|
|
341
|
+
writable: true,
|
|
342
|
+
enabledByDefault: true
|
|
320
343
|
});
|
|
321
344
|
specs.push({
|
|
322
345
|
entity: "ptz_preset",
|
|
323
346
|
platform: "select",
|
|
324
347
|
label: "PTZ preset",
|
|
325
348
|
writable: true,
|
|
326
|
-
options: device.ptzPresets ?? []
|
|
349
|
+
options: device.ptzPresets ?? [],
|
|
350
|
+
enabledByDefault: true
|
|
327
351
|
});
|
|
328
352
|
}
|
|
329
353
|
specs.push({
|
|
@@ -331,7 +355,8 @@ function cameraSpecs(device) {
|
|
|
331
355
|
platform: "select",
|
|
332
356
|
label: "Snooze notifications",
|
|
333
357
|
writable: true,
|
|
334
|
-
options: SNOOZE_OPTIONS
|
|
358
|
+
options: SNOOZE_OPTIONS,
|
|
359
|
+
enabledByDefault: true
|
|
335
360
|
});
|
|
336
361
|
for (const macro of EXPORTED_MACROS) {
|
|
337
362
|
const label = humanise(macro);
|
|
@@ -339,22 +364,26 @@ function cameraSpecs(device) {
|
|
|
339
364
|
entity: `${macro}_detected`,
|
|
340
365
|
platform: "binary_sensor",
|
|
341
366
|
label: `${label} detected`,
|
|
342
|
-
deviceClass: "motion"
|
|
367
|
+
deviceClass: "motion",
|
|
368
|
+
enabledByDefault: true
|
|
343
369
|
}, {
|
|
344
370
|
entity: `${macro}_last_image`,
|
|
345
371
|
platform: "image",
|
|
346
|
-
label: `${label} last image
|
|
372
|
+
label: `${label} last image`,
|
|
373
|
+
enabledByDefault: false
|
|
347
374
|
}, {
|
|
348
375
|
entity: `${macro}_last_detection`,
|
|
349
376
|
platform: "sensor",
|
|
350
377
|
label: `${label} last detection`,
|
|
351
|
-
deviceClass: "timestamp"
|
|
378
|
+
deviceClass: "timestamp",
|
|
379
|
+
enabledByDefault: false
|
|
352
380
|
});
|
|
353
381
|
if (LABELLED_MACROS.includes(macro)) specs.push({
|
|
354
382
|
entity: `${macro}_last_label`,
|
|
355
383
|
platform: "sensor",
|
|
356
384
|
label: `${label} last label`,
|
|
357
|
-
icon: "mdi:tag"
|
|
385
|
+
icon: "mdi:tag",
|
|
386
|
+
enabledByDefault: false
|
|
358
387
|
});
|
|
359
388
|
}
|
|
360
389
|
for (const zone of device.zones) specs.push(...zoneSpecs(zone));
|
|
@@ -539,6 +568,22 @@ var CAPS_NOT_EXPORTED = [
|
|
|
539
568
|
{
|
|
540
569
|
cap: "reboot",
|
|
541
570
|
reason: "Rendered as a button, gated on the `rebootable` feature rather than the binding."
|
|
571
|
+
},
|
|
572
|
+
{
|
|
573
|
+
cap: "device-status",
|
|
574
|
+
reason: "Auto-registered by BaseDevice on EVERY device, so it would add an entity to all of them; availability is already the heartbeat, per device."
|
|
575
|
+
},
|
|
576
|
+
{
|
|
577
|
+
cap: "feature-probe",
|
|
578
|
+
reason: "The runtime truth about what a device CAN do — it decides which entities exist, so exporting it as one would be circular."
|
|
579
|
+
},
|
|
580
|
+
{
|
|
581
|
+
cap: "device-ops",
|
|
582
|
+
reason: "The per-device operations envelope; its members surface individually (e.g. reboot)."
|
|
583
|
+
},
|
|
584
|
+
{
|
|
585
|
+
cap: "device-discovery",
|
|
586
|
+
reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
|
|
542
587
|
}
|
|
543
588
|
];
|
|
544
589
|
/** A capability in neither list — the thing the guard exists to surface. */
|
|
@@ -546,14 +591,28 @@ function unclassifiedCaps(caps) {
|
|
|
546
591
|
const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
|
|
547
592
|
return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
|
|
548
593
|
}
|
|
549
|
-
/**
|
|
594
|
+
/**
|
|
595
|
+
* The derived half: every non-camera kind, from the caps it declares.
|
|
596
|
+
*
|
|
597
|
+
* **Everything here arrives ENABLED**, and that is the rule rather than
|
|
598
|
+
* an oversight in the other direction. A cap-derived entity IS the reason
|
|
599
|
+
* the device exists — a temperature sensor's temperature, a light's
|
|
600
|
+
* brightness, an alarm panel's state — and there is no fan-out to
|
|
601
|
+
* protect against: measured on the live hub, 115 non-camera devices
|
|
602
|
+
* produce 228 entities, 2 per device on average and 3 at most, because a
|
|
603
|
+
* device declares one or two mapped caps and the rest of its bindings
|
|
604
|
+
* (`device-status`, `feature-probe`, `device-ops`) carry no entity at
|
|
605
|
+
* all. The camera valve is for 73-per-device; this is not that problem,
|
|
606
|
+
* and exporting the operator's temperature switched off was the bug.
|
|
607
|
+
*/
|
|
550
608
|
function buildDerivedPlan(device) {
|
|
551
609
|
const specs = [{
|
|
552
610
|
entity: "online",
|
|
553
611
|
platform: "binary_sensor",
|
|
554
612
|
label: "Online",
|
|
555
613
|
deviceClass: "connectivity",
|
|
556
|
-
entityCategory: "diagnostic"
|
|
614
|
+
entityCategory: "diagnostic",
|
|
615
|
+
enabledByDefault: true
|
|
557
616
|
}];
|
|
558
617
|
for (const cap of device.boundCaps) {
|
|
559
618
|
const mapping = CAP_ENTITY_MAP[cap];
|
|
@@ -562,6 +621,7 @@ function buildDerivedPlan(device) {
|
|
|
562
621
|
entity: toSlug(cap),
|
|
563
622
|
platform: mapping.platform,
|
|
564
623
|
label: humanise(cap),
|
|
624
|
+
enabledByDefault: true,
|
|
565
625
|
...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
|
|
566
626
|
...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
|
|
567
627
|
...mapping.writable === true ? { writable: true } : {}
|
|
@@ -678,6 +738,59 @@ function resolveCommand(target, entity, value) {
|
|
|
678
738
|
}
|
|
679
739
|
return null;
|
|
680
740
|
}
|
|
741
|
+
/**
|
|
742
|
+
* The ONE derivation of "a signed, expiring URL".
|
|
743
|
+
*
|
|
744
|
+
* This repo mints unguessable, self-expiring links in three places now — the
|
|
745
|
+
* notification artifact plane, the Home Assistant media plane, and the snapshot
|
|
746
|
+
* link plane. The first two grew independently and are byte-identical logic
|
|
747
|
+
* (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
|
|
748
|
+
* each restating the crypto locally because **addons never import each other**.
|
|
749
|
+
*
|
|
750
|
+
* That reason is real, and the conclusion drawn from it was wrong. Two copies of
|
|
751
|
+
* a signing scheme is how one of them quietly ends up with a different TTL, a
|
|
752
|
+
* different compare, or a missing expiry check, and nothing fails until a link
|
|
753
|
+
* that should have died keeps working. The fix is the same one D52 applies to
|
|
754
|
+
* crop geometry: one derivation, in a place every addon may depend on. A
|
|
755
|
+
* framework package is exactly that place — `@camstack/types/node`, off the root
|
|
756
|
+
* entry because `node:crypto` must never be traversed by a browser bundler.
|
|
757
|
+
*
|
|
758
|
+
* What this module deliberately does NOT decide: the TTL, the base URL, the
|
|
759
|
+
* shape of `id`, and the access level of the route. Those are per-plane policy
|
|
760
|
+
* and each caller states them where a reader can see them.
|
|
761
|
+
*/
|
|
762
|
+
/**
|
|
763
|
+
* The signature over `(id, expMs)`.
|
|
764
|
+
*
|
|
765
|
+
* `id` is whatever the plane uses to name the thing being served — an artifact
|
|
766
|
+
* id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
|
|
767
|
+
* must not put a `:` inside a field whose boundary matters; where a plane has
|
|
768
|
+
* more than one field, it composes them itself and owns that ambiguity.
|
|
769
|
+
*/
|
|
770
|
+
function signExpiringUrl(secret, id, expMs) {
|
|
771
|
+
return (0, node_crypto.createHmac)("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
|
|
772
|
+
}
|
|
773
|
+
/**
|
|
774
|
+
* Verify a request's `(id, exp, sig)`.
|
|
775
|
+
*
|
|
776
|
+
* **Expiry is checked BEFORE the compare**, so an expired link is refused
|
|
777
|
+
* whether or not its signature is valid — a leaked URL stops working on its own
|
|
778
|
+
* and cannot be kept alive by holding a correct signature. The compare itself is
|
|
779
|
+
* constant-time so a public route cannot be probed for the signature byte by
|
|
780
|
+
* byte.
|
|
781
|
+
*/
|
|
782
|
+
function verifyExpiringUrl(input) {
|
|
783
|
+
const { secret, id, exp, sig, nowMs } = input;
|
|
784
|
+
if (exp === void 0 || sig === void 0) return false;
|
|
785
|
+
const expMs = typeof exp === "number" ? exp : Number(exp);
|
|
786
|
+
if (!Number.isFinite(expMs)) return false;
|
|
787
|
+
if (expMs <= nowMs) return false;
|
|
788
|
+
const expected = signExpiringUrl(secret, id, expMs);
|
|
789
|
+
const a = Buffer.from(expected, "utf8");
|
|
790
|
+
const b = Buffer.from(sig, "utf8");
|
|
791
|
+
if (a.length !== b.length) return false;
|
|
792
|
+
return (0, node_crypto.timingSafeEqual)(a, b);
|
|
793
|
+
}
|
|
681
794
|
//#endregion
|
|
682
795
|
//#region src/ha-export/media-url.ts
|
|
683
796
|
/**
|
|
@@ -690,18 +803,23 @@ function resolveCommand(target, entity, value) {
|
|
|
690
803
|
*
|
|
691
804
|
* The link is signed rather than protected by a session, because the
|
|
692
805
|
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
693
|
-
* camstack token.
|
|
694
|
-
* (that module lives in another addon and addons never import each other,
|
|
695
|
-
* so the crypto is restated here rather than reached for).
|
|
806
|
+
* camstack token.
|
|
696
807
|
*
|
|
697
|
-
*
|
|
698
|
-
*
|
|
699
|
-
*
|
|
808
|
+
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
809
|
+
* notification artifact plane each carried their own copy, because addons never
|
|
810
|
+
* import each other and neither could reach the other's. That reasoning was
|
|
811
|
+
* right and the conclusion was wrong: both now delegate to the single
|
|
812
|
+
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
813
|
+
* stays local is policy — the TTL below, and the id this plane signs.
|
|
814
|
+
*
|
|
815
|
+
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
816
|
+
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
817
|
+
* URL stops working on its own.
|
|
700
818
|
*/
|
|
701
819
|
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
702
820
|
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
703
821
|
function signMedia(secret, id, expMs) {
|
|
704
|
-
return (
|
|
822
|
+
return signExpiringUrl(secret, id, expMs);
|
|
705
823
|
}
|
|
706
824
|
function buildMediaUrl(input) {
|
|
707
825
|
const base = input.baseUrl.replace(/\/+$/, "");
|
|
@@ -709,13 +827,7 @@ function buildMediaUrl(input) {
|
|
|
709
827
|
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
710
828
|
}
|
|
711
829
|
function verifyMediaSignature(input) {
|
|
712
|
-
|
|
713
|
-
const expMs = Number(input.exp);
|
|
714
|
-
if (!Number.isFinite(expMs)) return false;
|
|
715
|
-
if (expMs < input.nowMs) return false;
|
|
716
|
-
const expected = signMedia(input.secret, input.id, expMs);
|
|
717
|
-
if (expected.length !== input.sig.length) return false;
|
|
718
|
-
return (0, node_crypto.timingSafeEqual)(Buffer.from(expected), Buffer.from(input.sig));
|
|
830
|
+
return verifyExpiringUrl(input);
|
|
719
831
|
}
|
|
720
832
|
//#endregion
|
|
721
833
|
//#region src/ha-export/membership.ts
|
|
@@ -772,6 +884,7 @@ var PushClient = class {
|
|
|
772
884
|
transport;
|
|
773
885
|
logger;
|
|
774
886
|
brokerId;
|
|
887
|
+
onLinkRestored;
|
|
775
888
|
/** topic → last value SENT. Dropped whenever the link is lost. */
|
|
776
889
|
stateCache = /* @__PURE__ */ new Map();
|
|
777
890
|
buffer = [];
|
|
@@ -785,6 +898,7 @@ var PushClient = class {
|
|
|
785
898
|
this.transport = options.transport;
|
|
786
899
|
this.logger = options.logger;
|
|
787
900
|
this.brokerId = options.brokerId;
|
|
901
|
+
this.onLinkRestored = options.onLinkRestored;
|
|
788
902
|
}
|
|
789
903
|
/** `false` once a POST has failed, `true` again on the next success. */
|
|
790
904
|
get healthy() {
|
|
@@ -886,6 +1000,21 @@ var PushClient = class {
|
|
|
886
1000
|
} });
|
|
887
1001
|
this.dropped = 0;
|
|
888
1002
|
this.droppedLoggedAt = 0;
|
|
1003
|
+
/**
|
|
1004
|
+
* Signalled AFTER the cache is gone and BEFORE anything else is sent:
|
|
1005
|
+
* the handler re-announces from inside this call, and a cache that
|
|
1006
|
+
* still held the old values would dedup away exactly those messages.
|
|
1007
|
+
* The handler's own failure is its business — it can never take the
|
|
1008
|
+
* transport, or the runner, down with it (D29).
|
|
1009
|
+
*/
|
|
1010
|
+
try {
|
|
1011
|
+
this.onLinkRestored?.(this.brokerId);
|
|
1012
|
+
} catch (err) {
|
|
1013
|
+
this.logger.warn("ha-export: the re-announce handler threw, link is up but not repaired", { meta: {
|
|
1014
|
+
brokerId: this.brokerId,
|
|
1015
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1016
|
+
} });
|
|
1017
|
+
}
|
|
889
1018
|
}
|
|
890
1019
|
/**
|
|
891
1020
|
* A failed POST discards work, so it may not be silent — an operator
|
|
@@ -907,6 +1036,48 @@ var PushClient = class {
|
|
|
907
1036
|
} });
|
|
908
1037
|
}
|
|
909
1038
|
};
|
|
1039
|
+
var ReachabilityReconciler = class {
|
|
1040
|
+
options;
|
|
1041
|
+
minIntervalMs;
|
|
1042
|
+
timer = null;
|
|
1043
|
+
lastRunAt = null;
|
|
1044
|
+
pending = /* @__PURE__ */ new Set();
|
|
1045
|
+
disposed = false;
|
|
1046
|
+
constructor(options) {
|
|
1047
|
+
this.options = options;
|
|
1048
|
+
this.minIntervalMs = options.minIntervalMs ?? 3e4;
|
|
1049
|
+
}
|
|
1050
|
+
/** One link came back. Ask for a full re-announce. */
|
|
1051
|
+
request(brokerId) {
|
|
1052
|
+
if (this.disposed) return;
|
|
1053
|
+
this.pending.add(brokerId);
|
|
1054
|
+
if (this.timer !== null) return;
|
|
1055
|
+
const sinceLast = this.lastRunAt === null ? Number.POSITIVE_INFINITY : Date.now() - this.lastRunAt;
|
|
1056
|
+
const delayMs = Math.max(0, this.minIntervalMs - sinceLast);
|
|
1057
|
+
this.options.onScheduled?.({
|
|
1058
|
+
brokerId,
|
|
1059
|
+
delayMs
|
|
1060
|
+
});
|
|
1061
|
+
this.timer = setTimeout(() => {
|
|
1062
|
+
this.timer = null;
|
|
1063
|
+
this.fire();
|
|
1064
|
+
}, delayMs);
|
|
1065
|
+
}
|
|
1066
|
+
dispose() {
|
|
1067
|
+
this.disposed = true;
|
|
1068
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
1069
|
+
this.timer = null;
|
|
1070
|
+
this.pending.clear();
|
|
1071
|
+
}
|
|
1072
|
+
fire() {
|
|
1073
|
+
if (this.disposed) return;
|
|
1074
|
+
const brokerIds = [...this.pending].sort();
|
|
1075
|
+
this.pending.clear();
|
|
1076
|
+
if (brokerIds.length === 0) return;
|
|
1077
|
+
this.lastRunAt = Date.now();
|
|
1078
|
+
this.options.run({ brokerIds });
|
|
1079
|
+
}
|
|
1080
|
+
};
|
|
910
1081
|
//#endregion
|
|
911
1082
|
//#region src/ha-export/state-projector.ts
|
|
912
1083
|
/**
|
|
@@ -1164,9 +1335,32 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1164
1335
|
mediaBaseUrl = null;
|
|
1165
1336
|
reconcileTimer = null;
|
|
1166
1337
|
reconcileInFlight = false;
|
|
1338
|
+
/** A reason asked for while one was running. Re-run, never dropped. */
|
|
1339
|
+
reconcilePending = null;
|
|
1167
1340
|
lastError;
|
|
1168
1341
|
entityCount = 0;
|
|
1169
1342
|
unclassified = [];
|
|
1343
|
+
/**
|
|
1344
|
+
* A link that returned repairs NOW, not at the next periodic pass.
|
|
1345
|
+
*
|
|
1346
|
+
* `PushClient` drops its dedup cache the moment Home Assistant answers
|
|
1347
|
+
* again; without this, nothing re-sent anything and every entity sat
|
|
1348
|
+
* blank for up to `reconcileIntervalSec` (300s). Bounded and coalesced,
|
|
1349
|
+
* because the answer is the COMPLETE `cmps` set plus every value for
|
|
1350
|
+
* every exported device — see `reachability-reconcile.ts`.
|
|
1351
|
+
*/
|
|
1352
|
+
reachability = new ReachabilityReconciler({
|
|
1353
|
+
onScheduled: ({ brokerId, delayMs }) => {
|
|
1354
|
+
this.ctx.logger.info("ha-export: Home Assistant link returned, re-announcing everything", { meta: {
|
|
1355
|
+
brokerId,
|
|
1356
|
+
inMs: delayMs
|
|
1357
|
+
} });
|
|
1358
|
+
},
|
|
1359
|
+
run: ({ brokerIds }) => {
|
|
1360
|
+
this.reconcile("link-restored");
|
|
1361
|
+
this.ctx.logger.debug("ha-export: re-announce triggered by a returning link", { meta: { brokers: [...brokerIds] } });
|
|
1362
|
+
}
|
|
1363
|
+
});
|
|
1170
1364
|
constructor() {
|
|
1171
1365
|
super({ ...DEFAULT_CONFIG });
|
|
1172
1366
|
}
|
|
@@ -1189,6 +1383,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1189
1383
|
this.startTimer();
|
|
1190
1384
|
this.ctx.addDisposer(async () => {
|
|
1191
1385
|
this.stopTimer();
|
|
1386
|
+
this.reachability.dispose();
|
|
1192
1387
|
for (const link of this.links.values()) await link.client.dispose();
|
|
1193
1388
|
this.links.clear();
|
|
1194
1389
|
});
|
|
@@ -1408,10 +1603,35 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1408
1603
|
* Re-resolves the brokers, rebuilds every plan, re-sends the COMPLETE
|
|
1409
1604
|
* `cmps` set for every exported device, and re-pushes every value.
|
|
1410
1605
|
* Events are an optimisation over this, never a substitute (D8/D11).
|
|
1606
|
+
*
|
|
1607
|
+
* A reconcile asked for while one is running is RE-RUN after it, never
|
|
1608
|
+
* dropped. It used to be dropped, and silently: a link that returned
|
|
1609
|
+
* mid-pass cleared its dedup cache and then lost the only re-announce
|
|
1610
|
+
* that would have refilled it, which is the exact defect this addon
|
|
1611
|
+
* just fixed, arriving through a different door.
|
|
1411
1612
|
*/
|
|
1412
1613
|
async reconcile(reason) {
|
|
1413
|
-
if (this.reconcileInFlight)
|
|
1614
|
+
if (this.reconcileInFlight) {
|
|
1615
|
+
this.reconcilePending = reason;
|
|
1616
|
+
this.ctx.logger.debug("ha-export: a reconcile is already running, re-running after it", { meta: { reason } });
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1414
1619
|
this.reconcileInFlight = true;
|
|
1620
|
+
try {
|
|
1621
|
+
let next = reason;
|
|
1622
|
+
while (next !== null) {
|
|
1623
|
+
const current = next;
|
|
1624
|
+
this.reconcilePending = null;
|
|
1625
|
+
await this.runReconcile(current);
|
|
1626
|
+
next = this.reconcilePending;
|
|
1627
|
+
}
|
|
1628
|
+
} finally {
|
|
1629
|
+
this.reconcileInFlight = false;
|
|
1630
|
+
this.reconcilePending = null;
|
|
1631
|
+
}
|
|
1632
|
+
}
|
|
1633
|
+
/** One pass. Never throws: a failure degrades this pass and no more. */
|
|
1634
|
+
async runReconcile(reason) {
|
|
1415
1635
|
const startedAt = Date.now();
|
|
1416
1636
|
try {
|
|
1417
1637
|
await this.refreshBrokers();
|
|
@@ -1496,8 +1716,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1496
1716
|
} catch (err) {
|
|
1497
1717
|
this.lastError = errMsg(err);
|
|
1498
1718
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
1499
|
-
} finally {
|
|
1500
|
-
this.reconcileInFlight = false;
|
|
1501
1719
|
}
|
|
1502
1720
|
}
|
|
1503
1721
|
/**
|
|
@@ -1693,7 +1911,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
1693
1911
|
const client = new PushClient({
|
|
1694
1912
|
transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
|
|
1695
1913
|
logger: this.ctx.logger,
|
|
1696
|
-
brokerId: broker.id
|
|
1914
|
+
brokerId: broker.id,
|
|
1915
|
+
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
1697
1916
|
});
|
|
1698
1917
|
client.start();
|
|
1699
1918
|
this.links.set(broker.id, {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { Ct as EventCategory, O as deviceExportCapability, c as addonRoutesCapability, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, st as BaseAddon, v as buildAddonRouteProvider, yt as string } from "../dist-
|
|
1
|
+
import { Ct as EventCategory, O as deviceExportCapability, c as addonRoutesCapability, i as CameraSwitchIdSchema, r as COCO_TO_MACRO, st as BaseAddon, v as buildAddonRouteProvider, yt as string } from "../dist-B04tcTnS.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
//#region src/ha-export/topics.ts
|
|
4
4
|
/**
|
|
@@ -125,21 +125,25 @@ var ZONE_MACROS = [
|
|
|
125
125
|
"animal"
|
|
126
126
|
];
|
|
127
127
|
/**
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
*
|
|
128
|
+
* `enabled_by_default: false` is a DECISION, taken per entity, never
|
|
129
|
+
* inferred from its name or its platform.
|
|
130
|
+
*
|
|
131
|
+
* It used to be inferred — a name-and-platform heuristic written for the
|
|
132
|
+
* camera catalog (`endsWith('_detected')`, `platform === 'switch'`) and
|
|
133
|
+
* then applied to every device the catalog builds. On a camera it is
|
|
134
|
+
* right: three zones is ~73 entities and the fleet is ~880, so only what
|
|
135
|
+
* an operator automates on arrives enabled. On a DERIVED device it was
|
|
136
|
+
* simply wrong — every sensor, every number and the alarm panel matched
|
|
137
|
+
* nothing and shipped switched off, so a temperature sensor exported its
|
|
138
|
+
* temperature disabled and the operator found the thing they installed
|
|
139
|
+
* the integration for turned off. Measured on the live hub, the whole
|
|
140
|
+
* derived half is **228 entities across 115 devices — 2 on average, 3 at
|
|
141
|
+
* most**: there is no fan-out there to protect against.
|
|
142
|
+
*
|
|
143
|
+
* So the flag is a required field on every spec. A new entity — or the
|
|
144
|
+
* synthetic devices the design addendum still owes — cannot inherit a
|
|
145
|
+
* camera's pressure valve by accident.
|
|
131
146
|
*/
|
|
132
|
-
var ENABLED_BY_DEFAULT_SUFFIXES = ["detected"];
|
|
133
|
-
var ENABLED_BY_DEFAULT_ENTITIES = [
|
|
134
|
-
"triggered",
|
|
135
|
-
"online",
|
|
136
|
-
"last_image"
|
|
137
|
-
];
|
|
138
|
-
function isEnabledByDefault(entity, platform) {
|
|
139
|
-
if (platform === "switch" || platform === "button" || platform === "select") return true;
|
|
140
|
-
if (ENABLED_BY_DEFAULT_ENTITIES.includes(entity)) return true;
|
|
141
|
-
return ENABLED_BY_DEFAULT_SUFFIXES.some((suffix) => entity.endsWith(`_${suffix}`));
|
|
142
|
-
}
|
|
143
147
|
/**
|
|
144
148
|
* The snooze surface, as a `select`.
|
|
145
149
|
*
|
|
@@ -175,7 +179,6 @@ var PTZ_BUTTONS = [
|
|
|
175
179
|
];
|
|
176
180
|
function buildComponent(device, spec) {
|
|
177
181
|
const deviceKey = deviceKeyFor(device.stableId);
|
|
178
|
-
const enabled = isEnabledByDefault(spec.entity, spec.platform);
|
|
179
182
|
return {
|
|
180
183
|
platform: spec.platform,
|
|
181
184
|
unique_id: `${device.stableId}_${spec.uniqueSuffix ?? spec.entity}`,
|
|
@@ -187,7 +190,7 @@ function buildComponent(device, spec) {
|
|
|
187
190
|
...spec.icon !== void 0 ? { icon: spec.icon } : {},
|
|
188
191
|
...spec.options !== void 0 ? { options: spec.options } : {},
|
|
189
192
|
...spec.entityCategory !== void 0 ? { entity_category: spec.entityCategory } : {},
|
|
190
|
-
...
|
|
193
|
+
...spec.enabledByDefault ? {} : { enabled_by_default: false },
|
|
191
194
|
...spec.platform === "binary_sensor" || spec.platform === "switch" ? {
|
|
192
195
|
payload_on: "true",
|
|
193
196
|
payload_off: "false"
|
|
@@ -203,6 +206,11 @@ function deviceBlock(device) {
|
|
|
203
206
|
mdl: device.model ?? device.type
|
|
204
207
|
};
|
|
205
208
|
}
|
|
209
|
+
/**
|
|
210
|
+
* The fan-out, and the reason the valve exists: zones × zone-macros × 4.
|
|
211
|
+
* The detector is what an operator automates on; the other three are
|
|
212
|
+
* detail they can switch on per entity.
|
|
213
|
+
*/
|
|
206
214
|
function zoneSpecs(zone) {
|
|
207
215
|
const slug = toSlug(zone.id);
|
|
208
216
|
const specs = [];
|
|
@@ -213,24 +221,28 @@ function zoneSpecs(zone) {
|
|
|
213
221
|
platform: "binary_sensor",
|
|
214
222
|
label: `${label} detected`,
|
|
215
223
|
uniqueSuffix: `${zone.id}_${macro}_detected`,
|
|
216
|
-
deviceClass: "motion"
|
|
224
|
+
deviceClass: "motion",
|
|
225
|
+
enabledByDefault: true
|
|
217
226
|
}, {
|
|
218
227
|
entity: `${slug}_${macro}_last_image`,
|
|
219
228
|
platform: "image",
|
|
220
229
|
label: `${label} last image`,
|
|
221
|
-
uniqueSuffix: `${zone.id}_${macro}_last_image
|
|
230
|
+
uniqueSuffix: `${zone.id}_${macro}_last_image`,
|
|
231
|
+
enabledByDefault: false
|
|
222
232
|
}, {
|
|
223
233
|
entity: `${slug}_${macro}_last_detection`,
|
|
224
234
|
platform: "sensor",
|
|
225
235
|
label: `${label} last detection`,
|
|
226
236
|
uniqueSuffix: `${zone.id}_${macro}_last_detection`,
|
|
227
|
-
deviceClass: "timestamp"
|
|
237
|
+
deviceClass: "timestamp",
|
|
238
|
+
enabledByDefault: false
|
|
228
239
|
}, {
|
|
229
240
|
entity: `${slug}_${macro}_objects`,
|
|
230
241
|
platform: "sensor",
|
|
231
242
|
label: `${label} objects`,
|
|
232
243
|
uniqueSuffix: `${zone.id}_${macro}_objects`,
|
|
233
|
-
icon: "mdi:counter"
|
|
244
|
+
icon: "mdi:counter",
|
|
245
|
+
enabledByDefault: false
|
|
234
246
|
});
|
|
235
247
|
}
|
|
236
248
|
return specs;
|
|
@@ -241,26 +253,30 @@ function cameraSpecs(device) {
|
|
|
241
253
|
entity: "triggered",
|
|
242
254
|
platform: "binary_sensor",
|
|
243
255
|
label: "Triggered",
|
|
244
|
-
deviceClass: "motion"
|
|
256
|
+
deviceClass: "motion",
|
|
257
|
+
enabledByDefault: true
|
|
245
258
|
},
|
|
246
259
|
{
|
|
247
260
|
entity: "online",
|
|
248
261
|
platform: "binary_sensor",
|
|
249
262
|
label: "Online",
|
|
250
263
|
deviceClass: "connectivity",
|
|
251
|
-
entityCategory: "diagnostic"
|
|
264
|
+
entityCategory: "diagnostic",
|
|
265
|
+
enabledByDefault: true
|
|
252
266
|
},
|
|
253
267
|
{
|
|
254
268
|
entity: "last_image",
|
|
255
269
|
platform: "image",
|
|
256
|
-
label: "Last image"
|
|
270
|
+
label: "Last image",
|
|
271
|
+
enabledByDefault: true
|
|
257
272
|
},
|
|
258
273
|
{
|
|
259
274
|
entity: "last_detection",
|
|
260
275
|
platform: "sensor",
|
|
261
276
|
label: "Last detection",
|
|
262
277
|
deviceClass: "timestamp",
|
|
263
|
-
icon: "mdi:clock"
|
|
278
|
+
icon: "mdi:clock",
|
|
279
|
+
enabledByDefault: false
|
|
264
280
|
}
|
|
265
281
|
];
|
|
266
282
|
if (device.slices.includes("battery")) specs.push({
|
|
@@ -269,18 +285,21 @@ function cameraSpecs(device) {
|
|
|
269
285
|
label: "Battery",
|
|
270
286
|
deviceClass: "battery",
|
|
271
287
|
unit: "%",
|
|
272
|
-
entityCategory: "diagnostic"
|
|
288
|
+
entityCategory: "diagnostic",
|
|
289
|
+
enabledByDefault: false
|
|
273
290
|
}, {
|
|
274
291
|
entity: "charger",
|
|
275
292
|
platform: "binary_sensor",
|
|
276
293
|
label: "Charging",
|
|
277
294
|
deviceClass: "battery_charging",
|
|
278
|
-
entityCategory: "diagnostic"
|
|
295
|
+
entityCategory: "diagnostic",
|
|
296
|
+
enabledByDefault: false
|
|
279
297
|
}, {
|
|
280
298
|
entity: "sleeping",
|
|
281
299
|
platform: "binary_sensor",
|
|
282
300
|
label: "Sleeping",
|
|
283
|
-
entityCategory: "diagnostic"
|
|
301
|
+
entityCategory: "diagnostic",
|
|
302
|
+
enabledByDefault: false
|
|
284
303
|
});
|
|
285
304
|
/**
|
|
286
305
|
* One HA switch per AVAILABLE camera switch, rendered from
|
|
@@ -298,28 +317,32 @@ function cameraSpecs(device) {
|
|
|
298
317
|
entity: toSlug(sw.id),
|
|
299
318
|
platform: "switch",
|
|
300
319
|
label: sw.label,
|
|
301
|
-
writable: true
|
|
320
|
+
writable: true,
|
|
321
|
+
enabledByDefault: true
|
|
302
322
|
});
|
|
303
323
|
}
|
|
304
324
|
if (device.features.includes("rebootable")) specs.push({
|
|
305
325
|
entity: "reboot",
|
|
306
326
|
platform: "button",
|
|
307
327
|
label: "Reboot",
|
|
308
|
-
writable: true
|
|
328
|
+
writable: true,
|
|
329
|
+
enabledByDefault: true
|
|
309
330
|
});
|
|
310
331
|
if (device.boundCaps.includes("ptz")) {
|
|
311
332
|
for (const entity of PTZ_BUTTONS) specs.push({
|
|
312
333
|
entity,
|
|
313
334
|
platform: "button",
|
|
314
335
|
label: humanise(entity),
|
|
315
|
-
writable: true
|
|
336
|
+
writable: true,
|
|
337
|
+
enabledByDefault: true
|
|
316
338
|
});
|
|
317
339
|
specs.push({
|
|
318
340
|
entity: "ptz_preset",
|
|
319
341
|
platform: "select",
|
|
320
342
|
label: "PTZ preset",
|
|
321
343
|
writable: true,
|
|
322
|
-
options: device.ptzPresets ?? []
|
|
344
|
+
options: device.ptzPresets ?? [],
|
|
345
|
+
enabledByDefault: true
|
|
323
346
|
});
|
|
324
347
|
}
|
|
325
348
|
specs.push({
|
|
@@ -327,7 +350,8 @@ function cameraSpecs(device) {
|
|
|
327
350
|
platform: "select",
|
|
328
351
|
label: "Snooze notifications",
|
|
329
352
|
writable: true,
|
|
330
|
-
options: SNOOZE_OPTIONS
|
|
353
|
+
options: SNOOZE_OPTIONS,
|
|
354
|
+
enabledByDefault: true
|
|
331
355
|
});
|
|
332
356
|
for (const macro of EXPORTED_MACROS) {
|
|
333
357
|
const label = humanise(macro);
|
|
@@ -335,22 +359,26 @@ function cameraSpecs(device) {
|
|
|
335
359
|
entity: `${macro}_detected`,
|
|
336
360
|
platform: "binary_sensor",
|
|
337
361
|
label: `${label} detected`,
|
|
338
|
-
deviceClass: "motion"
|
|
362
|
+
deviceClass: "motion",
|
|
363
|
+
enabledByDefault: true
|
|
339
364
|
}, {
|
|
340
365
|
entity: `${macro}_last_image`,
|
|
341
366
|
platform: "image",
|
|
342
|
-
label: `${label} last image
|
|
367
|
+
label: `${label} last image`,
|
|
368
|
+
enabledByDefault: false
|
|
343
369
|
}, {
|
|
344
370
|
entity: `${macro}_last_detection`,
|
|
345
371
|
platform: "sensor",
|
|
346
372
|
label: `${label} last detection`,
|
|
347
|
-
deviceClass: "timestamp"
|
|
373
|
+
deviceClass: "timestamp",
|
|
374
|
+
enabledByDefault: false
|
|
348
375
|
});
|
|
349
376
|
if (LABELLED_MACROS.includes(macro)) specs.push({
|
|
350
377
|
entity: `${macro}_last_label`,
|
|
351
378
|
platform: "sensor",
|
|
352
379
|
label: `${label} last label`,
|
|
353
|
-
icon: "mdi:tag"
|
|
380
|
+
icon: "mdi:tag",
|
|
381
|
+
enabledByDefault: false
|
|
354
382
|
});
|
|
355
383
|
}
|
|
356
384
|
for (const zone of device.zones) specs.push(...zoneSpecs(zone));
|
|
@@ -535,6 +563,22 @@ var CAPS_NOT_EXPORTED = [
|
|
|
535
563
|
{
|
|
536
564
|
cap: "reboot",
|
|
537
565
|
reason: "Rendered as a button, gated on the `rebootable` feature rather than the binding."
|
|
566
|
+
},
|
|
567
|
+
{
|
|
568
|
+
cap: "device-status",
|
|
569
|
+
reason: "Auto-registered by BaseDevice on EVERY device, so it would add an entity to all of them; availability is already the heartbeat, per device."
|
|
570
|
+
},
|
|
571
|
+
{
|
|
572
|
+
cap: "feature-probe",
|
|
573
|
+
reason: "The runtime truth about what a device CAN do — it decides which entities exist, so exporting it as one would be circular."
|
|
574
|
+
},
|
|
575
|
+
{
|
|
576
|
+
cap: "device-ops",
|
|
577
|
+
reason: "The per-device operations envelope; its members surface individually (e.g. reboot)."
|
|
578
|
+
},
|
|
579
|
+
{
|
|
580
|
+
cap: "device-discovery",
|
|
581
|
+
reason: "Adoption-time only — it describes candidates, never the state of an adopted device."
|
|
538
582
|
}
|
|
539
583
|
];
|
|
540
584
|
/** A capability in neither list — the thing the guard exists to surface. */
|
|
@@ -542,14 +586,28 @@ function unclassifiedCaps(caps) {
|
|
|
542
586
|
const known = new Set([...Object.keys(CAP_ENTITY_MAP), ...CAPS_NOT_EXPORTED.map((e) => e.cap)]);
|
|
543
587
|
return [...new Set(caps)].filter((cap) => !known.has(cap)).sort();
|
|
544
588
|
}
|
|
545
|
-
/**
|
|
589
|
+
/**
|
|
590
|
+
* The derived half: every non-camera kind, from the caps it declares.
|
|
591
|
+
*
|
|
592
|
+
* **Everything here arrives ENABLED**, and that is the rule rather than
|
|
593
|
+
* an oversight in the other direction. A cap-derived entity IS the reason
|
|
594
|
+
* the device exists — a temperature sensor's temperature, a light's
|
|
595
|
+
* brightness, an alarm panel's state — and there is no fan-out to
|
|
596
|
+
* protect against: measured on the live hub, 115 non-camera devices
|
|
597
|
+
* produce 228 entities, 2 per device on average and 3 at most, because a
|
|
598
|
+
* device declares one or two mapped caps and the rest of its bindings
|
|
599
|
+
* (`device-status`, `feature-probe`, `device-ops`) carry no entity at
|
|
600
|
+
* all. The camera valve is for 73-per-device; this is not that problem,
|
|
601
|
+
* and exporting the operator's temperature switched off was the bug.
|
|
602
|
+
*/
|
|
546
603
|
function buildDerivedPlan(device) {
|
|
547
604
|
const specs = [{
|
|
548
605
|
entity: "online",
|
|
549
606
|
platform: "binary_sensor",
|
|
550
607
|
label: "Online",
|
|
551
608
|
deviceClass: "connectivity",
|
|
552
|
-
entityCategory: "diagnostic"
|
|
609
|
+
entityCategory: "diagnostic",
|
|
610
|
+
enabledByDefault: true
|
|
553
611
|
}];
|
|
554
612
|
for (const cap of device.boundCaps) {
|
|
555
613
|
const mapping = CAP_ENTITY_MAP[cap];
|
|
@@ -558,6 +616,7 @@ function buildDerivedPlan(device) {
|
|
|
558
616
|
entity: toSlug(cap),
|
|
559
617
|
platform: mapping.platform,
|
|
560
618
|
label: humanise(cap),
|
|
619
|
+
enabledByDefault: true,
|
|
561
620
|
...mapping.deviceClass !== void 0 ? { deviceClass: mapping.deviceClass } : {},
|
|
562
621
|
...mapping.unit !== void 0 ? { unit: mapping.unit } : {},
|
|
563
622
|
...mapping.writable === true ? { writable: true } : {}
|
|
@@ -674,6 +733,59 @@ function resolveCommand(target, entity, value) {
|
|
|
674
733
|
}
|
|
675
734
|
return null;
|
|
676
735
|
}
|
|
736
|
+
/**
|
|
737
|
+
* The ONE derivation of "a signed, expiring URL".
|
|
738
|
+
*
|
|
739
|
+
* This repo mints unguessable, self-expiring links in three places now — the
|
|
740
|
+
* notification artifact plane, the Home Assistant media plane, and the snapshot
|
|
741
|
+
* link plane. The first two grew independently and are byte-identical logic
|
|
742
|
+
* (`hmac(secret, "<id>:<exp>")`, expiry checked before a constant-time compare),
|
|
743
|
+
* each restating the crypto locally because **addons never import each other**.
|
|
744
|
+
*
|
|
745
|
+
* That reason is real, and the conclusion drawn from it was wrong. Two copies of
|
|
746
|
+
* a signing scheme is how one of them quietly ends up with a different TTL, a
|
|
747
|
+
* different compare, or a missing expiry check, and nothing fails until a link
|
|
748
|
+
* that should have died keeps working. The fix is the same one D52 applies to
|
|
749
|
+
* crop geometry: one derivation, in a place every addon may depend on. A
|
|
750
|
+
* framework package is exactly that place — `@camstack/types/node`, off the root
|
|
751
|
+
* entry because `node:crypto` must never be traversed by a browser bundler.
|
|
752
|
+
*
|
|
753
|
+
* What this module deliberately does NOT decide: the TTL, the base URL, the
|
|
754
|
+
* shape of `id`, and the access level of the route. Those are per-plane policy
|
|
755
|
+
* and each caller states them where a reader can see them.
|
|
756
|
+
*/
|
|
757
|
+
/**
|
|
758
|
+
* The signature over `(id, expMs)`.
|
|
759
|
+
*
|
|
760
|
+
* `id` is whatever the plane uses to name the thing being served — an artifact
|
|
761
|
+
* id, a track id, a `<deviceId>:<width>` pair. It is joined with `:` so a caller
|
|
762
|
+
* must not put a `:` inside a field whose boundary matters; where a plane has
|
|
763
|
+
* more than one field, it composes them itself and owns that ambiguity.
|
|
764
|
+
*/
|
|
765
|
+
function signExpiringUrl(secret, id, expMs) {
|
|
766
|
+
return createHmac("sha256", secret).update(`${id}:${String(expMs)}`).digest("hex");
|
|
767
|
+
}
|
|
768
|
+
/**
|
|
769
|
+
* Verify a request's `(id, exp, sig)`.
|
|
770
|
+
*
|
|
771
|
+
* **Expiry is checked BEFORE the compare**, so an expired link is refused
|
|
772
|
+
* whether or not its signature is valid — a leaked URL stops working on its own
|
|
773
|
+
* and cannot be kept alive by holding a correct signature. The compare itself is
|
|
774
|
+
* constant-time so a public route cannot be probed for the signature byte by
|
|
775
|
+
* byte.
|
|
776
|
+
*/
|
|
777
|
+
function verifyExpiringUrl(input) {
|
|
778
|
+
const { secret, id, exp, sig, nowMs } = input;
|
|
779
|
+
if (exp === void 0 || sig === void 0) return false;
|
|
780
|
+
const expMs = typeof exp === "number" ? exp : Number(exp);
|
|
781
|
+
if (!Number.isFinite(expMs)) return false;
|
|
782
|
+
if (expMs <= nowMs) return false;
|
|
783
|
+
const expected = signExpiringUrl(secret, id, expMs);
|
|
784
|
+
const a = Buffer.from(expected, "utf8");
|
|
785
|
+
const b = Buffer.from(sig, "utf8");
|
|
786
|
+
if (a.length !== b.length) return false;
|
|
787
|
+
return timingSafeEqual(a, b);
|
|
788
|
+
}
|
|
677
789
|
//#endregion
|
|
678
790
|
//#region src/ha-export/media-url.ts
|
|
679
791
|
/**
|
|
@@ -686,18 +798,23 @@ function resolveCommand(target, entity, value) {
|
|
|
686
798
|
*
|
|
687
799
|
* The link is signed rather than protected by a session, because the
|
|
688
800
|
* fetcher is HA's own frontend or a phone — neither of which holds a
|
|
689
|
-
* camstack token.
|
|
690
|
-
* (that module lives in another addon and addons never import each other,
|
|
691
|
-
* so the crypto is restated here rather than reached for).
|
|
801
|
+
* camstack token.
|
|
692
802
|
*
|
|
693
|
-
*
|
|
694
|
-
*
|
|
695
|
-
*
|
|
803
|
+
* The HMAC itself is NOT restated here. It used to be — this module and the
|
|
804
|
+
* notification artifact plane each carried their own copy, because addons never
|
|
805
|
+
* import each other and neither could reach the other's. That reasoning was
|
|
806
|
+
* right and the conclusion was wrong: both now delegate to the single
|
|
807
|
+
* derivation in `@camstack/types/node`, which every addon may depend on. What
|
|
808
|
+
* stays local is policy — the TTL below, and the id this plane signs.
|
|
809
|
+
*
|
|
810
|
+
* Expiry is checked BEFORE the constant-time compare (in the shared verifier):
|
|
811
|
+
* an expired link is refused whether or not its signature is valid, so a leaked
|
|
812
|
+
* URL stops working on its own.
|
|
696
813
|
*/
|
|
697
814
|
/** How long a minted link stays valid. Long enough for HA to render it. */
|
|
698
815
|
var MEDIA_URL_TTL_MS = 360 * 60 * 1e3;
|
|
699
816
|
function signMedia(secret, id, expMs) {
|
|
700
|
-
return
|
|
817
|
+
return signExpiringUrl(secret, id, expMs);
|
|
701
818
|
}
|
|
702
819
|
function buildMediaUrl(input) {
|
|
703
820
|
const base = input.baseUrl.replace(/\/+$/, "");
|
|
@@ -705,13 +822,7 @@ function buildMediaUrl(input) {
|
|
|
705
822
|
return `${base}${input.routePrefix}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
706
823
|
}
|
|
707
824
|
function verifyMediaSignature(input) {
|
|
708
|
-
|
|
709
|
-
const expMs = Number(input.exp);
|
|
710
|
-
if (!Number.isFinite(expMs)) return false;
|
|
711
|
-
if (expMs < input.nowMs) return false;
|
|
712
|
-
const expected = signMedia(input.secret, input.id, expMs);
|
|
713
|
-
if (expected.length !== input.sig.length) return false;
|
|
714
|
-
return timingSafeEqual(Buffer.from(expected), Buffer.from(input.sig));
|
|
825
|
+
return verifyExpiringUrl(input);
|
|
715
826
|
}
|
|
716
827
|
//#endregion
|
|
717
828
|
//#region src/ha-export/membership.ts
|
|
@@ -768,6 +879,7 @@ var PushClient = class {
|
|
|
768
879
|
transport;
|
|
769
880
|
logger;
|
|
770
881
|
brokerId;
|
|
882
|
+
onLinkRestored;
|
|
771
883
|
/** topic → last value SENT. Dropped whenever the link is lost. */
|
|
772
884
|
stateCache = /* @__PURE__ */ new Map();
|
|
773
885
|
buffer = [];
|
|
@@ -781,6 +893,7 @@ var PushClient = class {
|
|
|
781
893
|
this.transport = options.transport;
|
|
782
894
|
this.logger = options.logger;
|
|
783
895
|
this.brokerId = options.brokerId;
|
|
896
|
+
this.onLinkRestored = options.onLinkRestored;
|
|
784
897
|
}
|
|
785
898
|
/** `false` once a POST has failed, `true` again on the next success. */
|
|
786
899
|
get healthy() {
|
|
@@ -882,6 +995,21 @@ var PushClient = class {
|
|
|
882
995
|
} });
|
|
883
996
|
this.dropped = 0;
|
|
884
997
|
this.droppedLoggedAt = 0;
|
|
998
|
+
/**
|
|
999
|
+
* Signalled AFTER the cache is gone and BEFORE anything else is sent:
|
|
1000
|
+
* the handler re-announces from inside this call, and a cache that
|
|
1001
|
+
* still held the old values would dedup away exactly those messages.
|
|
1002
|
+
* The handler's own failure is its business — it can never take the
|
|
1003
|
+
* transport, or the runner, down with it (D29).
|
|
1004
|
+
*/
|
|
1005
|
+
try {
|
|
1006
|
+
this.onLinkRestored?.(this.brokerId);
|
|
1007
|
+
} catch (err) {
|
|
1008
|
+
this.logger.warn("ha-export: the re-announce handler threw, link is up but not repaired", { meta: {
|
|
1009
|
+
brokerId: this.brokerId,
|
|
1010
|
+
error: err instanceof Error ? err.message : String(err)
|
|
1011
|
+
} });
|
|
1012
|
+
}
|
|
885
1013
|
}
|
|
886
1014
|
/**
|
|
887
1015
|
* A failed POST discards work, so it may not be silent — an operator
|
|
@@ -903,6 +1031,48 @@ var PushClient = class {
|
|
|
903
1031
|
} });
|
|
904
1032
|
}
|
|
905
1033
|
};
|
|
1034
|
+
var ReachabilityReconciler = class {
|
|
1035
|
+
options;
|
|
1036
|
+
minIntervalMs;
|
|
1037
|
+
timer = null;
|
|
1038
|
+
lastRunAt = null;
|
|
1039
|
+
pending = /* @__PURE__ */ new Set();
|
|
1040
|
+
disposed = false;
|
|
1041
|
+
constructor(options) {
|
|
1042
|
+
this.options = options;
|
|
1043
|
+
this.minIntervalMs = options.minIntervalMs ?? 3e4;
|
|
1044
|
+
}
|
|
1045
|
+
/** One link came back. Ask for a full re-announce. */
|
|
1046
|
+
request(brokerId) {
|
|
1047
|
+
if (this.disposed) return;
|
|
1048
|
+
this.pending.add(brokerId);
|
|
1049
|
+
if (this.timer !== null) return;
|
|
1050
|
+
const sinceLast = this.lastRunAt === null ? Number.POSITIVE_INFINITY : Date.now() - this.lastRunAt;
|
|
1051
|
+
const delayMs = Math.max(0, this.minIntervalMs - sinceLast);
|
|
1052
|
+
this.options.onScheduled?.({
|
|
1053
|
+
brokerId,
|
|
1054
|
+
delayMs
|
|
1055
|
+
});
|
|
1056
|
+
this.timer = setTimeout(() => {
|
|
1057
|
+
this.timer = null;
|
|
1058
|
+
this.fire();
|
|
1059
|
+
}, delayMs);
|
|
1060
|
+
}
|
|
1061
|
+
dispose() {
|
|
1062
|
+
this.disposed = true;
|
|
1063
|
+
if (this.timer !== null) clearTimeout(this.timer);
|
|
1064
|
+
this.timer = null;
|
|
1065
|
+
this.pending.clear();
|
|
1066
|
+
}
|
|
1067
|
+
fire() {
|
|
1068
|
+
if (this.disposed) return;
|
|
1069
|
+
const brokerIds = [...this.pending].sort();
|
|
1070
|
+
this.pending.clear();
|
|
1071
|
+
if (brokerIds.length === 0) return;
|
|
1072
|
+
this.lastRunAt = Date.now();
|
|
1073
|
+
this.options.run({ brokerIds });
|
|
1074
|
+
}
|
|
1075
|
+
};
|
|
906
1076
|
//#endregion
|
|
907
1077
|
//#region src/ha-export/state-projector.ts
|
|
908
1078
|
/**
|
|
@@ -1160,9 +1330,32 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1160
1330
|
mediaBaseUrl = null;
|
|
1161
1331
|
reconcileTimer = null;
|
|
1162
1332
|
reconcileInFlight = false;
|
|
1333
|
+
/** A reason asked for while one was running. Re-run, never dropped. */
|
|
1334
|
+
reconcilePending = null;
|
|
1163
1335
|
lastError;
|
|
1164
1336
|
entityCount = 0;
|
|
1165
1337
|
unclassified = [];
|
|
1338
|
+
/**
|
|
1339
|
+
* A link that returned repairs NOW, not at the next periodic pass.
|
|
1340
|
+
*
|
|
1341
|
+
* `PushClient` drops its dedup cache the moment Home Assistant answers
|
|
1342
|
+
* again; without this, nothing re-sent anything and every entity sat
|
|
1343
|
+
* blank for up to `reconcileIntervalSec` (300s). Bounded and coalesced,
|
|
1344
|
+
* because the answer is the COMPLETE `cmps` set plus every value for
|
|
1345
|
+
* every exported device — see `reachability-reconcile.ts`.
|
|
1346
|
+
*/
|
|
1347
|
+
reachability = new ReachabilityReconciler({
|
|
1348
|
+
onScheduled: ({ brokerId, delayMs }) => {
|
|
1349
|
+
this.ctx.logger.info("ha-export: Home Assistant link returned, re-announcing everything", { meta: {
|
|
1350
|
+
brokerId,
|
|
1351
|
+
inMs: delayMs
|
|
1352
|
+
} });
|
|
1353
|
+
},
|
|
1354
|
+
run: ({ brokerIds }) => {
|
|
1355
|
+
this.reconcile("link-restored");
|
|
1356
|
+
this.ctx.logger.debug("ha-export: re-announce triggered by a returning link", { meta: { brokers: [...brokerIds] } });
|
|
1357
|
+
}
|
|
1358
|
+
});
|
|
1166
1359
|
constructor() {
|
|
1167
1360
|
super({ ...DEFAULT_CONFIG });
|
|
1168
1361
|
}
|
|
@@ -1185,6 +1378,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1185
1378
|
this.startTimer();
|
|
1186
1379
|
this.ctx.addDisposer(async () => {
|
|
1187
1380
|
this.stopTimer();
|
|
1381
|
+
this.reachability.dispose();
|
|
1188
1382
|
for (const link of this.links.values()) await link.client.dispose();
|
|
1189
1383
|
this.links.clear();
|
|
1190
1384
|
});
|
|
@@ -1404,10 +1598,35 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1404
1598
|
* Re-resolves the brokers, rebuilds every plan, re-sends the COMPLETE
|
|
1405
1599
|
* `cmps` set for every exported device, and re-pushes every value.
|
|
1406
1600
|
* Events are an optimisation over this, never a substitute (D8/D11).
|
|
1601
|
+
*
|
|
1602
|
+
* A reconcile asked for while one is running is RE-RUN after it, never
|
|
1603
|
+
* dropped. It used to be dropped, and silently: a link that returned
|
|
1604
|
+
* mid-pass cleared its dedup cache and then lost the only re-announce
|
|
1605
|
+
* that would have refilled it, which is the exact defect this addon
|
|
1606
|
+
* just fixed, arriving through a different door.
|
|
1407
1607
|
*/
|
|
1408
1608
|
async reconcile(reason) {
|
|
1409
|
-
if (this.reconcileInFlight)
|
|
1609
|
+
if (this.reconcileInFlight) {
|
|
1610
|
+
this.reconcilePending = reason;
|
|
1611
|
+
this.ctx.logger.debug("ha-export: a reconcile is already running, re-running after it", { meta: { reason } });
|
|
1612
|
+
return;
|
|
1613
|
+
}
|
|
1410
1614
|
this.reconcileInFlight = true;
|
|
1615
|
+
try {
|
|
1616
|
+
let next = reason;
|
|
1617
|
+
while (next !== null) {
|
|
1618
|
+
const current = next;
|
|
1619
|
+
this.reconcilePending = null;
|
|
1620
|
+
await this.runReconcile(current);
|
|
1621
|
+
next = this.reconcilePending;
|
|
1622
|
+
}
|
|
1623
|
+
} finally {
|
|
1624
|
+
this.reconcileInFlight = false;
|
|
1625
|
+
this.reconcilePending = null;
|
|
1626
|
+
}
|
|
1627
|
+
}
|
|
1628
|
+
/** One pass. Never throws: a failure degrades this pass and no more. */
|
|
1629
|
+
async runReconcile(reason) {
|
|
1411
1630
|
const startedAt = Date.now();
|
|
1412
1631
|
try {
|
|
1413
1632
|
await this.refreshBrokers();
|
|
@@ -1492,8 +1711,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1492
1711
|
} catch (err) {
|
|
1493
1712
|
this.lastError = errMsg(err);
|
|
1494
1713
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
1495
|
-
} finally {
|
|
1496
|
-
this.reconcileInFlight = false;
|
|
1497
1714
|
}
|
|
1498
1715
|
}
|
|
1499
1716
|
/**
|
|
@@ -1689,7 +1906,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
1689
1906
|
const client = new PushClient({
|
|
1690
1907
|
transport: (message) => postToHomeAssistant(resolved.baseUrl, resolved.token, message),
|
|
1691
1908
|
logger: this.ctx.logger,
|
|
1692
|
-
brokerId: broker.id
|
|
1909
|
+
brokerId: broker.id,
|
|
1910
|
+
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
1693
1911
|
});
|
|
1694
1912
|
client.start();
|
|
1695
1913
|
this.links.set(broker.id, {
|