@camstack/addon-provider-homeassistant 1.2.602 → 1.2.604
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 +2 -1
- package/dist/addon.mjs +2 -1
- package/dist/{dist-xuu2eG_W.js → dist-BUoXKXFs.js} +341 -37
- package/dist/{dist-Dcum8auE.mjs → dist-CA_cJwK-.mjs} +341 -37
- package/dist/ha-export/ha-export.addon.js +565 -76
- package/dist/ha-export/ha-export.addon.mjs +565 -76
- package/package.json +1 -1
|
@@ -24,7 +24,7 @@ var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__ge
|
|
|
24
24
|
enumerable: true
|
|
25
25
|
}) : target, mod));
|
|
26
26
|
//#endregion
|
|
27
|
-
const require_dist = require("../dist-
|
|
27
|
+
const require_dist = require("../dist-BUoXKXFs.js");
|
|
28
28
|
let node_crypto = require("node:crypto");
|
|
29
29
|
require("node:zlib");
|
|
30
30
|
let node_fs = require("node:fs");
|
|
@@ -2473,6 +2473,187 @@ var CAP_ENTITY_MAP = {
|
|
|
2473
2473
|
}
|
|
2474
2474
|
}
|
|
2475
2475
|
},
|
|
2476
|
+
/**
|
|
2477
|
+
* A dishwasher. The headline is the appliance's OWN operation state (`Run`, `DelayedStart`,
|
|
2478
|
+
* `ActionRequired`, …) — not the coarse `state`, which folds nine states into six.
|
|
2479
|
+
*
|
|
2480
|
+
* Every flag is a `binary_sensor` / `switch` whose value is `null` until the appliance has
|
|
2481
|
+
* said: the projector publishes nothing for a null, so Home Assistant shows `unknown`, never
|
|
2482
|
+
* `off` (the slice is nullable for exactly this reason).
|
|
2483
|
+
*
|
|
2484
|
+
* Not mapped, by design: `availablePrograms` / `activeEvents` are arrays, and the program
|
|
2485
|
+
* list is the appliance's own vocabulary (a static `select` would offer programs it rejects);
|
|
2486
|
+
* `selectedProgram` / `program` are exported read-only. The raised events are separate
|
|
2487
|
+
* entities already — one `binary_sensor` per event and the events emitter.
|
|
2488
|
+
*/
|
|
2489
|
+
dishwasher: {
|
|
2490
|
+
platform: "sensor",
|
|
2491
|
+
field: "operationState",
|
|
2492
|
+
icon: "mdi:dishwasher",
|
|
2493
|
+
extras: {
|
|
2494
|
+
program: {
|
|
2495
|
+
platform: "sensor",
|
|
2496
|
+
field: "program",
|
|
2497
|
+
label: "Active program",
|
|
2498
|
+
icon: "mdi:dishwasher"
|
|
2499
|
+
},
|
|
2500
|
+
"selected-program": {
|
|
2501
|
+
platform: "sensor",
|
|
2502
|
+
field: "selectedProgram",
|
|
2503
|
+
label: "Selected program",
|
|
2504
|
+
enabledByDefault: false
|
|
2505
|
+
},
|
|
2506
|
+
"remaining-time": {
|
|
2507
|
+
platform: "sensor",
|
|
2508
|
+
field: "remainingSeconds",
|
|
2509
|
+
label: "Remaining time",
|
|
2510
|
+
deviceClass: "duration",
|
|
2511
|
+
unit: "s"
|
|
2512
|
+
},
|
|
2513
|
+
"delayed-start": {
|
|
2514
|
+
platform: "sensor",
|
|
2515
|
+
field: "delayedStartSeconds",
|
|
2516
|
+
label: "Delayed start",
|
|
2517
|
+
deviceClass: "duration",
|
|
2518
|
+
unit: "s",
|
|
2519
|
+
enabledByDefault: false
|
|
2520
|
+
},
|
|
2521
|
+
progress: {
|
|
2522
|
+
platform: "sensor",
|
|
2523
|
+
field: "progressPercent",
|
|
2524
|
+
label: "Progress",
|
|
2525
|
+
unit: "%",
|
|
2526
|
+
enabledByDefault: false
|
|
2527
|
+
},
|
|
2528
|
+
door: {
|
|
2529
|
+
platform: "binary_sensor",
|
|
2530
|
+
field: "doorOpen",
|
|
2531
|
+
label: "Door",
|
|
2532
|
+
deviceClass: "door"
|
|
2533
|
+
},
|
|
2534
|
+
"door-locked": {
|
|
2535
|
+
platform: "binary_sensor",
|
|
2536
|
+
field: "doorLocked",
|
|
2537
|
+
label: "Door locked",
|
|
2538
|
+
icon: "mdi:lock",
|
|
2539
|
+
enabledByDefault: false,
|
|
2540
|
+
entityCategory: "diagnostic"
|
|
2541
|
+
},
|
|
2542
|
+
"remote-start-allowed": {
|
|
2543
|
+
platform: "binary_sensor",
|
|
2544
|
+
field: "remoteStartAllowed",
|
|
2545
|
+
label: "Remote start allowed",
|
|
2546
|
+
enabledByDefault: false
|
|
2547
|
+
},
|
|
2548
|
+
"remote-control": {
|
|
2549
|
+
platform: "binary_sensor",
|
|
2550
|
+
field: "remoteControlActive",
|
|
2551
|
+
label: "Remote control active",
|
|
2552
|
+
enabledByDefault: false,
|
|
2553
|
+
entityCategory: "diagnostic"
|
|
2554
|
+
},
|
|
2555
|
+
"local-control": {
|
|
2556
|
+
platform: "binary_sensor",
|
|
2557
|
+
field: "localControlActive",
|
|
2558
|
+
label: "Local control active",
|
|
2559
|
+
enabledByDefault: false,
|
|
2560
|
+
entityCategory: "diagnostic"
|
|
2561
|
+
},
|
|
2562
|
+
power: {
|
|
2563
|
+
platform: "switch",
|
|
2564
|
+
field: "powerOn",
|
|
2565
|
+
label: "Power",
|
|
2566
|
+
writable: true
|
|
2567
|
+
},
|
|
2568
|
+
"child-lock": {
|
|
2569
|
+
platform: "switch",
|
|
2570
|
+
field: "childLock",
|
|
2571
|
+
label: "Child lock",
|
|
2572
|
+
writable: true,
|
|
2573
|
+
entityCategory: "config",
|
|
2574
|
+
enabledByDefault: false
|
|
2575
|
+
},
|
|
2576
|
+
"half-load": {
|
|
2577
|
+
platform: "switch",
|
|
2578
|
+
field: "halfLoad",
|
|
2579
|
+
label: "Half load",
|
|
2580
|
+
writable: true,
|
|
2581
|
+
entityCategory: "config",
|
|
2582
|
+
enabledByDefault: false
|
|
2583
|
+
},
|
|
2584
|
+
"hygiene-plus": {
|
|
2585
|
+
platform: "switch",
|
|
2586
|
+
field: "hygienePlus",
|
|
2587
|
+
label: "Hygiene plus",
|
|
2588
|
+
writable: true,
|
|
2589
|
+
entityCategory: "config",
|
|
2590
|
+
enabledByDefault: false
|
|
2591
|
+
},
|
|
2592
|
+
"extra-dry": {
|
|
2593
|
+
platform: "switch",
|
|
2594
|
+
field: "extraDry",
|
|
2595
|
+
label: "Extra dry",
|
|
2596
|
+
writable: true,
|
|
2597
|
+
entityCategory: "config",
|
|
2598
|
+
enabledByDefault: false
|
|
2599
|
+
},
|
|
2600
|
+
"intensive-zone": {
|
|
2601
|
+
platform: "switch",
|
|
2602
|
+
field: "intensiveZone",
|
|
2603
|
+
label: "Intensive zone",
|
|
2604
|
+
writable: true,
|
|
2605
|
+
entityCategory: "config",
|
|
2606
|
+
enabledByDefault: false
|
|
2607
|
+
},
|
|
2608
|
+
"vario-speed-plus": {
|
|
2609
|
+
platform: "switch",
|
|
2610
|
+
field: "varioSpeedPlus",
|
|
2611
|
+
label: "VarioSpeed+",
|
|
2612
|
+
writable: true,
|
|
2613
|
+
entityCategory: "config",
|
|
2614
|
+
enabledByDefault: false
|
|
2615
|
+
},
|
|
2616
|
+
salt: {
|
|
2617
|
+
platform: "sensor",
|
|
2618
|
+
field: "saltLevel",
|
|
2619
|
+
label: "Salt level",
|
|
2620
|
+
enabledByDefault: false
|
|
2621
|
+
},
|
|
2622
|
+
"rinse-aid": {
|
|
2623
|
+
platform: "sensor",
|
|
2624
|
+
field: "rinseAidLevel",
|
|
2625
|
+
label: "Rinse aid level",
|
|
2626
|
+
enabledByDefault: false
|
|
2627
|
+
},
|
|
2628
|
+
error: {
|
|
2629
|
+
platform: "sensor",
|
|
2630
|
+
field: "errorLabel",
|
|
2631
|
+
label: "Error",
|
|
2632
|
+
entityCategory: "diagnostic",
|
|
2633
|
+
enabledByDefault: false
|
|
2634
|
+
}
|
|
2635
|
+
},
|
|
2636
|
+
commands: {
|
|
2637
|
+
start: {
|
|
2638
|
+
label: "Start",
|
|
2639
|
+
icon: "mdi:play"
|
|
2640
|
+
},
|
|
2641
|
+
pause: {
|
|
2642
|
+
label: "Pause",
|
|
2643
|
+
icon: "mdi:pause"
|
|
2644
|
+
},
|
|
2645
|
+
resume: {
|
|
2646
|
+
label: "Resume",
|
|
2647
|
+
icon: "mdi:play-pause",
|
|
2648
|
+
enabledByDefault: false
|
|
2649
|
+
},
|
|
2650
|
+
stop: {
|
|
2651
|
+
label: "Stop",
|
|
2652
|
+
icon: "mdi:stop",
|
|
2653
|
+
enabledByDefault: false
|
|
2654
|
+
}
|
|
2655
|
+
}
|
|
2656
|
+
},
|
|
2476
2657
|
"lawn-mower-control": {
|
|
2477
2658
|
platform: "sensor",
|
|
2478
2659
|
field: "activity",
|
|
@@ -3460,6 +3641,18 @@ function parseEnum(schema, value) {
|
|
|
3460
3641
|
const parsed = schema.safeParse(value.trim().toLowerCase());
|
|
3461
3642
|
return parsed.success ? parsed.data : null;
|
|
3462
3643
|
}
|
|
3644
|
+
/** A dishwasher `{ on }` switch: an unparseable payload is refused, never read as "off". */
|
|
3645
|
+
function dishwasherSwitch(control) {
|
|
3646
|
+
return (deviceId, value) => {
|
|
3647
|
+
const on = parseBool(value);
|
|
3648
|
+
return on === null ? null : {
|
|
3649
|
+
kind: "dishwasher-switch",
|
|
3650
|
+
deviceId,
|
|
3651
|
+
control,
|
|
3652
|
+
on
|
|
3653
|
+
};
|
|
3654
|
+
};
|
|
3655
|
+
}
|
|
3463
3656
|
/**
|
|
3464
3657
|
* capability → the commands that WRITE it.
|
|
3465
3658
|
*
|
|
@@ -3814,6 +4007,39 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3814
4007
|
})
|
|
3815
4008
|
}
|
|
3816
4009
|
},
|
|
4010
|
+
dishwasher: {
|
|
4011
|
+
extras: {
|
|
4012
|
+
power: dishwasherSwitch("power"),
|
|
4013
|
+
"child-lock": dishwasherSwitch("child-lock"),
|
|
4014
|
+
"half-load": dishwasherSwitch("half-load"),
|
|
4015
|
+
"hygiene-plus": dishwasherSwitch("hygiene-plus"),
|
|
4016
|
+
"extra-dry": dishwasherSwitch("extra-dry"),
|
|
4017
|
+
"intensive-zone": dishwasherSwitch("intensive-zone"),
|
|
4018
|
+
"vario-speed-plus": dishwasherSwitch("vario-speed-plus")
|
|
4019
|
+
},
|
|
4020
|
+
commands: {
|
|
4021
|
+
start: (deviceId) => ({
|
|
4022
|
+
kind: "dishwasher-verb",
|
|
4023
|
+
deviceId,
|
|
4024
|
+
verb: "start"
|
|
4025
|
+
}),
|
|
4026
|
+
pause: (deviceId) => ({
|
|
4027
|
+
kind: "dishwasher-verb",
|
|
4028
|
+
deviceId,
|
|
4029
|
+
verb: "pause"
|
|
4030
|
+
}),
|
|
4031
|
+
resume: (deviceId) => ({
|
|
4032
|
+
kind: "dishwasher-verb",
|
|
4033
|
+
deviceId,
|
|
4034
|
+
verb: "resume"
|
|
4035
|
+
}),
|
|
4036
|
+
stop: (deviceId) => ({
|
|
4037
|
+
kind: "dishwasher-verb",
|
|
4038
|
+
deviceId,
|
|
4039
|
+
verb: "stop"
|
|
4040
|
+
})
|
|
4041
|
+
}
|
|
4042
|
+
},
|
|
3817
4043
|
"lawn-mower-control": { commands: {
|
|
3818
4044
|
start: (deviceId) => ({
|
|
3819
4045
|
kind: "mower-verb",
|
|
@@ -4334,6 +4560,25 @@ async function applyCapCommand(device, command) {
|
|
|
4334
4560
|
case "script-verb": return call(device.scriptRunner, "script-runner", (script) => command.verb === "run" ? script.run({}) : script.stop({}));
|
|
4335
4561
|
case "automation-enabled": return call(device.automationControl, "automation-control", (automation) => command.enabled ? automation.enable({}) : automation.disable({}));
|
|
4336
4562
|
case "automation-trigger": return call(device.automationControl, "automation-control", (automation) => automation.trigger({}));
|
|
4563
|
+
case "dishwasher-verb": return call(device.dishwasher, "dishwasher", (dishwasher) => {
|
|
4564
|
+
switch (command.verb) {
|
|
4565
|
+
case "start": return dishwasher.start({});
|
|
4566
|
+
case "pause": return dishwasher.pause({});
|
|
4567
|
+
case "resume": return dishwasher.resume({});
|
|
4568
|
+
case "stop": return dishwasher.stop({});
|
|
4569
|
+
}
|
|
4570
|
+
});
|
|
4571
|
+
case "dishwasher-switch": return call(device.dishwasher, "dishwasher", (dishwasher) => {
|
|
4572
|
+
switch (command.control) {
|
|
4573
|
+
case "power": return dishwasher.setPower({ on: command.on });
|
|
4574
|
+
case "child-lock": return dishwasher.setChildLock({ on: command.on });
|
|
4575
|
+
case "half-load": return dishwasher.setHalfLoad({ on: command.on });
|
|
4576
|
+
case "hygiene-plus": return dishwasher.setHygienePlus({ on: command.on });
|
|
4577
|
+
case "extra-dry": return dishwasher.setExtraDry({ on: command.on });
|
|
4578
|
+
case "intensive-zone": return dishwasher.setIntensiveZone({ on: command.on });
|
|
4579
|
+
case "vario-speed-plus": return dishwasher.setVarioSpeedPlus({ on: command.on });
|
|
4580
|
+
}
|
|
4581
|
+
});
|
|
4337
4582
|
}
|
|
4338
4583
|
}
|
|
4339
4584
|
/**
|
|
@@ -4767,14 +5012,15 @@ var PushClient = class {
|
|
|
4767
5012
|
* is flushed first — a value for an entity HA has not built yet is a
|
|
4768
5013
|
* value it discards.
|
|
4769
5014
|
*/
|
|
4770
|
-
async publishEntityChange(deviceKey, cmps, dev) {
|
|
4771
|
-
if (this.disposed) return;
|
|
5015
|
+
async publishEntityChange(deviceKey, cmps, dev, full = false) {
|
|
5016
|
+
if (this.disposed) return false;
|
|
4772
5017
|
await this.flush();
|
|
4773
|
-
|
|
5018
|
+
return this.send({
|
|
4774
5019
|
type: "entity_change",
|
|
4775
5020
|
device_id: deviceKey,
|
|
4776
5021
|
cmps,
|
|
4777
|
-
dev
|
|
5022
|
+
dev,
|
|
5023
|
+
...full ? { full: true } : {}
|
|
4778
5024
|
});
|
|
4779
5025
|
}
|
|
4780
5026
|
/** Send everything buffered now. Called before any structure change. */
|
|
@@ -4791,12 +5037,15 @@ var PushClient = class {
|
|
|
4791
5037
|
items
|
|
4792
5038
|
});
|
|
4793
5039
|
}
|
|
5040
|
+
/** `true` when Home Assistant took the message. */
|
|
4794
5041
|
async send(message) {
|
|
4795
5042
|
try {
|
|
4796
5043
|
await this.transport(message);
|
|
4797
5044
|
this.onReachable();
|
|
5045
|
+
return true;
|
|
4798
5046
|
} catch (err) {
|
|
4799
5047
|
this.onUnreachable(message, err);
|
|
5048
|
+
return false;
|
|
4800
5049
|
}
|
|
4801
5050
|
}
|
|
4802
5051
|
onReachable() {
|
|
@@ -4910,6 +5159,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
|
|
|
4910
5159
|
return reason === "structure-changed" && fingerprint === lastFingerprint;
|
|
4911
5160
|
}
|
|
4912
5161
|
//#endregion
|
|
5162
|
+
//#region src/ha-export/held-reads.ts
|
|
5163
|
+
var ReadMirror = class {
|
|
5164
|
+
keyOf;
|
|
5165
|
+
entries = /* @__PURE__ */ new Map();
|
|
5166
|
+
constructor(keyOf) {
|
|
5167
|
+
this.keyOf = keyOf;
|
|
5168
|
+
}
|
|
5169
|
+
resolve(deviceId, outcome) {
|
|
5170
|
+
const entry = this.entries.get(deviceId);
|
|
5171
|
+
if (outcome.kind === "failed") {
|
|
5172
|
+
if (entry === void 0) return {
|
|
5173
|
+
status: "unknown",
|
|
5174
|
+
reason: outcome.reason
|
|
5175
|
+
};
|
|
5176
|
+
return {
|
|
5177
|
+
status: "held-failed",
|
|
5178
|
+
items: entry.confirmed,
|
|
5179
|
+
reason: outcome.reason
|
|
5180
|
+
};
|
|
5181
|
+
}
|
|
5182
|
+
if (entry === void 0) {
|
|
5183
|
+
this.entries.set(deviceId, {
|
|
5184
|
+
confirmed: outcome.items,
|
|
5185
|
+
pendingDrop: null
|
|
5186
|
+
});
|
|
5187
|
+
return {
|
|
5188
|
+
status: "fresh",
|
|
5189
|
+
items: outcome.items
|
|
5190
|
+
};
|
|
5191
|
+
}
|
|
5192
|
+
const incoming = new Set(outcome.items.map(this.keyOf));
|
|
5193
|
+
const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
|
|
5194
|
+
if (dropped.length === 0) {
|
|
5195
|
+
entry.confirmed = outcome.items;
|
|
5196
|
+
entry.pendingDrop = null;
|
|
5197
|
+
return {
|
|
5198
|
+
status: "fresh",
|
|
5199
|
+
items: outcome.items
|
|
5200
|
+
};
|
|
5201
|
+
}
|
|
5202
|
+
const vote = dropped.join("\0");
|
|
5203
|
+
if (entry.pendingDrop === vote) {
|
|
5204
|
+
entry.confirmed = outcome.items;
|
|
5205
|
+
entry.pendingDrop = null;
|
|
5206
|
+
return {
|
|
5207
|
+
status: "fresh",
|
|
5208
|
+
items: outcome.items
|
|
5209
|
+
};
|
|
5210
|
+
}
|
|
5211
|
+
entry.pendingDrop = vote;
|
|
5212
|
+
return {
|
|
5213
|
+
status: "held-removal",
|
|
5214
|
+
items: entry.confirmed,
|
|
5215
|
+
reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
|
|
5216
|
+
};
|
|
5217
|
+
}
|
|
5218
|
+
};
|
|
5219
|
+
/**
|
|
5220
|
+
* The async sources `readCameraFunctions` could not obtain, or `null` when
|
|
5221
|
+
* every one answered. Their absence turns a function switch into "not
|
|
5222
|
+
* available", which removes its entity — a failure must not look like that.
|
|
5223
|
+
* `brokerAudioMuted` is only a read at all when the camera has a source node,
|
|
5224
|
+
* and `privacy` is a snapshot slice (absent = the cap is not there).
|
|
5225
|
+
*/
|
|
5226
|
+
function cameraFunctionReadsFailed(reads, sourceNodeId) {
|
|
5227
|
+
const failed = [];
|
|
5228
|
+
if (reads.bindableCapNames === null) failed.push("bindableCapNames");
|
|
5229
|
+
if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
|
|
5230
|
+
if (reads.recordingEnabled === null) failed.push("recordingEnabled");
|
|
5231
|
+
if (reads.notificationsMuted === null) failed.push("notificationsMuted");
|
|
5232
|
+
if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
|
|
5233
|
+
return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
|
|
5234
|
+
}
|
|
5235
|
+
//#endregion
|
|
5236
|
+
//#region src/ha-export/resync-gate.ts
|
|
5237
|
+
/**
|
|
5238
|
+
* When the exporter must send a FULL authoritative announce rather than let an
|
|
5239
|
+
* unchanged structure short-circuit.
|
|
5240
|
+
*
|
|
5241
|
+
* A gap is any moment after which the hub can no longer vouch for what Home
|
|
5242
|
+
* Assistant holds: the first pass after a start (the receiver may have been
|
|
5243
|
+
* running while this addon was down), a broker link that was just opened, an
|
|
5244
|
+
* announce or state push that was not delivered. The next pass after a gap is
|
|
5245
|
+
* a full resync, once, whatever its reason — an event-debounced pass included,
|
|
5246
|
+
* which would otherwise skip on an identical fingerprint and leave the
|
|
5247
|
+
* receiver unrepaired until the periodic pass.
|
|
5248
|
+
*
|
|
5249
|
+
* Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
|
|
5250
|
+
*/
|
|
5251
|
+
/** Reasons whose pass is a full resync whether or not a gap was noted. */
|
|
5252
|
+
var ALWAYS_FULL = new Set([
|
|
5253
|
+
"boot",
|
|
5254
|
+
"link-restored",
|
|
5255
|
+
"periodic"
|
|
5256
|
+
]);
|
|
5257
|
+
var ResyncGate = class {
|
|
5258
|
+
gap = true;
|
|
5259
|
+
lastReason = "start";
|
|
5260
|
+
/** Note that the receiver may be out of step; the next pass will be full. */
|
|
5261
|
+
markGap(reason) {
|
|
5262
|
+
this.gap = true;
|
|
5263
|
+
this.lastReason = reason;
|
|
5264
|
+
}
|
|
5265
|
+
/** `true` while a gap is waiting for a pass. */
|
|
5266
|
+
pending() {
|
|
5267
|
+
return this.gap;
|
|
5268
|
+
}
|
|
5269
|
+
/** Why the gap was opened (for the log line). */
|
|
5270
|
+
gapReason() {
|
|
5271
|
+
return this.gap ? this.lastReason : null;
|
|
5272
|
+
}
|
|
5273
|
+
/**
|
|
5274
|
+
* Called once at the start of a pass. Returns whether the pass is a full
|
|
5275
|
+
* resync and consumes the gap noted so far; a gap noted DURING the pass is
|
|
5276
|
+
* kept for the next one.
|
|
5277
|
+
*/
|
|
5278
|
+
take(reason) {
|
|
5279
|
+
const full = this.gap || ALWAYS_FULL.has(reason);
|
|
5280
|
+
this.gap = false;
|
|
5281
|
+
this.lastReason = null;
|
|
5282
|
+
return full;
|
|
5283
|
+
}
|
|
5284
|
+
};
|
|
5285
|
+
//#endregion
|
|
4913
5286
|
//#region src/ha-export/rule-triggers.ts
|
|
4914
5287
|
function reduceOne(row) {
|
|
4915
5288
|
return {
|
|
@@ -5964,9 +6337,9 @@ function projectSynthetic(input, nowMs) {
|
|
|
5964
6337
|
topic: stateTopic(srv, `${slug}_memory`),
|
|
5965
6338
|
value: String(Math.round(node.memoryPercent))
|
|
5966
6339
|
});
|
|
5967
|
-
if (node.online && node.
|
|
6340
|
+
if (node.online && node.uptimeMs > 0) values.push({
|
|
5968
6341
|
topic: stateTopic(srv, `${slug}_uptime`),
|
|
5969
|
-
value:
|
|
6342
|
+
value: new Date(nowMs - node.uptimeMs).toISOString()
|
|
5970
6343
|
});
|
|
5971
6344
|
if (node.footageUsedBytes !== null) values.push({
|
|
5972
6345
|
topic: stateTopic(srv, `${slug}_footage_used_bytes`),
|
|
@@ -6032,6 +6405,8 @@ var PUSH_PATH = "/api/camstack/push";
|
|
|
6032
6405
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6033
6406
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6034
6407
|
var DOORBELL_PULSE_MS = 5e3;
|
|
6408
|
+
/** How soon a pass with a held-back read is retried. */
|
|
6409
|
+
var READ_RETRY_MS = 3e4;
|
|
6035
6410
|
/**
|
|
6036
6411
|
* The earliest timestamp a real change could plausibly carry. Below this,
|
|
6037
6412
|
* a slice field is a placeholder or a seeded "known value, unknown change
|
|
@@ -6078,6 +6453,24 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6078
6453
|
* still holds through the periodic pass.
|
|
6079
6454
|
*/
|
|
6080
6455
|
lastStructureFingerprint;
|
|
6456
|
+
/**
|
|
6457
|
+
* When the next pass must be a FULL authoritative announce (`full: true` on
|
|
6458
|
+
* the wire) whatever the fingerprint says: a start, a new broker link, an
|
|
6459
|
+
* undelivered push, a failed pass. See `resync-gate.ts`.
|
|
6460
|
+
*/
|
|
6461
|
+
resync = new ResyncGate();
|
|
6462
|
+
/**
|
|
6463
|
+
* The last EXPORTED set of each part of a device's component set. A read
|
|
6464
|
+
* that failed (or a provider not registered yet) holds it; a shrink needs
|
|
6465
|
+
* two agreeing answers. See `held-reads.ts`.
|
|
6466
|
+
*/
|
|
6467
|
+
boundCapsMirror = new ReadMirror((cap) => cap);
|
|
6468
|
+
zonesMirror = new ReadMirror((zone) => zone.id);
|
|
6469
|
+
streamsMirror = new ReadMirror((stream) => stream.id);
|
|
6470
|
+
presetsMirror = new ReadMirror((preset) => preset);
|
|
6471
|
+
switchesMirror = new ReadMirror((fn) => fn.id);
|
|
6472
|
+
/** A held-back read is retried soon, not at the next 300 s pass. */
|
|
6473
|
+
readTimer = null;
|
|
6081
6474
|
entityCount = 0;
|
|
6082
6475
|
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
6083
6476
|
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
@@ -6154,6 +6547,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6154
6547
|
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
6155
6548
|
this.doorbellReleaseTimers.clear();
|
|
6156
6549
|
this.reachability.dispose();
|
|
6550
|
+
if (this.readTimer !== null) clearTimeout(this.readTimer);
|
|
6551
|
+
this.readTimer = null;
|
|
6157
6552
|
for (const link of this.links.values()) await link.client.dispose();
|
|
6158
6553
|
this.links.clear();
|
|
6159
6554
|
});
|
|
@@ -6626,6 +7021,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6626
7021
|
const startedAt = Date.now();
|
|
6627
7022
|
try {
|
|
6628
7023
|
await this.refreshBrokers();
|
|
7024
|
+
const fullResync = this.resync.take(reason);
|
|
7025
|
+
let readsHeld = false;
|
|
6629
7026
|
await this.refreshMediaBaseUrl();
|
|
6630
7027
|
await this.refreshComponentSupport();
|
|
6631
7028
|
const enabled = this.enabledBrokerIds();
|
|
@@ -6688,16 +7085,22 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6688
7085
|
* used to call the deprecated `getCameraSwitches` separately for the
|
|
6689
7086
|
* same answer.
|
|
6690
7087
|
*/
|
|
6691
|
-
const
|
|
7088
|
+
const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
|
|
7089
|
+
const functionReads = device.type === "camera" ? await this.readCameraFunctions({
|
|
6692
7090
|
deviceId: numericId,
|
|
6693
7091
|
deviceType: device.type,
|
|
6694
7092
|
deviceDisabled: device.disabled,
|
|
6695
7093
|
privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
|
|
6696
|
-
sourceNodeId
|
|
7094
|
+
sourceNodeId,
|
|
6697
7095
|
bindableCapsByType,
|
|
6698
7096
|
mutedDeviceIds
|
|
6699
|
-
})
|
|
6700
|
-
const
|
|
7097
|
+
}) : null;
|
|
7098
|
+
const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
|
|
7099
|
+
const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
|
|
7100
|
+
const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
|
|
7101
|
+
if (built.heldBack) readsHeld = true;
|
|
7102
|
+
const catalogDevice = built.device;
|
|
7103
|
+
if (catalogDevice === null) continue;
|
|
6701
7104
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
6702
7105
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
6703
7106
|
/**
|
|
@@ -6768,7 +7171,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6768
7171
|
this.entityCount = entityCount;
|
|
6769
7172
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
6770
7173
|
const fingerprint = structureFingerprint(exported);
|
|
6771
|
-
if (
|
|
7174
|
+
if (readsHeld) this.scheduleReadRetry();
|
|
7175
|
+
if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
|
|
6772
7176
|
this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
|
|
6773
7177
|
devices: exported.size,
|
|
6774
7178
|
entities: entityCount
|
|
@@ -6786,11 +7190,17 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6786
7190
|
entities: line.entities
|
|
6787
7191
|
}
|
|
6788
7192
|
});
|
|
6789
|
-
await this.announce(exported);
|
|
7193
|
+
const delivered = await this.announce(exported, fullResync);
|
|
6790
7194
|
await this.pushFullState(exported, snapshots);
|
|
6791
7195
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
7196
|
+
if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
|
|
7197
|
+
this.resync.markGap("push-not-delivered");
|
|
7198
|
+
this.lastStructureFingerprint = void 0;
|
|
7199
|
+
this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
|
|
7200
|
+
}
|
|
6792
7201
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6793
7202
|
reason,
|
|
7203
|
+
fullResync,
|
|
6794
7204
|
brokers: enabled.length,
|
|
6795
7205
|
devices: exported.size,
|
|
6796
7206
|
entities: entityCount,
|
|
@@ -6801,6 +7211,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6801
7211
|
this.lastError = void 0;
|
|
6802
7212
|
} catch (err) {
|
|
6803
7213
|
this.lastError = errMsg(err);
|
|
7214
|
+
this.resync.markGap("reconcile-failed");
|
|
6804
7215
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
6805
7216
|
}
|
|
6806
7217
|
}
|
|
@@ -6999,7 +7410,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6999
7410
|
online: node.isOnline,
|
|
7000
7411
|
cpuPercent: node.cpuPercent,
|
|
7001
7412
|
memoryPercent: node.memoryPercent,
|
|
7002
|
-
|
|
7413
|
+
uptimeMs: node.uptime,
|
|
7003
7414
|
isHub: node.isHub,
|
|
7004
7415
|
footageUsedBytes: footage.footageUsedBytes,
|
|
7005
7416
|
footageFreePercent: footage.footageFreePercent,
|
|
@@ -7018,13 +7429,16 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7018
7429
|
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
7019
7430
|
}
|
|
7020
7431
|
}
|
|
7021
|
-
|
|
7432
|
+
/** `false` when any announce was not delivered. */
|
|
7433
|
+
async announce(exported, full) {
|
|
7434
|
+
let allDelivered = true;
|
|
7022
7435
|
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
7023
7436
|
const link = this.links.get(brokerId);
|
|
7024
7437
|
if (link === void 0) continue;
|
|
7025
7438
|
try {
|
|
7026
|
-
await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
|
|
7439
|
+
if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
|
|
7027
7440
|
} catch (err) {
|
|
7441
|
+
allDelivered = false;
|
|
7028
7442
|
this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
|
|
7029
7443
|
tags: { deviceId: entry.deviceId },
|
|
7030
7444
|
meta: {
|
|
@@ -7034,6 +7448,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7034
7448
|
});
|
|
7035
7449
|
}
|
|
7036
7450
|
}
|
|
7451
|
+
return allDelivered;
|
|
7037
7452
|
}
|
|
7038
7453
|
async pushFullState(exported, snapshots) {
|
|
7039
7454
|
for (const entry of exported.values()) try {
|
|
@@ -7151,46 +7566,105 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7151
7566
|
}
|
|
7152
7567
|
return values;
|
|
7153
7568
|
}
|
|
7154
|
-
|
|
7569
|
+
/**
|
|
7570
|
+
* `device: null` = a part of this device's component set cannot be read and
|
|
7571
|
+
* nothing was ever exported for it, so the device is not announced this pass
|
|
7572
|
+
* (an empty part would make Home Assistant delete what it holds). `heldBack`
|
|
7573
|
+
* = at least one part is not a fresh answer, so a retry is worth arming.
|
|
7574
|
+
*/
|
|
7575
|
+
async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
|
|
7155
7576
|
const snapshot = toSnapshot(snapshotRaw);
|
|
7156
|
-
|
|
7157
|
-
|
|
7577
|
+
let heldBack = false;
|
|
7578
|
+
let unknown = false;
|
|
7579
|
+
const settle = (part, mirror, outcome) => {
|
|
7580
|
+
const resolution = mirror.resolve(device.id, outcome);
|
|
7581
|
+
if (resolution.status !== "fresh") heldBack = true;
|
|
7582
|
+
if (resolution.status === "unknown") unknown = true;
|
|
7583
|
+
this.logHeldRead(device.id, part, resolution);
|
|
7584
|
+
return resolution.status === "unknown" ? [] : resolution.items;
|
|
7585
|
+
};
|
|
7586
|
+
const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
|
|
7587
|
+
const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
|
|
7158
7588
|
/**
|
|
7159
7589
|
* Every camera function, available or not — `entity-catalog.ts` is the
|
|
7160
7590
|
* one place that filters to `available`, exactly as it did for
|
|
7161
|
-
* `getCameraSwitches`'s answer.
|
|
7591
|
+
* `getCameraSwitches`'s answer. A function that did not answer is
|
|
7592
|
+
* "unavailable", which removes its entity, so the set goes through the
|
|
7593
|
+
* mirror: held when a source failed, shrunk only on two agreeing passes.
|
|
7162
7594
|
*/
|
|
7163
|
-
const
|
|
7595
|
+
const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
|
|
7164
7596
|
id: fn.id,
|
|
7165
7597
|
label: fn.label,
|
|
7166
7598
|
available: fn.available
|
|
7167
7599
|
}));
|
|
7168
|
-
const
|
|
7169
|
-
|
|
7600
|
+
const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
|
|
7601
|
+
kind: "failed",
|
|
7602
|
+
reason: functionsFailure
|
|
7603
|
+
} : {
|
|
7604
|
+
kind: "answered",
|
|
7605
|
+
items: mappedSwitches.filter((fn) => fn.available)
|
|
7606
|
+
});
|
|
7607
|
+
const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
|
|
7608
|
+
kind: "answered",
|
|
7609
|
+
items: []
|
|
7610
|
+
}) : [];
|
|
7611
|
+
const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
|
|
7612
|
+
kind: "answered",
|
|
7613
|
+
items: []
|
|
7614
|
+
}) : [];
|
|
7615
|
+
if (unknown) return {
|
|
7616
|
+
device: null,
|
|
7617
|
+
heldBack
|
|
7618
|
+
};
|
|
7170
7619
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
7171
7620
|
const model = readString(device.metadata ?? {}, "model");
|
|
7172
7621
|
return {
|
|
7173
|
-
|
|
7174
|
-
|
|
7175
|
-
|
|
7176
|
-
|
|
7177
|
-
|
|
7178
|
-
|
|
7179
|
-
|
|
7180
|
-
|
|
7181
|
-
|
|
7182
|
-
|
|
7183
|
-
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7189
|
-
|
|
7190
|
-
|
|
7191
|
-
|
|
7622
|
+
device: {
|
|
7623
|
+
id: device.id,
|
|
7624
|
+
name: device.name,
|
|
7625
|
+
type: device.type,
|
|
7626
|
+
...manufacturer !== null ? { manufacturer } : {},
|
|
7627
|
+
...model !== null ? { model } : {},
|
|
7628
|
+
features: device.features,
|
|
7629
|
+
boundCaps,
|
|
7630
|
+
zones,
|
|
7631
|
+
switches,
|
|
7632
|
+
ptzPresets,
|
|
7633
|
+
streams,
|
|
7634
|
+
slices: Object.keys(snapshot),
|
|
7635
|
+
/**
|
|
7636
|
+
* The slice VALUES, for the native platforms only: a native
|
|
7637
|
+
* `climate` announces the modes the device accepts, and
|
|
7638
|
+
* `availableModes` is on the slice and nowhere else. See
|
|
7639
|
+
* `CatalogDevice.capSlices`.
|
|
7640
|
+
*/
|
|
7641
|
+
capSlices: snapshot
|
|
7642
|
+
},
|
|
7643
|
+
heldBack
|
|
7192
7644
|
};
|
|
7193
7645
|
}
|
|
7646
|
+
/** One WARN per held part, with the device tag and the reason. */
|
|
7647
|
+
logHeldRead(deviceId, part, resolution) {
|
|
7648
|
+
if (resolution.status === "fresh") return;
|
|
7649
|
+
const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
|
|
7650
|
+
const message = resolution.status === "unknown" ? "ha-export: a read did not answer and nothing was exported yet, the device is not announced this pass" : resolution.status === "held-failed" ? "ha-export: a read did not answer, keeping the last exported components" : "ha-export: a read dropped components, keeping them until a second answer agrees";
|
|
7651
|
+
this.ctx.logger.warn(message, {
|
|
7652
|
+
tags: { deviceId },
|
|
7653
|
+
meta: {
|
|
7654
|
+
part,
|
|
7655
|
+
reason: resolution.reason,
|
|
7656
|
+
kept
|
|
7657
|
+
}
|
|
7658
|
+
});
|
|
7659
|
+
}
|
|
7660
|
+
/** Retry soon after a held-back read: the provider usually registers seconds later. */
|
|
7661
|
+
scheduleReadRetry() {
|
|
7662
|
+
if (this.readTimer !== null) return;
|
|
7663
|
+
this.readTimer = setTimeout(() => {
|
|
7664
|
+
this.readTimer = null;
|
|
7665
|
+
this.reconcile("read-retry");
|
|
7666
|
+
}, READ_RETRY_MS);
|
|
7667
|
+
}
|
|
7194
7668
|
/**
|
|
7195
7669
|
* Every stream of the camera, as the hub's own picker lists them.
|
|
7196
7670
|
*
|
|
@@ -7208,45 +7682,54 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7208
7682
|
*/
|
|
7209
7683
|
async loadStreamChoices(deviceId) {
|
|
7210
7684
|
try {
|
|
7211
|
-
return
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7216
|
-
|
|
7685
|
+
return {
|
|
7686
|
+
kind: "answered",
|
|
7687
|
+
items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
|
|
7688
|
+
id: choice.id,
|
|
7689
|
+
label: choice.label,
|
|
7690
|
+
target: choice.target,
|
|
7691
|
+
...choice.resolution === null ? {} : { height: choice.resolution.height }
|
|
7692
|
+
}))
|
|
7693
|
+
};
|
|
7217
7694
|
} catch (err) {
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
|
|
7221
|
-
}
|
|
7222
|
-
return [];
|
|
7695
|
+
return {
|
|
7696
|
+
kind: "failed",
|
|
7697
|
+
reason: errMsg(err)
|
|
7698
|
+
};
|
|
7223
7699
|
}
|
|
7224
7700
|
}
|
|
7225
7701
|
async loadBoundCaps(deviceId) {
|
|
7226
7702
|
try {
|
|
7227
|
-
return
|
|
7703
|
+
return {
|
|
7704
|
+
kind: "answered",
|
|
7705
|
+
items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
|
|
7706
|
+
};
|
|
7228
7707
|
} catch (err) {
|
|
7229
|
-
|
|
7230
|
-
|
|
7231
|
-
|
|
7232
|
-
}
|
|
7233
|
-
return [];
|
|
7708
|
+
return {
|
|
7709
|
+
kind: "failed",
|
|
7710
|
+
reason: errMsg(err)
|
|
7711
|
+
};
|
|
7234
7712
|
}
|
|
7235
7713
|
}
|
|
7236
7714
|
async loadZones(deviceId) {
|
|
7237
7715
|
try {
|
|
7238
7716
|
const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
|
|
7239
|
-
if (snapshot === null) return
|
|
7240
|
-
|
|
7241
|
-
|
|
7242
|
-
|
|
7243
|
-
|
|
7717
|
+
if (snapshot === null) return {
|
|
7718
|
+
kind: "answered",
|
|
7719
|
+
items: []
|
|
7720
|
+
};
|
|
7721
|
+
return {
|
|
7722
|
+
kind: "answered",
|
|
7723
|
+
items: snapshot.zones.map((zone) => ({
|
|
7724
|
+
id: zone.zoneId,
|
|
7725
|
+
name: zone.zoneName
|
|
7726
|
+
}))
|
|
7727
|
+
};
|
|
7244
7728
|
} catch (err) {
|
|
7245
|
-
|
|
7246
|
-
|
|
7247
|
-
|
|
7248
|
-
}
|
|
7249
|
-
return [];
|
|
7729
|
+
return {
|
|
7730
|
+
kind: "failed",
|
|
7731
|
+
reason: errMsg(err)
|
|
7732
|
+
};
|
|
7250
7733
|
}
|
|
7251
7734
|
}
|
|
7252
7735
|
/**
|
|
@@ -7365,14 +7848,19 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7365
7848
|
async loadPresets(deviceId) {
|
|
7366
7849
|
try {
|
|
7367
7850
|
const device = await this.ctx.fetchDevice(deviceId);
|
|
7368
|
-
if (device.ptz === void 0) return
|
|
7369
|
-
|
|
7851
|
+
if (device.ptz === void 0) return {
|
|
7852
|
+
kind: "answered",
|
|
7853
|
+
items: []
|
|
7854
|
+
};
|
|
7855
|
+
return {
|
|
7856
|
+
kind: "answered",
|
|
7857
|
+
items: (await device.ptz.getPresets({})).map((preset) => preset.name)
|
|
7858
|
+
};
|
|
7370
7859
|
} catch (err) {
|
|
7371
|
-
|
|
7372
|
-
|
|
7373
|
-
|
|
7374
|
-
}
|
|
7375
|
-
return [];
|
|
7860
|
+
return {
|
|
7861
|
+
kind: "failed",
|
|
7862
|
+
reason: errMsg(err)
|
|
7863
|
+
};
|
|
7376
7864
|
}
|
|
7377
7865
|
}
|
|
7378
7866
|
/**
|
|
@@ -7451,6 +7939,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7451
7939
|
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
7452
7940
|
});
|
|
7453
7941
|
client.start();
|
|
7942
|
+
this.resync.markGap("broker-connected");
|
|
7454
7943
|
this.links.set(broker.id, {
|
|
7455
7944
|
brokerId: broker.id,
|
|
7456
7945
|
name: broker.name,
|