@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
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as oauthIntegrationCapability, Bt as EventCategory, C as buildAddonRouteProvider, Et as nodePin, I as deviceExportCapability, It as string, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema } from "../dist-
|
|
1
|
+
import { $ as oauthIntegrationCapability, Bt as EventCategory, C as buildAddonRouteProvider, Et as nodePin, I as deviceExportCapability, It as string, a as COCO_TO_MACRO, bt as BaseAddon, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema } from "../dist-CA_cJwK-.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
import "node:zlib";
|
|
4
4
|
import "node:fs";
|
|
@@ -2445,6 +2445,187 @@ var CAP_ENTITY_MAP = {
|
|
|
2445
2445
|
}
|
|
2446
2446
|
}
|
|
2447
2447
|
},
|
|
2448
|
+
/**
|
|
2449
|
+
* A dishwasher. The headline is the appliance's OWN operation state (`Run`, `DelayedStart`,
|
|
2450
|
+
* `ActionRequired`, …) — not the coarse `state`, which folds nine states into six.
|
|
2451
|
+
*
|
|
2452
|
+
* Every flag is a `binary_sensor` / `switch` whose value is `null` until the appliance has
|
|
2453
|
+
* said: the projector publishes nothing for a null, so Home Assistant shows `unknown`, never
|
|
2454
|
+
* `off` (the slice is nullable for exactly this reason).
|
|
2455
|
+
*
|
|
2456
|
+
* Not mapped, by design: `availablePrograms` / `activeEvents` are arrays, and the program
|
|
2457
|
+
* list is the appliance's own vocabulary (a static `select` would offer programs it rejects);
|
|
2458
|
+
* `selectedProgram` / `program` are exported read-only. The raised events are separate
|
|
2459
|
+
* entities already — one `binary_sensor` per event and the events emitter.
|
|
2460
|
+
*/
|
|
2461
|
+
dishwasher: {
|
|
2462
|
+
platform: "sensor",
|
|
2463
|
+
field: "operationState",
|
|
2464
|
+
icon: "mdi:dishwasher",
|
|
2465
|
+
extras: {
|
|
2466
|
+
program: {
|
|
2467
|
+
platform: "sensor",
|
|
2468
|
+
field: "program",
|
|
2469
|
+
label: "Active program",
|
|
2470
|
+
icon: "mdi:dishwasher"
|
|
2471
|
+
},
|
|
2472
|
+
"selected-program": {
|
|
2473
|
+
platform: "sensor",
|
|
2474
|
+
field: "selectedProgram",
|
|
2475
|
+
label: "Selected program",
|
|
2476
|
+
enabledByDefault: false
|
|
2477
|
+
},
|
|
2478
|
+
"remaining-time": {
|
|
2479
|
+
platform: "sensor",
|
|
2480
|
+
field: "remainingSeconds",
|
|
2481
|
+
label: "Remaining time",
|
|
2482
|
+
deviceClass: "duration",
|
|
2483
|
+
unit: "s"
|
|
2484
|
+
},
|
|
2485
|
+
"delayed-start": {
|
|
2486
|
+
platform: "sensor",
|
|
2487
|
+
field: "delayedStartSeconds",
|
|
2488
|
+
label: "Delayed start",
|
|
2489
|
+
deviceClass: "duration",
|
|
2490
|
+
unit: "s",
|
|
2491
|
+
enabledByDefault: false
|
|
2492
|
+
},
|
|
2493
|
+
progress: {
|
|
2494
|
+
platform: "sensor",
|
|
2495
|
+
field: "progressPercent",
|
|
2496
|
+
label: "Progress",
|
|
2497
|
+
unit: "%",
|
|
2498
|
+
enabledByDefault: false
|
|
2499
|
+
},
|
|
2500
|
+
door: {
|
|
2501
|
+
platform: "binary_sensor",
|
|
2502
|
+
field: "doorOpen",
|
|
2503
|
+
label: "Door",
|
|
2504
|
+
deviceClass: "door"
|
|
2505
|
+
},
|
|
2506
|
+
"door-locked": {
|
|
2507
|
+
platform: "binary_sensor",
|
|
2508
|
+
field: "doorLocked",
|
|
2509
|
+
label: "Door locked",
|
|
2510
|
+
icon: "mdi:lock",
|
|
2511
|
+
enabledByDefault: false,
|
|
2512
|
+
entityCategory: "diagnostic"
|
|
2513
|
+
},
|
|
2514
|
+
"remote-start-allowed": {
|
|
2515
|
+
platform: "binary_sensor",
|
|
2516
|
+
field: "remoteStartAllowed",
|
|
2517
|
+
label: "Remote start allowed",
|
|
2518
|
+
enabledByDefault: false
|
|
2519
|
+
},
|
|
2520
|
+
"remote-control": {
|
|
2521
|
+
platform: "binary_sensor",
|
|
2522
|
+
field: "remoteControlActive",
|
|
2523
|
+
label: "Remote control active",
|
|
2524
|
+
enabledByDefault: false,
|
|
2525
|
+
entityCategory: "diagnostic"
|
|
2526
|
+
},
|
|
2527
|
+
"local-control": {
|
|
2528
|
+
platform: "binary_sensor",
|
|
2529
|
+
field: "localControlActive",
|
|
2530
|
+
label: "Local control active",
|
|
2531
|
+
enabledByDefault: false,
|
|
2532
|
+
entityCategory: "diagnostic"
|
|
2533
|
+
},
|
|
2534
|
+
power: {
|
|
2535
|
+
platform: "switch",
|
|
2536
|
+
field: "powerOn",
|
|
2537
|
+
label: "Power",
|
|
2538
|
+
writable: true
|
|
2539
|
+
},
|
|
2540
|
+
"child-lock": {
|
|
2541
|
+
platform: "switch",
|
|
2542
|
+
field: "childLock",
|
|
2543
|
+
label: "Child lock",
|
|
2544
|
+
writable: true,
|
|
2545
|
+
entityCategory: "config",
|
|
2546
|
+
enabledByDefault: false
|
|
2547
|
+
},
|
|
2548
|
+
"half-load": {
|
|
2549
|
+
platform: "switch",
|
|
2550
|
+
field: "halfLoad",
|
|
2551
|
+
label: "Half load",
|
|
2552
|
+
writable: true,
|
|
2553
|
+
entityCategory: "config",
|
|
2554
|
+
enabledByDefault: false
|
|
2555
|
+
},
|
|
2556
|
+
"hygiene-plus": {
|
|
2557
|
+
platform: "switch",
|
|
2558
|
+
field: "hygienePlus",
|
|
2559
|
+
label: "Hygiene plus",
|
|
2560
|
+
writable: true,
|
|
2561
|
+
entityCategory: "config",
|
|
2562
|
+
enabledByDefault: false
|
|
2563
|
+
},
|
|
2564
|
+
"extra-dry": {
|
|
2565
|
+
platform: "switch",
|
|
2566
|
+
field: "extraDry",
|
|
2567
|
+
label: "Extra dry",
|
|
2568
|
+
writable: true,
|
|
2569
|
+
entityCategory: "config",
|
|
2570
|
+
enabledByDefault: false
|
|
2571
|
+
},
|
|
2572
|
+
"intensive-zone": {
|
|
2573
|
+
platform: "switch",
|
|
2574
|
+
field: "intensiveZone",
|
|
2575
|
+
label: "Intensive zone",
|
|
2576
|
+
writable: true,
|
|
2577
|
+
entityCategory: "config",
|
|
2578
|
+
enabledByDefault: false
|
|
2579
|
+
},
|
|
2580
|
+
"vario-speed-plus": {
|
|
2581
|
+
platform: "switch",
|
|
2582
|
+
field: "varioSpeedPlus",
|
|
2583
|
+
label: "VarioSpeed+",
|
|
2584
|
+
writable: true,
|
|
2585
|
+
entityCategory: "config",
|
|
2586
|
+
enabledByDefault: false
|
|
2587
|
+
},
|
|
2588
|
+
salt: {
|
|
2589
|
+
platform: "sensor",
|
|
2590
|
+
field: "saltLevel",
|
|
2591
|
+
label: "Salt level",
|
|
2592
|
+
enabledByDefault: false
|
|
2593
|
+
},
|
|
2594
|
+
"rinse-aid": {
|
|
2595
|
+
platform: "sensor",
|
|
2596
|
+
field: "rinseAidLevel",
|
|
2597
|
+
label: "Rinse aid level",
|
|
2598
|
+
enabledByDefault: false
|
|
2599
|
+
},
|
|
2600
|
+
error: {
|
|
2601
|
+
platform: "sensor",
|
|
2602
|
+
field: "errorLabel",
|
|
2603
|
+
label: "Error",
|
|
2604
|
+
entityCategory: "diagnostic",
|
|
2605
|
+
enabledByDefault: false
|
|
2606
|
+
}
|
|
2607
|
+
},
|
|
2608
|
+
commands: {
|
|
2609
|
+
start: {
|
|
2610
|
+
label: "Start",
|
|
2611
|
+
icon: "mdi:play"
|
|
2612
|
+
},
|
|
2613
|
+
pause: {
|
|
2614
|
+
label: "Pause",
|
|
2615
|
+
icon: "mdi:pause"
|
|
2616
|
+
},
|
|
2617
|
+
resume: {
|
|
2618
|
+
label: "Resume",
|
|
2619
|
+
icon: "mdi:play-pause",
|
|
2620
|
+
enabledByDefault: false
|
|
2621
|
+
},
|
|
2622
|
+
stop: {
|
|
2623
|
+
label: "Stop",
|
|
2624
|
+
icon: "mdi:stop",
|
|
2625
|
+
enabledByDefault: false
|
|
2626
|
+
}
|
|
2627
|
+
}
|
|
2628
|
+
},
|
|
2448
2629
|
"lawn-mower-control": {
|
|
2449
2630
|
platform: "sensor",
|
|
2450
2631
|
field: "activity",
|
|
@@ -3432,6 +3613,18 @@ function parseEnum(schema, value) {
|
|
|
3432
3613
|
const parsed = schema.safeParse(value.trim().toLowerCase());
|
|
3433
3614
|
return parsed.success ? parsed.data : null;
|
|
3434
3615
|
}
|
|
3616
|
+
/** A dishwasher `{ on }` switch: an unparseable payload is refused, never read as "off". */
|
|
3617
|
+
function dishwasherSwitch(control) {
|
|
3618
|
+
return (deviceId, value) => {
|
|
3619
|
+
const on = parseBool(value);
|
|
3620
|
+
return on === null ? null : {
|
|
3621
|
+
kind: "dishwasher-switch",
|
|
3622
|
+
deviceId,
|
|
3623
|
+
control,
|
|
3624
|
+
on
|
|
3625
|
+
};
|
|
3626
|
+
};
|
|
3627
|
+
}
|
|
3435
3628
|
/**
|
|
3436
3629
|
* capability → the commands that WRITE it.
|
|
3437
3630
|
*
|
|
@@ -3786,6 +3979,39 @@ var CAP_COMMAND_ROUTES = {
|
|
|
3786
3979
|
})
|
|
3787
3980
|
}
|
|
3788
3981
|
},
|
|
3982
|
+
dishwasher: {
|
|
3983
|
+
extras: {
|
|
3984
|
+
power: dishwasherSwitch("power"),
|
|
3985
|
+
"child-lock": dishwasherSwitch("child-lock"),
|
|
3986
|
+
"half-load": dishwasherSwitch("half-load"),
|
|
3987
|
+
"hygiene-plus": dishwasherSwitch("hygiene-plus"),
|
|
3988
|
+
"extra-dry": dishwasherSwitch("extra-dry"),
|
|
3989
|
+
"intensive-zone": dishwasherSwitch("intensive-zone"),
|
|
3990
|
+
"vario-speed-plus": dishwasherSwitch("vario-speed-plus")
|
|
3991
|
+
},
|
|
3992
|
+
commands: {
|
|
3993
|
+
start: (deviceId) => ({
|
|
3994
|
+
kind: "dishwasher-verb",
|
|
3995
|
+
deviceId,
|
|
3996
|
+
verb: "start"
|
|
3997
|
+
}),
|
|
3998
|
+
pause: (deviceId) => ({
|
|
3999
|
+
kind: "dishwasher-verb",
|
|
4000
|
+
deviceId,
|
|
4001
|
+
verb: "pause"
|
|
4002
|
+
}),
|
|
4003
|
+
resume: (deviceId) => ({
|
|
4004
|
+
kind: "dishwasher-verb",
|
|
4005
|
+
deviceId,
|
|
4006
|
+
verb: "resume"
|
|
4007
|
+
}),
|
|
4008
|
+
stop: (deviceId) => ({
|
|
4009
|
+
kind: "dishwasher-verb",
|
|
4010
|
+
deviceId,
|
|
4011
|
+
verb: "stop"
|
|
4012
|
+
})
|
|
4013
|
+
}
|
|
4014
|
+
},
|
|
3789
4015
|
"lawn-mower-control": { commands: {
|
|
3790
4016
|
start: (deviceId) => ({
|
|
3791
4017
|
kind: "mower-verb",
|
|
@@ -4306,6 +4532,25 @@ async function applyCapCommand(device, command) {
|
|
|
4306
4532
|
case "script-verb": return call(device.scriptRunner, "script-runner", (script) => command.verb === "run" ? script.run({}) : script.stop({}));
|
|
4307
4533
|
case "automation-enabled": return call(device.automationControl, "automation-control", (automation) => command.enabled ? automation.enable({}) : automation.disable({}));
|
|
4308
4534
|
case "automation-trigger": return call(device.automationControl, "automation-control", (automation) => automation.trigger({}));
|
|
4535
|
+
case "dishwasher-verb": return call(device.dishwasher, "dishwasher", (dishwasher) => {
|
|
4536
|
+
switch (command.verb) {
|
|
4537
|
+
case "start": return dishwasher.start({});
|
|
4538
|
+
case "pause": return dishwasher.pause({});
|
|
4539
|
+
case "resume": return dishwasher.resume({});
|
|
4540
|
+
case "stop": return dishwasher.stop({});
|
|
4541
|
+
}
|
|
4542
|
+
});
|
|
4543
|
+
case "dishwasher-switch": return call(device.dishwasher, "dishwasher", (dishwasher) => {
|
|
4544
|
+
switch (command.control) {
|
|
4545
|
+
case "power": return dishwasher.setPower({ on: command.on });
|
|
4546
|
+
case "child-lock": return dishwasher.setChildLock({ on: command.on });
|
|
4547
|
+
case "half-load": return dishwasher.setHalfLoad({ on: command.on });
|
|
4548
|
+
case "hygiene-plus": return dishwasher.setHygienePlus({ on: command.on });
|
|
4549
|
+
case "extra-dry": return dishwasher.setExtraDry({ on: command.on });
|
|
4550
|
+
case "intensive-zone": return dishwasher.setIntensiveZone({ on: command.on });
|
|
4551
|
+
case "vario-speed-plus": return dishwasher.setVarioSpeedPlus({ on: command.on });
|
|
4552
|
+
}
|
|
4553
|
+
});
|
|
4309
4554
|
}
|
|
4310
4555
|
}
|
|
4311
4556
|
/**
|
|
@@ -4739,14 +4984,15 @@ var PushClient = class {
|
|
|
4739
4984
|
* is flushed first — a value for an entity HA has not built yet is a
|
|
4740
4985
|
* value it discards.
|
|
4741
4986
|
*/
|
|
4742
|
-
async publishEntityChange(deviceKey, cmps, dev) {
|
|
4743
|
-
if (this.disposed) return;
|
|
4987
|
+
async publishEntityChange(deviceKey, cmps, dev, full = false) {
|
|
4988
|
+
if (this.disposed) return false;
|
|
4744
4989
|
await this.flush();
|
|
4745
|
-
|
|
4990
|
+
return this.send({
|
|
4746
4991
|
type: "entity_change",
|
|
4747
4992
|
device_id: deviceKey,
|
|
4748
4993
|
cmps,
|
|
4749
|
-
dev
|
|
4994
|
+
dev,
|
|
4995
|
+
...full ? { full: true } : {}
|
|
4750
4996
|
});
|
|
4751
4997
|
}
|
|
4752
4998
|
/** Send everything buffered now. Called before any structure change. */
|
|
@@ -4763,12 +5009,15 @@ var PushClient = class {
|
|
|
4763
5009
|
items
|
|
4764
5010
|
});
|
|
4765
5011
|
}
|
|
5012
|
+
/** `true` when Home Assistant took the message. */
|
|
4766
5013
|
async send(message) {
|
|
4767
5014
|
try {
|
|
4768
5015
|
await this.transport(message);
|
|
4769
5016
|
this.onReachable();
|
|
5017
|
+
return true;
|
|
4770
5018
|
} catch (err) {
|
|
4771
5019
|
this.onUnreachable(message, err);
|
|
5020
|
+
return false;
|
|
4772
5021
|
}
|
|
4773
5022
|
}
|
|
4774
5023
|
onReachable() {
|
|
@@ -4882,6 +5131,130 @@ function shouldSkipAnnounce(reason, fingerprint, lastFingerprint) {
|
|
|
4882
5131
|
return reason === "structure-changed" && fingerprint === lastFingerprint;
|
|
4883
5132
|
}
|
|
4884
5133
|
//#endregion
|
|
5134
|
+
//#region src/ha-export/held-reads.ts
|
|
5135
|
+
var ReadMirror = class {
|
|
5136
|
+
keyOf;
|
|
5137
|
+
entries = /* @__PURE__ */ new Map();
|
|
5138
|
+
constructor(keyOf) {
|
|
5139
|
+
this.keyOf = keyOf;
|
|
5140
|
+
}
|
|
5141
|
+
resolve(deviceId, outcome) {
|
|
5142
|
+
const entry = this.entries.get(deviceId);
|
|
5143
|
+
if (outcome.kind === "failed") {
|
|
5144
|
+
if (entry === void 0) return {
|
|
5145
|
+
status: "unknown",
|
|
5146
|
+
reason: outcome.reason
|
|
5147
|
+
};
|
|
5148
|
+
return {
|
|
5149
|
+
status: "held-failed",
|
|
5150
|
+
items: entry.confirmed,
|
|
5151
|
+
reason: outcome.reason
|
|
5152
|
+
};
|
|
5153
|
+
}
|
|
5154
|
+
if (entry === void 0) {
|
|
5155
|
+
this.entries.set(deviceId, {
|
|
5156
|
+
confirmed: outcome.items,
|
|
5157
|
+
pendingDrop: null
|
|
5158
|
+
});
|
|
5159
|
+
return {
|
|
5160
|
+
status: "fresh",
|
|
5161
|
+
items: outcome.items
|
|
5162
|
+
};
|
|
5163
|
+
}
|
|
5164
|
+
const incoming = new Set(outcome.items.map(this.keyOf));
|
|
5165
|
+
const dropped = entry.confirmed.map(this.keyOf).filter((key) => !incoming.has(key)).toSorted();
|
|
5166
|
+
if (dropped.length === 0) {
|
|
5167
|
+
entry.confirmed = outcome.items;
|
|
5168
|
+
entry.pendingDrop = null;
|
|
5169
|
+
return {
|
|
5170
|
+
status: "fresh",
|
|
5171
|
+
items: outcome.items
|
|
5172
|
+
};
|
|
5173
|
+
}
|
|
5174
|
+
const vote = dropped.join("\0");
|
|
5175
|
+
if (entry.pendingDrop === vote) {
|
|
5176
|
+
entry.confirmed = outcome.items;
|
|
5177
|
+
entry.pendingDrop = null;
|
|
5178
|
+
return {
|
|
5179
|
+
status: "fresh",
|
|
5180
|
+
items: outcome.items
|
|
5181
|
+
};
|
|
5182
|
+
}
|
|
5183
|
+
entry.pendingDrop = vote;
|
|
5184
|
+
return {
|
|
5185
|
+
status: "held-removal",
|
|
5186
|
+
items: entry.confirmed,
|
|
5187
|
+
reason: `an answered read dropped ${dropped.join(", ")}; waiting for a second answer to agree`
|
|
5188
|
+
};
|
|
5189
|
+
}
|
|
5190
|
+
};
|
|
5191
|
+
/**
|
|
5192
|
+
* The async sources `readCameraFunctions` could not obtain, or `null` when
|
|
5193
|
+
* every one answered. Their absence turns a function switch into "not
|
|
5194
|
+
* available", which removes its entity — a failure must not look like that.
|
|
5195
|
+
* `brokerAudioMuted` is only a read at all when the camera has a source node,
|
|
5196
|
+
* and `privacy` is a snapshot slice (absent = the cap is not there).
|
|
5197
|
+
*/
|
|
5198
|
+
function cameraFunctionReadsFailed(reads, sourceNodeId) {
|
|
5199
|
+
const failed = [];
|
|
5200
|
+
if (reads.bindableCapNames === null) failed.push("bindableCapNames");
|
|
5201
|
+
if (reads.wrappedCapNames === null) failed.push("wrappedCapNames");
|
|
5202
|
+
if (reads.recordingEnabled === null) failed.push("recordingEnabled");
|
|
5203
|
+
if (reads.notificationsMuted === null) failed.push("notificationsMuted");
|
|
5204
|
+
if (sourceNodeId !== null && reads.brokerAudioMuted === null) failed.push("brokerAudioMuted");
|
|
5205
|
+
return failed.length === 0 ? null : `did not answer: ${failed.join(", ")}`;
|
|
5206
|
+
}
|
|
5207
|
+
//#endregion
|
|
5208
|
+
//#region src/ha-export/resync-gate.ts
|
|
5209
|
+
/**
|
|
5210
|
+
* When the exporter must send a FULL authoritative announce rather than let an
|
|
5211
|
+
* unchanged structure short-circuit.
|
|
5212
|
+
*
|
|
5213
|
+
* A gap is any moment after which the hub can no longer vouch for what Home
|
|
5214
|
+
* Assistant holds: the first pass after a start (the receiver may have been
|
|
5215
|
+
* running while this addon was down), a broker link that was just opened, an
|
|
5216
|
+
* announce or state push that was not delivered. The next pass after a gap is
|
|
5217
|
+
* a full resync, once, whatever its reason — an event-debounced pass included,
|
|
5218
|
+
* which would otherwise skip on an identical fingerprint and leave the
|
|
5219
|
+
* receiver unrepaired until the periodic pass.
|
|
5220
|
+
*
|
|
5221
|
+
* Passes for 'boot', 'link-restored' and 'periodic' are full by their nature.
|
|
5222
|
+
*/
|
|
5223
|
+
/** Reasons whose pass is a full resync whether or not a gap was noted. */
|
|
5224
|
+
var ALWAYS_FULL = new Set([
|
|
5225
|
+
"boot",
|
|
5226
|
+
"link-restored",
|
|
5227
|
+
"periodic"
|
|
5228
|
+
]);
|
|
5229
|
+
var ResyncGate = class {
|
|
5230
|
+
gap = true;
|
|
5231
|
+
lastReason = "start";
|
|
5232
|
+
/** Note that the receiver may be out of step; the next pass will be full. */
|
|
5233
|
+
markGap(reason) {
|
|
5234
|
+
this.gap = true;
|
|
5235
|
+
this.lastReason = reason;
|
|
5236
|
+
}
|
|
5237
|
+
/** `true` while a gap is waiting for a pass. */
|
|
5238
|
+
pending() {
|
|
5239
|
+
return this.gap;
|
|
5240
|
+
}
|
|
5241
|
+
/** Why the gap was opened (for the log line). */
|
|
5242
|
+
gapReason() {
|
|
5243
|
+
return this.gap ? this.lastReason : null;
|
|
5244
|
+
}
|
|
5245
|
+
/**
|
|
5246
|
+
* Called once at the start of a pass. Returns whether the pass is a full
|
|
5247
|
+
* resync and consumes the gap noted so far; a gap noted DURING the pass is
|
|
5248
|
+
* kept for the next one.
|
|
5249
|
+
*/
|
|
5250
|
+
take(reason) {
|
|
5251
|
+
const full = this.gap || ALWAYS_FULL.has(reason);
|
|
5252
|
+
this.gap = false;
|
|
5253
|
+
this.lastReason = null;
|
|
5254
|
+
return full;
|
|
5255
|
+
}
|
|
5256
|
+
};
|
|
5257
|
+
//#endregion
|
|
4885
5258
|
//#region src/ha-export/rule-triggers.ts
|
|
4886
5259
|
function reduceOne(row) {
|
|
4887
5260
|
return {
|
|
@@ -5936,9 +6309,9 @@ function projectSynthetic(input, nowMs) {
|
|
|
5936
6309
|
topic: stateTopic(srv, `${slug}_memory`),
|
|
5937
6310
|
value: String(Math.round(node.memoryPercent))
|
|
5938
6311
|
});
|
|
5939
|
-
if (node.online && node.
|
|
6312
|
+
if (node.online && node.uptimeMs > 0) values.push({
|
|
5940
6313
|
topic: stateTopic(srv, `${slug}_uptime`),
|
|
5941
|
-
value:
|
|
6314
|
+
value: new Date(nowMs - node.uptimeMs).toISOString()
|
|
5942
6315
|
});
|
|
5943
6316
|
if (node.footageUsedBytes !== null) values.push({
|
|
5944
6317
|
topic: stateTopic(srv, `${slug}_footage_used_bytes`),
|
|
@@ -6004,6 +6377,8 @@ var PUSH_PATH = "/api/camstack/push";
|
|
|
6004
6377
|
var MEDIA_ROUTE_PREFIX = `/addon/${ADDON_ID}/ha-media`;
|
|
6005
6378
|
/** How long `doorbell_pressed` stays on before its OFF edge is published. */
|
|
6006
6379
|
var DOORBELL_PULSE_MS = 5e3;
|
|
6380
|
+
/** How soon a pass with a held-back read is retried. */
|
|
6381
|
+
var READ_RETRY_MS = 3e4;
|
|
6007
6382
|
/**
|
|
6008
6383
|
* The earliest timestamp a real change could plausibly carry. Below this,
|
|
6009
6384
|
* a slice field is a placeholder or a seeded "known value, unknown change
|
|
@@ -6050,6 +6425,24 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6050
6425
|
* still holds through the periodic pass.
|
|
6051
6426
|
*/
|
|
6052
6427
|
lastStructureFingerprint;
|
|
6428
|
+
/**
|
|
6429
|
+
* When the next pass must be a FULL authoritative announce (`full: true` on
|
|
6430
|
+
* the wire) whatever the fingerprint says: a start, a new broker link, an
|
|
6431
|
+
* undelivered push, a failed pass. See `resync-gate.ts`.
|
|
6432
|
+
*/
|
|
6433
|
+
resync = new ResyncGate();
|
|
6434
|
+
/**
|
|
6435
|
+
* The last EXPORTED set of each part of a device's component set. A read
|
|
6436
|
+
* that failed (or a provider not registered yet) holds it; a shrink needs
|
|
6437
|
+
* two agreeing answers. See `held-reads.ts`.
|
|
6438
|
+
*/
|
|
6439
|
+
boundCapsMirror = new ReadMirror((cap) => cap);
|
|
6440
|
+
zonesMirror = new ReadMirror((zone) => zone.id);
|
|
6441
|
+
streamsMirror = new ReadMirror((stream) => stream.id);
|
|
6442
|
+
presetsMirror = new ReadMirror((preset) => preset);
|
|
6443
|
+
switchesMirror = new ReadMirror((fn) => fn.id);
|
|
6444
|
+
/** A held-back read is retried soon, not at the next 300 s pass. */
|
|
6445
|
+
readTimer = null;
|
|
6053
6446
|
entityCount = 0;
|
|
6054
6447
|
/** deviceId → the `lastPressedAt` already rung, so a re-read does not ring again. */
|
|
6055
6448
|
lastDoorbellPressAt = /* @__PURE__ */ new Map();
|
|
@@ -6126,6 +6519,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6126
6519
|
for (const timer of this.doorbellReleaseTimers.values()) clearTimeout(timer);
|
|
6127
6520
|
this.doorbellReleaseTimers.clear();
|
|
6128
6521
|
this.reachability.dispose();
|
|
6522
|
+
if (this.readTimer !== null) clearTimeout(this.readTimer);
|
|
6523
|
+
this.readTimer = null;
|
|
6129
6524
|
for (const link of this.links.values()) await link.client.dispose();
|
|
6130
6525
|
this.links.clear();
|
|
6131
6526
|
});
|
|
@@ -6598,6 +6993,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6598
6993
|
const startedAt = Date.now();
|
|
6599
6994
|
try {
|
|
6600
6995
|
await this.refreshBrokers();
|
|
6996
|
+
const fullResync = this.resync.take(reason);
|
|
6997
|
+
let readsHeld = false;
|
|
6601
6998
|
await this.refreshMediaBaseUrl();
|
|
6602
6999
|
await this.refreshComponentSupport();
|
|
6603
7000
|
const enabled = this.enabledBrokerIds();
|
|
@@ -6660,16 +7057,22 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6660
7057
|
* used to call the deprecated `getCameraSwitches` separately for the
|
|
6661
7058
|
* same answer.
|
|
6662
7059
|
*/
|
|
6663
|
-
const
|
|
7060
|
+
const sourceNodeId = this.cameraStatusByDeviceId.get(numericId)?.assignment.sourceNodeId ?? null;
|
|
7061
|
+
const functionReads = device.type === "camera" ? await this.readCameraFunctions({
|
|
6664
7062
|
deviceId: numericId,
|
|
6665
7063
|
deviceType: device.type,
|
|
6666
7064
|
deviceDisabled: device.disabled,
|
|
6667
7065
|
privacySlice: toSnapshot(snapshots[String(numericId)])["privacy-mask"],
|
|
6668
|
-
sourceNodeId
|
|
7066
|
+
sourceNodeId,
|
|
6669
7067
|
bindableCapsByType,
|
|
6670
7068
|
mutedDeviceIds
|
|
6671
|
-
})
|
|
6672
|
-
const
|
|
7069
|
+
}) : null;
|
|
7070
|
+
const cameraFunctions = functionReads !== null ? deriveCameraFunctions(functionReads) : null;
|
|
7071
|
+
const functionsFailure = functionReads !== null ? cameraFunctionReadsFailed(functionReads, sourceNodeId) : null;
|
|
7072
|
+
const built = await this.buildCatalogDevice(device, snapshots[String(numericId)], cameraFunctions, functionsFailure);
|
|
7073
|
+
if (built.heldBack) readsHeld = true;
|
|
7074
|
+
const catalogDevice = built.device;
|
|
7075
|
+
if (catalogDevice === null) continue;
|
|
6673
7076
|
for (const cap of catalogDevice.boundCaps) allCaps.add(cap);
|
|
6674
7077
|
const brokerIds = brokersExposing(this.config.membership, deviceIdStr).filter((id) => enabled.includes(id));
|
|
6675
7078
|
/**
|
|
@@ -6740,7 +7143,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6740
7143
|
this.entityCount = entityCount;
|
|
6741
7144
|
this.unclassified = unclassifiedCaps([...allCaps]);
|
|
6742
7145
|
const fingerprint = structureFingerprint(exported);
|
|
6743
|
-
if (
|
|
7146
|
+
if (readsHeld) this.scheduleReadRetry();
|
|
7147
|
+
if (!fullResync && shouldSkipAnnounce(reason, fingerprint, this.lastStructureFingerprint)) {
|
|
6744
7148
|
this.ctx.logger.debug("ha-export: structure unchanged, skipping announce", { meta: {
|
|
6745
7149
|
devices: exported.size,
|
|
6746
7150
|
entities: entityCount
|
|
@@ -6758,11 +7162,17 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6758
7162
|
entities: line.entities
|
|
6759
7163
|
}
|
|
6760
7164
|
});
|
|
6761
|
-
await this.announce(exported);
|
|
7165
|
+
const delivered = await this.announce(exported, fullResync);
|
|
6762
7166
|
await this.pushFullState(exported, snapshots);
|
|
6763
7167
|
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
7168
|
+
if (!delivered || [...this.links.values()].some((link) => !link.client.healthy)) {
|
|
7169
|
+
this.resync.markGap("push-not-delivered");
|
|
7170
|
+
this.lastStructureFingerprint = void 0;
|
|
7171
|
+
this.ctx.logger.warn("ha-export: a push was not delivered, the next pass is a full resync");
|
|
7172
|
+
}
|
|
6764
7173
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6765
7174
|
reason,
|
|
7175
|
+
fullResync,
|
|
6766
7176
|
brokers: enabled.length,
|
|
6767
7177
|
devices: exported.size,
|
|
6768
7178
|
entities: entityCount,
|
|
@@ -6773,6 +7183,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6773
7183
|
this.lastError = void 0;
|
|
6774
7184
|
} catch (err) {
|
|
6775
7185
|
this.lastError = errMsg(err);
|
|
7186
|
+
this.resync.markGap("reconcile-failed");
|
|
6776
7187
|
this.ctx.logger.warn("ha-export: reconcile failed", { meta: { error: this.lastError } });
|
|
6777
7188
|
}
|
|
6778
7189
|
}
|
|
@@ -6971,7 +7382,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6971
7382
|
online: node.isOnline,
|
|
6972
7383
|
cpuPercent: node.cpuPercent,
|
|
6973
7384
|
memoryPercent: node.memoryPercent,
|
|
6974
|
-
|
|
7385
|
+
uptimeMs: node.uptime,
|
|
6975
7386
|
isHub: node.isHub,
|
|
6976
7387
|
footageUsedBytes: footage.footageUsedBytes,
|
|
6977
7388
|
footageFreePercent: footage.footageFreePercent,
|
|
@@ -6990,13 +7401,16 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6990
7401
|
for (const value of values) link.client.publishState(value.topic, value.value);
|
|
6991
7402
|
}
|
|
6992
7403
|
}
|
|
6993
|
-
|
|
7404
|
+
/** `false` when any announce was not delivered. */
|
|
7405
|
+
async announce(exported, full) {
|
|
7406
|
+
let allDelivered = true;
|
|
6994
7407
|
for (const entry of exported.values()) for (const brokerId of entry.brokerIds) {
|
|
6995
7408
|
const link = this.links.get(brokerId);
|
|
6996
7409
|
if (link === void 0) continue;
|
|
6997
7410
|
try {
|
|
6998
|
-
await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev);
|
|
7411
|
+
if (!await link.client.publishEntityChange(entry.plan.deviceKey, entry.plan.cmps, entry.plan.dev, full)) allDelivered = false;
|
|
6999
7412
|
} catch (err) {
|
|
7413
|
+
allDelivered = false;
|
|
7000
7414
|
this.ctx.logger.warn("ha-export: could not announce a device, skipping it", {
|
|
7001
7415
|
tags: { deviceId: entry.deviceId },
|
|
7002
7416
|
meta: {
|
|
@@ -7006,6 +7420,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7006
7420
|
});
|
|
7007
7421
|
}
|
|
7008
7422
|
}
|
|
7423
|
+
return allDelivered;
|
|
7009
7424
|
}
|
|
7010
7425
|
async pushFullState(exported, snapshots) {
|
|
7011
7426
|
for (const entry of exported.values()) try {
|
|
@@ -7123,46 +7538,105 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7123
7538
|
}
|
|
7124
7539
|
return values;
|
|
7125
7540
|
}
|
|
7126
|
-
|
|
7541
|
+
/**
|
|
7542
|
+
* `device: null` = a part of this device's component set cannot be read and
|
|
7543
|
+
* nothing was ever exported for it, so the device is not announced this pass
|
|
7544
|
+
* (an empty part would make Home Assistant delete what it holds). `heldBack`
|
|
7545
|
+
* = at least one part is not a fresh answer, so a retry is worth arming.
|
|
7546
|
+
*/
|
|
7547
|
+
async buildCatalogDevice(device, snapshotRaw, cameraFunctions, functionsFailure) {
|
|
7127
7548
|
const snapshot = toSnapshot(snapshotRaw);
|
|
7128
|
-
|
|
7129
|
-
|
|
7549
|
+
let heldBack = false;
|
|
7550
|
+
let unknown = false;
|
|
7551
|
+
const settle = (part, mirror, outcome) => {
|
|
7552
|
+
const resolution = mirror.resolve(device.id, outcome);
|
|
7553
|
+
if (resolution.status !== "fresh") heldBack = true;
|
|
7554
|
+
if (resolution.status === "unknown") unknown = true;
|
|
7555
|
+
this.logHeldRead(device.id, part, resolution);
|
|
7556
|
+
return resolution.status === "unknown" ? [] : resolution.items;
|
|
7557
|
+
};
|
|
7558
|
+
const boundCaps = settle("boundCaps", this.boundCapsMirror, await this.loadBoundCaps(device.id));
|
|
7559
|
+
const zones = device.type === "camera" ? settle("zones", this.zonesMirror, await this.loadZones(device.id)) : [];
|
|
7130
7560
|
/**
|
|
7131
7561
|
* Every camera function, available or not — `entity-catalog.ts` is the
|
|
7132
7562
|
* one place that filters to `available`, exactly as it did for
|
|
7133
|
-
* `getCameraSwitches`'s answer.
|
|
7563
|
+
* `getCameraSwitches`'s answer. A function that did not answer is
|
|
7564
|
+
* "unavailable", which removes its entity, so the set goes through the
|
|
7565
|
+
* mirror: held when a source failed, shrunk only on two agreeing passes.
|
|
7134
7566
|
*/
|
|
7135
|
-
const
|
|
7567
|
+
const mappedSwitches = (cameraFunctions ?? []).map((fn) => ({
|
|
7136
7568
|
id: fn.id,
|
|
7137
7569
|
label: fn.label,
|
|
7138
7570
|
available: fn.available
|
|
7139
7571
|
}));
|
|
7140
|
-
const
|
|
7141
|
-
|
|
7572
|
+
const switches = cameraFunctions === null ? mappedSwitches : settle("cameraFunctions", this.switchesMirror, functionsFailure !== null ? {
|
|
7573
|
+
kind: "failed",
|
|
7574
|
+
reason: functionsFailure
|
|
7575
|
+
} : {
|
|
7576
|
+
kind: "answered",
|
|
7577
|
+
items: mappedSwitches.filter((fn) => fn.available)
|
|
7578
|
+
});
|
|
7579
|
+
const ptzPresets = device.type === "camera" ? settle("ptzPresets", this.presetsMirror, boundCaps.includes("ptz") ? await this.loadPresets(device.id) : {
|
|
7580
|
+
kind: "answered",
|
|
7581
|
+
items: []
|
|
7582
|
+
}) : [];
|
|
7583
|
+
const streams = device.type === "camera" ? settle("streams", this.streamsMirror, boundCaps.includes("webrtc-session") ? await this.loadStreamChoices(device.id) : {
|
|
7584
|
+
kind: "answered",
|
|
7585
|
+
items: []
|
|
7586
|
+
}) : [];
|
|
7587
|
+
if (unknown) return {
|
|
7588
|
+
device: null,
|
|
7589
|
+
heldBack
|
|
7590
|
+
};
|
|
7142
7591
|
const manufacturer = readString(device.metadata ?? {}, "manufacturer");
|
|
7143
7592
|
const model = readString(device.metadata ?? {}, "model");
|
|
7144
7593
|
return {
|
|
7145
|
-
|
|
7146
|
-
|
|
7147
|
-
|
|
7148
|
-
|
|
7149
|
-
|
|
7150
|
-
|
|
7151
|
-
|
|
7152
|
-
|
|
7153
|
-
|
|
7154
|
-
|
|
7155
|
-
|
|
7156
|
-
|
|
7157
|
-
|
|
7158
|
-
|
|
7159
|
-
|
|
7160
|
-
|
|
7161
|
-
|
|
7162
|
-
|
|
7163
|
-
|
|
7594
|
+
device: {
|
|
7595
|
+
id: device.id,
|
|
7596
|
+
name: device.name,
|
|
7597
|
+
type: device.type,
|
|
7598
|
+
...manufacturer !== null ? { manufacturer } : {},
|
|
7599
|
+
...model !== null ? { model } : {},
|
|
7600
|
+
features: device.features,
|
|
7601
|
+
boundCaps,
|
|
7602
|
+
zones,
|
|
7603
|
+
switches,
|
|
7604
|
+
ptzPresets,
|
|
7605
|
+
streams,
|
|
7606
|
+
slices: Object.keys(snapshot),
|
|
7607
|
+
/**
|
|
7608
|
+
* The slice VALUES, for the native platforms only: a native
|
|
7609
|
+
* `climate` announces the modes the device accepts, and
|
|
7610
|
+
* `availableModes` is on the slice and nowhere else. See
|
|
7611
|
+
* `CatalogDevice.capSlices`.
|
|
7612
|
+
*/
|
|
7613
|
+
capSlices: snapshot
|
|
7614
|
+
},
|
|
7615
|
+
heldBack
|
|
7164
7616
|
};
|
|
7165
7617
|
}
|
|
7618
|
+
/** One WARN per held part, with the device tag and the reason. */
|
|
7619
|
+
logHeldRead(deviceId, part, resolution) {
|
|
7620
|
+
if (resolution.status === "fresh") return;
|
|
7621
|
+
const kept = resolution.status === "unknown" ? 0 : resolution.items.length;
|
|
7622
|
+
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";
|
|
7623
|
+
this.ctx.logger.warn(message, {
|
|
7624
|
+
tags: { deviceId },
|
|
7625
|
+
meta: {
|
|
7626
|
+
part,
|
|
7627
|
+
reason: resolution.reason,
|
|
7628
|
+
kept
|
|
7629
|
+
}
|
|
7630
|
+
});
|
|
7631
|
+
}
|
|
7632
|
+
/** Retry soon after a held-back read: the provider usually registers seconds later. */
|
|
7633
|
+
scheduleReadRetry() {
|
|
7634
|
+
if (this.readTimer !== null) return;
|
|
7635
|
+
this.readTimer = setTimeout(() => {
|
|
7636
|
+
this.readTimer = null;
|
|
7637
|
+
this.reconcile("read-retry");
|
|
7638
|
+
}, READ_RETRY_MS);
|
|
7639
|
+
}
|
|
7166
7640
|
/**
|
|
7167
7641
|
* Every stream of the camera, as the hub's own picker lists them.
|
|
7168
7642
|
*
|
|
@@ -7180,45 +7654,54 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7180
7654
|
*/
|
|
7181
7655
|
async loadStreamChoices(deviceId) {
|
|
7182
7656
|
try {
|
|
7183
|
-
return
|
|
7184
|
-
|
|
7185
|
-
|
|
7186
|
-
|
|
7187
|
-
|
|
7188
|
-
|
|
7657
|
+
return {
|
|
7658
|
+
kind: "answered",
|
|
7659
|
+
items: (await this.ctx.api.webrtcSession.listStreams.query({ deviceId })).map((choice) => ({
|
|
7660
|
+
id: choice.id,
|
|
7661
|
+
label: choice.label,
|
|
7662
|
+
target: choice.target,
|
|
7663
|
+
...choice.resolution === null ? {} : { height: choice.resolution.height }
|
|
7664
|
+
}))
|
|
7665
|
+
};
|
|
7189
7666
|
} catch (err) {
|
|
7190
|
-
|
|
7191
|
-
|
|
7192
|
-
|
|
7193
|
-
}
|
|
7194
|
-
return [];
|
|
7667
|
+
return {
|
|
7668
|
+
kind: "failed",
|
|
7669
|
+
reason: errMsg(err)
|
|
7670
|
+
};
|
|
7195
7671
|
}
|
|
7196
7672
|
}
|
|
7197
7673
|
async loadBoundCaps(deviceId) {
|
|
7198
7674
|
try {
|
|
7199
|
-
return
|
|
7675
|
+
return {
|
|
7676
|
+
kind: "answered",
|
|
7677
|
+
items: (await this.ctx.api.deviceManager.getBindings.query({ deviceId })).entries.map((entry) => entry.capName)
|
|
7678
|
+
};
|
|
7200
7679
|
} catch (err) {
|
|
7201
|
-
|
|
7202
|
-
|
|
7203
|
-
|
|
7204
|
-
}
|
|
7205
|
-
return [];
|
|
7680
|
+
return {
|
|
7681
|
+
kind: "failed",
|
|
7682
|
+
reason: errMsg(err)
|
|
7683
|
+
};
|
|
7206
7684
|
}
|
|
7207
7685
|
}
|
|
7208
7686
|
async loadZones(deviceId) {
|
|
7209
7687
|
try {
|
|
7210
7688
|
const snapshot = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId });
|
|
7211
|
-
if (snapshot === null) return
|
|
7212
|
-
|
|
7213
|
-
|
|
7214
|
-
|
|
7215
|
-
|
|
7689
|
+
if (snapshot === null) return {
|
|
7690
|
+
kind: "answered",
|
|
7691
|
+
items: []
|
|
7692
|
+
};
|
|
7693
|
+
return {
|
|
7694
|
+
kind: "answered",
|
|
7695
|
+
items: snapshot.zones.map((zone) => ({
|
|
7696
|
+
id: zone.zoneId,
|
|
7697
|
+
name: zone.zoneName
|
|
7698
|
+
}))
|
|
7699
|
+
};
|
|
7216
7700
|
} catch (err) {
|
|
7217
|
-
|
|
7218
|
-
|
|
7219
|
-
|
|
7220
|
-
}
|
|
7221
|
-
return [];
|
|
7701
|
+
return {
|
|
7702
|
+
kind: "failed",
|
|
7703
|
+
reason: errMsg(err)
|
|
7704
|
+
};
|
|
7222
7705
|
}
|
|
7223
7706
|
}
|
|
7224
7707
|
/**
|
|
@@ -7337,14 +7820,19 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7337
7820
|
async loadPresets(deviceId) {
|
|
7338
7821
|
try {
|
|
7339
7822
|
const device = await this.ctx.fetchDevice(deviceId);
|
|
7340
|
-
if (device.ptz === void 0) return
|
|
7341
|
-
|
|
7823
|
+
if (device.ptz === void 0) return {
|
|
7824
|
+
kind: "answered",
|
|
7825
|
+
items: []
|
|
7826
|
+
};
|
|
7827
|
+
return {
|
|
7828
|
+
kind: "answered",
|
|
7829
|
+
items: (await device.ptz.getPresets({})).map((preset) => preset.name)
|
|
7830
|
+
};
|
|
7342
7831
|
} catch (err) {
|
|
7343
|
-
|
|
7344
|
-
|
|
7345
|
-
|
|
7346
|
-
}
|
|
7347
|
-
return [];
|
|
7832
|
+
return {
|
|
7833
|
+
kind: "failed",
|
|
7834
|
+
reason: errMsg(err)
|
|
7835
|
+
};
|
|
7348
7836
|
}
|
|
7349
7837
|
}
|
|
7350
7838
|
/**
|
|
@@ -7423,6 +7911,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7423
7911
|
onLinkRestored: (brokerId) => this.reachability.request(brokerId)
|
|
7424
7912
|
});
|
|
7425
7913
|
client.start();
|
|
7914
|
+
this.resync.markGap("broker-connected");
|
|
7426
7915
|
this.links.set(broker.id, {
|
|
7427
7916
|
brokerId: broker.id,
|
|
7428
7917
|
name: broker.name,
|