@camstack/addon-provider-homeassistant 1.2.80 → 1.2.82
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 +92 -28
- package/dist/addon.mjs +92 -28
- package/dist/{dist-HZncHwuU.js → dist-GeeaZrMS.js} +22 -4
- package/dist/{dist-BpePMREY.mjs → dist-ru9DzHlP.mjs} +22 -4
- package/dist/ha-export/ha-export.addon.js +13 -281
- package/dist/ha-export/ha-export.addon.mjs +13 -281
- 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-GeeaZrMS.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
|
|
@@ -1410,31 +1410,14 @@ var HaClimateDevice = class extends HaBaseChildDevice {
|
|
|
1410
1410
|
this.registerClimateCap();
|
|
1411
1411
|
this.runtimeState.setCapState("climate-control", COLD_START$15);
|
|
1412
1412
|
}
|
|
1413
|
+
/** The louver vocabularies HA last advertised, for the swing setters. */
|
|
1414
|
+
swingModes = [];
|
|
1415
|
+
swingHorizontalModes = [];
|
|
1413
1416
|
handleStatePush(state) {
|
|
1414
1417
|
if (state.state === "unavailable" || state.state === "unknown") return;
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
const availableModes = readStringArray$4(attrs, "hvac_modes").map(sanitizeHvacMode).filter((m) => m !== null);
|
|
1419
|
-
const fanMode = readStringAttr$6(attrs, "fan_mode") ?? "";
|
|
1420
|
-
const availableFanModes = readStringArray$4(attrs, "fan_modes");
|
|
1421
|
-
const preset = readStringAttr$6(attrs, "preset_mode") ?? "";
|
|
1422
|
-
const availablePresets = readStringArray$4(attrs, "preset_modes");
|
|
1423
|
-
this.runtimeState.setCapState("climate-control", {
|
|
1424
|
-
mode: mode ?? "off",
|
|
1425
|
-
availableModes,
|
|
1426
|
-
fanMode,
|
|
1427
|
-
availableFanModes,
|
|
1428
|
-
preset,
|
|
1429
|
-
availablePresets,
|
|
1430
|
-
target: readNumberAttr$5(attrs, "temperature"),
|
|
1431
|
-
targetHigh: readNumberAttr$5(attrs, "target_temp_high"),
|
|
1432
|
-
targetLow: readNumberAttr$5(attrs, "target_temp_low"),
|
|
1433
|
-
targetHumidity: readNumberAttr$5(attrs, "target_humidity"),
|
|
1434
|
-
currentTemp: readNumberAttr$5(attrs, "current_temperature"),
|
|
1435
|
-
currentHumidity: readNumberAttr$5(attrs, "current_humidity"),
|
|
1436
|
-
lastFetchedAt
|
|
1437
|
-
});
|
|
1418
|
+
this.swingModes = readStringArray$4(state.attributes, "swing_modes");
|
|
1419
|
+
this.swingHorizontalModes = readStringArray$4(state.attributes, "swing_horizontal_modes");
|
|
1420
|
+
this.runtimeState.setCapState("climate-control", climateSliceFromState(state));
|
|
1438
1421
|
}
|
|
1439
1422
|
registerClimateCap() {
|
|
1440
1423
|
this.ctx.registerNativeCap(require_dist.climateControlCapability, {
|
|
@@ -1462,15 +1445,96 @@ var HaClimateDevice = class extends HaBaseChildDevice {
|
|
|
1462
1445
|
setTargetHumidity: async ({ targetHumidity }) => {
|
|
1463
1446
|
await this.callHaService("climate", "set_humidity", { humidity: targetHumidity });
|
|
1464
1447
|
},
|
|
1465
|
-
setSwingVertical: async () => {
|
|
1466
|
-
|
|
1448
|
+
setSwingVertical: async ({ on }) => {
|
|
1449
|
+
const swingMode = pickSwingMode(this.swingModes, on, VERTICAL_ON_MODES);
|
|
1450
|
+
if (swingMode === null) throw new Error("HaClimateDevice: vertical swing not supported");
|
|
1451
|
+
await this.callHaService("climate", "set_swing_mode", { swing_mode: swingMode });
|
|
1467
1452
|
},
|
|
1468
|
-
setSwingHorizontal: async () => {
|
|
1469
|
-
|
|
1453
|
+
setSwingHorizontal: async ({ on }) => {
|
|
1454
|
+
if (this.swingHorizontalModes.length > 0) {
|
|
1455
|
+
const mode = pickSwingMode(this.swingHorizontalModes, on, HORIZONTAL_ON_MODES);
|
|
1456
|
+
if (mode === null) throw new Error("HaClimateDevice: horizontal swing not supported");
|
|
1457
|
+
await this.callHaService("climate", "set_swing_horizontal_mode", { swing_horizontal_mode: mode });
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const swingMode = pickSwingMode(this.swingModes, on, HORIZONTAL_ON_MODES);
|
|
1461
|
+
if (swingMode === null) throw new Error("HaClimateDevice: horizontal swing not supported");
|
|
1462
|
+
await this.callHaService("climate", "set_swing_mode", { swing_mode: swingMode });
|
|
1470
1463
|
}
|
|
1471
1464
|
});
|
|
1472
1465
|
}
|
|
1473
1466
|
};
|
|
1467
|
+
/**
|
|
1468
|
+
* The `climate-control` slice for one HA `climate.*` state. Pure, and the ONLY
|
|
1469
|
+
* place the slice is assembled: every key the cap schema declares is present —
|
|
1470
|
+
* `swingVertical` / `swingHorizontal` are `nullable`, not optional, and a slice
|
|
1471
|
+
* that omitted them was refused by `setCapState` on every push (828 dropped
|
|
1472
|
+
* pushes in a day on the live hub, every thermostat stuck at its cold start).
|
|
1473
|
+
*/
|
|
1474
|
+
function climateSliceFromState(state) {
|
|
1475
|
+
const lastFetchedAt = Date.parse(state.last_updated) || Date.now();
|
|
1476
|
+
const attrs = state.attributes;
|
|
1477
|
+
const mode = sanitizeHvacMode(state.state);
|
|
1478
|
+
const availableModes = readStringArray$4(attrs, "hvac_modes").map(sanitizeHvacMode).filter((m) => m !== null);
|
|
1479
|
+
const fanMode = readStringAttr$6(attrs, "fan_mode") ?? "";
|
|
1480
|
+
const availableFanModes = readStringArray$4(attrs, "fan_modes");
|
|
1481
|
+
const preset = readStringAttr$6(attrs, "preset_mode") ?? "";
|
|
1482
|
+
const availablePresets = readStringArray$4(attrs, "preset_modes");
|
|
1483
|
+
return {
|
|
1484
|
+
mode: mode ?? "off",
|
|
1485
|
+
availableModes,
|
|
1486
|
+
fanMode,
|
|
1487
|
+
availableFanModes,
|
|
1488
|
+
preset,
|
|
1489
|
+
availablePresets,
|
|
1490
|
+
target: readNumberAttr$5(attrs, "temperature"),
|
|
1491
|
+
targetHigh: readNumberAttr$5(attrs, "target_temp_high"),
|
|
1492
|
+
targetLow: readNumberAttr$5(attrs, "target_temp_low"),
|
|
1493
|
+
targetHumidity: readNumberAttr$5(attrs, "target_humidity"),
|
|
1494
|
+
currentTemp: readNumberAttr$5(attrs, "current_temperature"),
|
|
1495
|
+
currentHumidity: readNumberAttr$5(attrs, "current_humidity"),
|
|
1496
|
+
...readSwing(attrs),
|
|
1497
|
+
lastFetchedAt
|
|
1498
|
+
};
|
|
1499
|
+
}
|
|
1500
|
+
/** HA `swing_mode` values that mean the VERTICAL louver is moving. */
|
|
1501
|
+
var VERTICAL_ON_MODES = [
|
|
1502
|
+
"vertical",
|
|
1503
|
+
"both",
|
|
1504
|
+
"on"
|
|
1505
|
+
];
|
|
1506
|
+
/** HA `swing_mode` / `swing_horizontal_mode` values that mean the HORIZONTAL louver is moving. */
|
|
1507
|
+
var HORIZONTAL_ON_MODES = [
|
|
1508
|
+
"horizontal",
|
|
1509
|
+
"both",
|
|
1510
|
+
"on"
|
|
1511
|
+
];
|
|
1512
|
+
/**
|
|
1513
|
+
* HA models swing as one `swing_mode` picked from `swing_modes` (`off`,
|
|
1514
|
+
* `vertical`, `horizontal`, `both`, vendor strings), plus — on newer cores —
|
|
1515
|
+
* a separate `swing_horizontal_mode`. An axis the device never advertised is
|
|
1516
|
+
* `null`: no vocabulary, no control (D62).
|
|
1517
|
+
*/
|
|
1518
|
+
function readSwing(attrs) {
|
|
1519
|
+
const swingModes = readStringArray$4(attrs, "swing_modes");
|
|
1520
|
+
const horizontalModes = readStringArray$4(attrs, "swing_horizontal_modes");
|
|
1521
|
+
const swingMode = (readStringAttr$6(attrs, "swing_mode") ?? "").toLowerCase();
|
|
1522
|
+
const horizontalMode = (readStringAttr$6(attrs, "swing_horizontal_mode") ?? "").toLowerCase();
|
|
1523
|
+
return {
|
|
1524
|
+
swingVertical: swingModes.length === 0 ? null : VERTICAL_ON_MODES.includes(swingMode),
|
|
1525
|
+
swingHorizontal: horizontalModes.length > 0 ? HORIZONTAL_ON_MODES.includes(horizontalMode) : swingModes.some((m) => HORIZONTAL_ON_MODES.includes(m.toLowerCase())) ? HORIZONTAL_ON_MODES.includes(swingMode) : null
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
/**
|
|
1529
|
+
* The HA mode that turns one axis on or off, chosen from what the device
|
|
1530
|
+
* advertised; `null` when the vocabulary has no such value.
|
|
1531
|
+
*/
|
|
1532
|
+
function pickSwingMode(advertised, enabled, onModes) {
|
|
1533
|
+
const lower = advertised.map((m) => m.toLowerCase());
|
|
1534
|
+
const wanted = enabled ? onModes : ["off"];
|
|
1535
|
+
const index = lower.findIndex((m) => wanted.includes(m));
|
|
1536
|
+
return index === -1 ? null : advertised[index] ?? null;
|
|
1537
|
+
}
|
|
1474
1538
|
function sanitizeHvacMode(s) {
|
|
1475
1539
|
switch (s) {
|
|
1476
1540
|
case "off":
|
package/dist/addon.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as presenceCapability, A as controlCapability, At as EventCategory, B as humiditySensorCapability, C as buildAddonRouteProvider, Ct as number, D as colorCapability, Dt as union, E as climateControlCapability, Et as string, F as eventEmitterCapability, G as motionCapability, H as lawnMowerControlCapability, I as fanControlCapability, J as notifierCapability, K as normalizeUnit, L as floodCapability, M as deviceAdoptionCapability, O as connectivityCapability, Ot as unknown, P as enumSensorCapability, Q as prepareNotification, R as gasCapability, S as brokerCapability, St as literal, T as carbonMonoxideCapability, Tt as record, U as lockControlCapability, V as imageCapability, W as mediaPlayerCapability, Y as numericSensorCapability, Z as powerMeterCapability, _ as automationControlCapability, _t as createEvent, at as temperatureSensorCapability, b as binaryCapability, bt as array, ct as valveCapability, d as TargetSchema, dt as weatherCapability, et as pressureSensorCapability, f as accessoriesCapability, ft as errMsg, g as ambientLightSensorCapability, gt as DeviceType, h as alarmPanelCapability, ht as DeviceRole, it as tamperCapability, j as coverCapability, k as contactCapability, kt as url, lt as vibrationCapability, m as airQualitySensorCapability, mt as DeviceFeature, n as BaseDevice, nt as smokeCapability, ot as updateCapability, p as addonRoutesCapability, q as notificationOutputCapability, r as BaseDeviceProvider, rt as switchCapability, s as EnumSensorDateTimeFormatSchema, st as vacuumControlCapability, tt as scriptRunnerCapability, ut as waterHeaterCapability, v as batteryCapability, w as buttonCapability, wt as object, x as brightnessCapability, xt as discriminatedUnion, y as bestLocationMatch, yt as _enum, z as humidifierCapability } from "./dist-
|
|
1
|
+
import { $ as presenceCapability, A as controlCapability, At as EventCategory, B as humiditySensorCapability, C as buildAddonRouteProvider, Ct as number, D as colorCapability, Dt as union, E as climateControlCapability, Et as string, F as eventEmitterCapability, G as motionCapability, H as lawnMowerControlCapability, I as fanControlCapability, J as notifierCapability, K as normalizeUnit, L as floodCapability, M as deviceAdoptionCapability, O as connectivityCapability, Ot as unknown, P as enumSensorCapability, Q as prepareNotification, R as gasCapability, S as brokerCapability, St as literal, T as carbonMonoxideCapability, Tt as record, U as lockControlCapability, V as imageCapability, W as mediaPlayerCapability, Y as numericSensorCapability, Z as powerMeterCapability, _ as automationControlCapability, _t as createEvent, at as temperatureSensorCapability, b as binaryCapability, bt as array, ct as valveCapability, d as TargetSchema, dt as weatherCapability, et as pressureSensorCapability, f as accessoriesCapability, ft as errMsg, g as ambientLightSensorCapability, gt as DeviceType, h as alarmPanelCapability, ht as DeviceRole, it as tamperCapability, j as coverCapability, k as contactCapability, kt as url, lt as vibrationCapability, m as airQualitySensorCapability, mt as DeviceFeature, n as BaseDevice, nt as smokeCapability, ot as updateCapability, p as addonRoutesCapability, q as notificationOutputCapability, r as BaseDeviceProvider, rt as switchCapability, s as EnumSensorDateTimeFormatSchema, st as vacuumControlCapability, tt as scriptRunnerCapability, ut as waterHeaterCapability, v as batteryCapability, w as buttonCapability, wt as object, x as brightnessCapability, xt as discriminatedUnion, y as bestLocationMatch, yt as _enum, z as humidifierCapability } from "./dist-ru9DzHlP.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
|
|
@@ -1409,31 +1409,14 @@ var HaClimateDevice = class extends HaBaseChildDevice {
|
|
|
1409
1409
|
this.registerClimateCap();
|
|
1410
1410
|
this.runtimeState.setCapState("climate-control", COLD_START$15);
|
|
1411
1411
|
}
|
|
1412
|
+
/** The louver vocabularies HA last advertised, for the swing setters. */
|
|
1413
|
+
swingModes = [];
|
|
1414
|
+
swingHorizontalModes = [];
|
|
1412
1415
|
handleStatePush(state) {
|
|
1413
1416
|
if (state.state === "unavailable" || state.state === "unknown") return;
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
const availableModes = readStringArray$4(attrs, "hvac_modes").map(sanitizeHvacMode).filter((m) => m !== null);
|
|
1418
|
-
const fanMode = readStringAttr$6(attrs, "fan_mode") ?? "";
|
|
1419
|
-
const availableFanModes = readStringArray$4(attrs, "fan_modes");
|
|
1420
|
-
const preset = readStringAttr$6(attrs, "preset_mode") ?? "";
|
|
1421
|
-
const availablePresets = readStringArray$4(attrs, "preset_modes");
|
|
1422
|
-
this.runtimeState.setCapState("climate-control", {
|
|
1423
|
-
mode: mode ?? "off",
|
|
1424
|
-
availableModes,
|
|
1425
|
-
fanMode,
|
|
1426
|
-
availableFanModes,
|
|
1427
|
-
preset,
|
|
1428
|
-
availablePresets,
|
|
1429
|
-
target: readNumberAttr$5(attrs, "temperature"),
|
|
1430
|
-
targetHigh: readNumberAttr$5(attrs, "target_temp_high"),
|
|
1431
|
-
targetLow: readNumberAttr$5(attrs, "target_temp_low"),
|
|
1432
|
-
targetHumidity: readNumberAttr$5(attrs, "target_humidity"),
|
|
1433
|
-
currentTemp: readNumberAttr$5(attrs, "current_temperature"),
|
|
1434
|
-
currentHumidity: readNumberAttr$5(attrs, "current_humidity"),
|
|
1435
|
-
lastFetchedAt
|
|
1436
|
-
});
|
|
1417
|
+
this.swingModes = readStringArray$4(state.attributes, "swing_modes");
|
|
1418
|
+
this.swingHorizontalModes = readStringArray$4(state.attributes, "swing_horizontal_modes");
|
|
1419
|
+
this.runtimeState.setCapState("climate-control", climateSliceFromState(state));
|
|
1437
1420
|
}
|
|
1438
1421
|
registerClimateCap() {
|
|
1439
1422
|
this.ctx.registerNativeCap(climateControlCapability, {
|
|
@@ -1461,15 +1444,96 @@ var HaClimateDevice = class extends HaBaseChildDevice {
|
|
|
1461
1444
|
setTargetHumidity: async ({ targetHumidity }) => {
|
|
1462
1445
|
await this.callHaService("climate", "set_humidity", { humidity: targetHumidity });
|
|
1463
1446
|
},
|
|
1464
|
-
setSwingVertical: async () => {
|
|
1465
|
-
|
|
1447
|
+
setSwingVertical: async ({ on }) => {
|
|
1448
|
+
const swingMode = pickSwingMode(this.swingModes, on, VERTICAL_ON_MODES);
|
|
1449
|
+
if (swingMode === null) throw new Error("HaClimateDevice: vertical swing not supported");
|
|
1450
|
+
await this.callHaService("climate", "set_swing_mode", { swing_mode: swingMode });
|
|
1466
1451
|
},
|
|
1467
|
-
setSwingHorizontal: async () => {
|
|
1468
|
-
|
|
1452
|
+
setSwingHorizontal: async ({ on }) => {
|
|
1453
|
+
if (this.swingHorizontalModes.length > 0) {
|
|
1454
|
+
const mode = pickSwingMode(this.swingHorizontalModes, on, HORIZONTAL_ON_MODES);
|
|
1455
|
+
if (mode === null) throw new Error("HaClimateDevice: horizontal swing not supported");
|
|
1456
|
+
await this.callHaService("climate", "set_swing_horizontal_mode", { swing_horizontal_mode: mode });
|
|
1457
|
+
return;
|
|
1458
|
+
}
|
|
1459
|
+
const swingMode = pickSwingMode(this.swingModes, on, HORIZONTAL_ON_MODES);
|
|
1460
|
+
if (swingMode === null) throw new Error("HaClimateDevice: horizontal swing not supported");
|
|
1461
|
+
await this.callHaService("climate", "set_swing_mode", { swing_mode: swingMode });
|
|
1469
1462
|
}
|
|
1470
1463
|
});
|
|
1471
1464
|
}
|
|
1472
1465
|
};
|
|
1466
|
+
/**
|
|
1467
|
+
* The `climate-control` slice for one HA `climate.*` state. Pure, and the ONLY
|
|
1468
|
+
* place the slice is assembled: every key the cap schema declares is present —
|
|
1469
|
+
* `swingVertical` / `swingHorizontal` are `nullable`, not optional, and a slice
|
|
1470
|
+
* that omitted them was refused by `setCapState` on every push (828 dropped
|
|
1471
|
+
* pushes in a day on the live hub, every thermostat stuck at its cold start).
|
|
1472
|
+
*/
|
|
1473
|
+
function climateSliceFromState(state) {
|
|
1474
|
+
const lastFetchedAt = Date.parse(state.last_updated) || Date.now();
|
|
1475
|
+
const attrs = state.attributes;
|
|
1476
|
+
const mode = sanitizeHvacMode(state.state);
|
|
1477
|
+
const availableModes = readStringArray$4(attrs, "hvac_modes").map(sanitizeHvacMode).filter((m) => m !== null);
|
|
1478
|
+
const fanMode = readStringAttr$6(attrs, "fan_mode") ?? "";
|
|
1479
|
+
const availableFanModes = readStringArray$4(attrs, "fan_modes");
|
|
1480
|
+
const preset = readStringAttr$6(attrs, "preset_mode") ?? "";
|
|
1481
|
+
const availablePresets = readStringArray$4(attrs, "preset_modes");
|
|
1482
|
+
return {
|
|
1483
|
+
mode: mode ?? "off",
|
|
1484
|
+
availableModes,
|
|
1485
|
+
fanMode,
|
|
1486
|
+
availableFanModes,
|
|
1487
|
+
preset,
|
|
1488
|
+
availablePresets,
|
|
1489
|
+
target: readNumberAttr$5(attrs, "temperature"),
|
|
1490
|
+
targetHigh: readNumberAttr$5(attrs, "target_temp_high"),
|
|
1491
|
+
targetLow: readNumberAttr$5(attrs, "target_temp_low"),
|
|
1492
|
+
targetHumidity: readNumberAttr$5(attrs, "target_humidity"),
|
|
1493
|
+
currentTemp: readNumberAttr$5(attrs, "current_temperature"),
|
|
1494
|
+
currentHumidity: readNumberAttr$5(attrs, "current_humidity"),
|
|
1495
|
+
...readSwing(attrs),
|
|
1496
|
+
lastFetchedAt
|
|
1497
|
+
};
|
|
1498
|
+
}
|
|
1499
|
+
/** HA `swing_mode` values that mean the VERTICAL louver is moving. */
|
|
1500
|
+
var VERTICAL_ON_MODES = [
|
|
1501
|
+
"vertical",
|
|
1502
|
+
"both",
|
|
1503
|
+
"on"
|
|
1504
|
+
];
|
|
1505
|
+
/** HA `swing_mode` / `swing_horizontal_mode` values that mean the HORIZONTAL louver is moving. */
|
|
1506
|
+
var HORIZONTAL_ON_MODES = [
|
|
1507
|
+
"horizontal",
|
|
1508
|
+
"both",
|
|
1509
|
+
"on"
|
|
1510
|
+
];
|
|
1511
|
+
/**
|
|
1512
|
+
* HA models swing as one `swing_mode` picked from `swing_modes` (`off`,
|
|
1513
|
+
* `vertical`, `horizontal`, `both`, vendor strings), plus — on newer cores —
|
|
1514
|
+
* a separate `swing_horizontal_mode`. An axis the device never advertised is
|
|
1515
|
+
* `null`: no vocabulary, no control (D62).
|
|
1516
|
+
*/
|
|
1517
|
+
function readSwing(attrs) {
|
|
1518
|
+
const swingModes = readStringArray$4(attrs, "swing_modes");
|
|
1519
|
+
const horizontalModes = readStringArray$4(attrs, "swing_horizontal_modes");
|
|
1520
|
+
const swingMode = (readStringAttr$6(attrs, "swing_mode") ?? "").toLowerCase();
|
|
1521
|
+
const horizontalMode = (readStringAttr$6(attrs, "swing_horizontal_mode") ?? "").toLowerCase();
|
|
1522
|
+
return {
|
|
1523
|
+
swingVertical: swingModes.length === 0 ? null : VERTICAL_ON_MODES.includes(swingMode),
|
|
1524
|
+
swingHorizontal: horizontalModes.length > 0 ? HORIZONTAL_ON_MODES.includes(horizontalMode) : swingModes.some((m) => HORIZONTAL_ON_MODES.includes(m.toLowerCase())) ? HORIZONTAL_ON_MODES.includes(swingMode) : null
|
|
1525
|
+
};
|
|
1526
|
+
}
|
|
1527
|
+
/**
|
|
1528
|
+
* The HA mode that turns one axis on or off, chosen from what the device
|
|
1529
|
+
* advertised; `null` when the vocabulary has no such value.
|
|
1530
|
+
*/
|
|
1531
|
+
function pickSwingMode(advertised, enabled, onModes) {
|
|
1532
|
+
const lower = advertised.map((m) => m.toLowerCase());
|
|
1533
|
+
const wanted = enabled ? onModes : ["off"];
|
|
1534
|
+
const index = lower.findIndex((m) => wanted.includes(m));
|
|
1535
|
+
return index === -1 ? null : advertised[index] ?? null;
|
|
1536
|
+
}
|
|
1473
1537
|
function sanitizeHvacMode(s) {
|
|
1474
1538
|
switch (s) {
|
|
1475
1539
|
case "off":
|
|
@@ -7756,7 +7756,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7756
7756
|
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7757
7757
|
"no-provider",
|
|
7758
7758
|
"source-unreachable",
|
|
7759
|
-
"not-configured"
|
|
7759
|
+
"not-configured",
|
|
7760
|
+
"device-disabled"
|
|
7760
7761
|
]);
|
|
7761
7762
|
/**
|
|
7762
7763
|
* One switch, resolved for one camera.
|
|
@@ -24036,12 +24037,20 @@ var BatteryStatusSchema = object({
|
|
|
24036
24037
|
* Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
|
|
24037
24038
|
* Reolink-specific for the Solar Panel 2 accessory (will become
|
|
24038
24039
|
* common on other battery cams). `'none'` means running on battery
|
|
24039
|
-
* alone.
|
|
24040
|
+
* alone — a NEGATIVE the firmware actually reported.
|
|
24041
|
+
*
|
|
24042
|
+
* `'unknown'` is the absence of an answer: the provider has not read the
|
|
24043
|
+
* power fields yet, or the firmware reported neither. Before it existed,
|
|
24044
|
+
* two unreadable fields produced `'none'`, and a camera nobody had reached
|
|
24045
|
+
* was drawn "on battery alone" — the same D315 defect `percentage` closed
|
|
24046
|
+
* by going nullable. Consumers SKIP it (no charger state published, no
|
|
24047
|
+
* glyph, no HomeKit ChargingState) rather than coerce it either way.
|
|
24040
24048
|
*/
|
|
24041
24049
|
charging: _enum([
|
|
24042
24050
|
"dc",
|
|
24043
24051
|
"solar",
|
|
24044
|
-
"none"
|
|
24052
|
+
"none",
|
|
24053
|
+
"unknown"
|
|
24045
24054
|
]),
|
|
24046
24055
|
/**
|
|
24047
24056
|
* True when the camera firmware has gone into low-power mode. Battery
|
|
@@ -24131,7 +24140,7 @@ onStatusChanged: { data: object({
|
|
|
24131
24140
|
kind: "push",
|
|
24132
24141
|
empty: {
|
|
24133
24142
|
percentage: 0,
|
|
24134
|
-
charging: "
|
|
24143
|
+
charging: "unknown",
|
|
24135
24144
|
sleeping: false,
|
|
24136
24145
|
lastUpdated: 0
|
|
24137
24146
|
}
|
|
@@ -33556,8 +33565,17 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33556
33565
|
async onRestoreDevices(savedDevices) {
|
|
33557
33566
|
const restored = /* @__PURE__ */ new Set();
|
|
33558
33567
|
const failures = [];
|
|
33568
|
+
const liveStableIds = new Set((await this.ctx.kernel.devices.getAll()).map((device) => device.stableId));
|
|
33559
33569
|
const attemptRestore = async (saved) => {
|
|
33560
33570
|
if (restored.has(saved.id)) return;
|
|
33571
|
+
if (liveStableIds.has(saved.stableId)) {
|
|
33572
|
+
this.ctx.logger.debug("Restore row already live — not dialled again", { tags: {
|
|
33573
|
+
deviceId: saved.id,
|
|
33574
|
+
stableId: saved.stableId
|
|
33575
|
+
} });
|
|
33576
|
+
restored.add(saved.id);
|
|
33577
|
+
return;
|
|
33578
|
+
}
|
|
33561
33579
|
const Class = this.deviceClasses[saved.type];
|
|
33562
33580
|
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
33563
33581
|
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
@@ -7756,7 +7756,8 @@ var CameraSwitchAuthoritySchema = discriminatedUnion("kind", [
|
|
|
7756
7756
|
var CameraSwitchUnavailableReasonSchema = _enum([
|
|
7757
7757
|
"no-provider",
|
|
7758
7758
|
"source-unreachable",
|
|
7759
|
-
"not-configured"
|
|
7759
|
+
"not-configured",
|
|
7760
|
+
"device-disabled"
|
|
7760
7761
|
]);
|
|
7761
7762
|
/**
|
|
7762
7763
|
* One switch, resolved for one camera.
|
|
@@ -24036,12 +24037,20 @@ var BatteryStatusSchema = object({
|
|
|
24036
24037
|
* Charging source. `'dc'` covers wall/USB adapters; `'solar'` is
|
|
24037
24038
|
* Reolink-specific for the Solar Panel 2 accessory (will become
|
|
24038
24039
|
* common on other battery cams). `'none'` means running on battery
|
|
24039
|
-
* alone.
|
|
24040
|
+
* alone — a NEGATIVE the firmware actually reported.
|
|
24041
|
+
*
|
|
24042
|
+
* `'unknown'` is the absence of an answer: the provider has not read the
|
|
24043
|
+
* power fields yet, or the firmware reported neither. Before it existed,
|
|
24044
|
+
* two unreadable fields produced `'none'`, and a camera nobody had reached
|
|
24045
|
+
* was drawn "on battery alone" — the same D315 defect `percentage` closed
|
|
24046
|
+
* by going nullable. Consumers SKIP it (no charger state published, no
|
|
24047
|
+
* glyph, no HomeKit ChargingState) rather than coerce it either way.
|
|
24040
24048
|
*/
|
|
24041
24049
|
charging: _enum([
|
|
24042
24050
|
"dc",
|
|
24043
24051
|
"solar",
|
|
24044
|
-
"none"
|
|
24052
|
+
"none",
|
|
24053
|
+
"unknown"
|
|
24045
24054
|
]),
|
|
24046
24055
|
/**
|
|
24047
24056
|
* True when the camera firmware has gone into low-power mode. Battery
|
|
@@ -24131,7 +24140,7 @@ onStatusChanged: { data: object({
|
|
|
24131
24140
|
kind: "push",
|
|
24132
24141
|
empty: {
|
|
24133
24142
|
percentage: 0,
|
|
24134
|
-
charging: "
|
|
24143
|
+
charging: "unknown",
|
|
24135
24144
|
sleeping: false,
|
|
24136
24145
|
lastUpdated: 0
|
|
24137
24146
|
}
|
|
@@ -33556,8 +33565,17 @@ var BaseDeviceProvider = class extends BaseAddon {
|
|
|
33556
33565
|
async onRestoreDevices(savedDevices) {
|
|
33557
33566
|
const restored = /* @__PURE__ */ new Set();
|
|
33558
33567
|
const failures = [];
|
|
33568
|
+
const liveStableIds = new Set((await this.ctx.kernel.devices.getAll()).map((device) => device.stableId));
|
|
33559
33569
|
const attemptRestore = async (saved) => {
|
|
33560
33570
|
if (restored.has(saved.id)) return;
|
|
33571
|
+
if (liveStableIds.has(saved.stableId)) {
|
|
33572
|
+
this.ctx.logger.debug("Restore row already live — not dialled again", { tags: {
|
|
33573
|
+
deviceId: saved.id,
|
|
33574
|
+
stableId: saved.stableId
|
|
33575
|
+
} });
|
|
33576
|
+
restored.add(saved.id);
|
|
33577
|
+
return;
|
|
33578
|
+
}
|
|
33561
33579
|
const Class = this.deviceClasses[saved.type];
|
|
33562
33580
|
if (!Class) throw new Error(`no device class registered for type "${saved.type}"`);
|
|
33563
33581
|
if (saved.parentDeviceId !== null && !restored.has(saved.parentDeviceId)) throw new Error(`parent device ${saved.parentDeviceId} not restored`);
|
|
@@ -3,7 +3,7 @@ Object.defineProperties(exports, {
|
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
5
|
//#endregion
|
|
6
|
-
const require_dist = require("../dist-
|
|
6
|
+
const require_dist = require("../dist-GeeaZrMS.js");
|
|
7
7
|
let node_crypto = require("node:crypto");
|
|
8
8
|
//#region src/ha-export/topics.ts
|
|
9
9
|
/**
|
|
@@ -1075,31 +1075,6 @@ var ZONE_MACROS = [
|
|
|
1075
1075
|
* already answer for automation), the per-zone timestamps, and the
|
|
1076
1076
|
* doorbell's since-restart counter.
|
|
1077
1077
|
*/
|
|
1078
|
-
/**
|
|
1079
|
-
* The snooze surface, as a `select`.
|
|
1080
|
-
*
|
|
1081
|
-
* `NC_SNOOZE_MAX_MINUTES` is 1440 server-side and there is no enum of
|
|
1082
|
-
* preset durations, so the presets are chosen here. `Off` cancels.
|
|
1083
|
-
*/
|
|
1084
|
-
var SNOOZE_OPTIONS = [
|
|
1085
|
-
"Off",
|
|
1086
|
-
"15 minutes",
|
|
1087
|
-
"30 minutes",
|
|
1088
|
-
"1 hour",
|
|
1089
|
-
"2 hours",
|
|
1090
|
-
"4 hours",
|
|
1091
|
-
"8 hours",
|
|
1092
|
-
"24 hours"
|
|
1093
|
-
];
|
|
1094
|
-
var SNOOZE_MINUTES = {
|
|
1095
|
-
"15 minutes": 15,
|
|
1096
|
-
"30 minutes": 30,
|
|
1097
|
-
"1 hour": 60,
|
|
1098
|
-
"2 hours": 120,
|
|
1099
|
-
"4 hours": 240,
|
|
1100
|
-
"8 hours": 480,
|
|
1101
|
-
"24 hours": 1440
|
|
1102
|
-
};
|
|
1103
1078
|
var PTZ_BUTTONS = [
|
|
1104
1079
|
"ptz_up",
|
|
1105
1080
|
"ptz_down",
|
|
@@ -1504,14 +1479,6 @@ function cameraSpecs(device) {
|
|
|
1504
1479
|
deviceClass: "running",
|
|
1505
1480
|
enabledByDefault: true
|
|
1506
1481
|
});
|
|
1507
|
-
specs.push({
|
|
1508
|
-
entity: "snooze",
|
|
1509
|
-
platform: "select",
|
|
1510
|
-
label: "Snooze notifications",
|
|
1511
|
-
writable: true,
|
|
1512
|
-
options: SNOOZE_OPTIONS,
|
|
1513
|
-
enabledByDefault: true
|
|
1514
|
-
});
|
|
1515
1482
|
for (const macro of EXPORTED_MACROS) {
|
|
1516
1483
|
const label = humanise(macro);
|
|
1517
1484
|
specs.push({
|
|
@@ -3671,21 +3638,6 @@ function resolveCommand(target, entity, value) {
|
|
|
3671
3638
|
}
|
|
3672
3639
|
};
|
|
3673
3640
|
}
|
|
3674
|
-
if (entity === "snooze") {
|
|
3675
|
-
if (!SNOOZE_OPTIONS.includes(value)) return {
|
|
3676
|
-
ok: false,
|
|
3677
|
-
reason: "bad-value"
|
|
3678
|
-
};
|
|
3679
|
-
const minutes = SNOOZE_MINUTES[value];
|
|
3680
|
-
return {
|
|
3681
|
-
ok: true,
|
|
3682
|
-
command: {
|
|
3683
|
-
kind: "snooze",
|
|
3684
|
-
deviceId: target.deviceId,
|
|
3685
|
-
minutes: minutes ?? null
|
|
3686
|
-
}
|
|
3687
|
-
};
|
|
3688
|
-
}
|
|
3689
3641
|
return {
|
|
3690
3642
|
ok: false,
|
|
3691
3643
|
reason: "unknown-entity"
|
|
@@ -3784,8 +3736,7 @@ function isCameraCommand(command) {
|
|
|
3784
3736
|
case "camera-function":
|
|
3785
3737
|
case "reboot":
|
|
3786
3738
|
case "ptz-move":
|
|
3787
|
-
case "ptz-preset":
|
|
3788
|
-
case "snooze": return true;
|
|
3739
|
+
case "ptz-preset": return true;
|
|
3789
3740
|
default: return false;
|
|
3790
3741
|
}
|
|
3791
3742
|
}
|
|
@@ -5008,12 +4959,13 @@ function projectZoneOccupancy(deviceKey, input) {
|
|
|
5008
4959
|
}
|
|
5009
4960
|
function projectBatterySlice(deviceKey, slice) {
|
|
5010
4961
|
const values = [{
|
|
5011
|
-
topic: stateTopic(deviceKey, "charger"),
|
|
5012
|
-
value: bool(slice.charging !== "none")
|
|
5013
|
-
}, {
|
|
5014
4962
|
topic: stateTopic(deviceKey, "sleeping"),
|
|
5015
4963
|
value: bool(slice.sleeping)
|
|
5016
4964
|
}];
|
|
4965
|
+
if (slice.charging !== "unknown") values.push({
|
|
4966
|
+
topic: stateTopic(deviceKey, "charger"),
|
|
4967
|
+
value: bool(slice.charging !== "none")
|
|
4968
|
+
});
|
|
5017
4969
|
/**
|
|
5018
4970
|
* `binary: true` means the device reports "low" or "not low" and the
|
|
5019
4971
|
* percentage is a stand-in. Publishing it would put a fabricated
|
|
@@ -5127,38 +5079,6 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
5127
5079
|
});
|
|
5128
5080
|
return values;
|
|
5129
5081
|
}
|
|
5130
|
-
/** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
|
|
5131
|
-
var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
|
|
5132
|
-
/**
|
|
5133
|
-
* The `snooze` select's STATE. The authority is
|
|
5134
|
-
* `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
|
|
5135
|
-
* not the preset label an operator picked — there is nowhere to store that
|
|
5136
|
-
* label, and there must not be: it is reconstructed from the two timestamps
|
|
5137
|
-
* every time, so it can never drift from the authority.
|
|
5138
|
-
*
|
|
5139
|
-
* No active snooze publishes `Off`, never silence: Home Assistant reads a
|
|
5140
|
-
* missing state update as "the last value still holds", so a select gone
|
|
5141
|
-
* quiet after a snooze expires would keep showing the expired duration
|
|
5142
|
-
* forever.
|
|
5143
|
-
*
|
|
5144
|
-
* A duration the select's closed vocabulary cannot express — a snooze the
|
|
5145
|
-
* viewer created with an arbitrary minute count — publishes NOTHING.
|
|
5146
|
-
* Rounding it to the nearest preset would let Home Assistant re-send a
|
|
5147
|
-
* duration the operator never chose; `unknown` is the honest state.
|
|
5148
|
-
*/
|
|
5149
|
-
function projectSnooze(deviceKey, window) {
|
|
5150
|
-
if (window === null) return [{
|
|
5151
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5152
|
-
value: "Off"
|
|
5153
|
-
}];
|
|
5154
|
-
const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
|
|
5155
|
-
const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
|
|
5156
|
-
if (option === void 0) return [];
|
|
5157
|
-
return [{
|
|
5158
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5159
|
-
value: option
|
|
5160
|
-
}];
|
|
5161
|
-
}
|
|
5162
5082
|
/**
|
|
5163
5083
|
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5164
5084
|
* a camera that is genuinely broken must never paint the same picture in Home
|
|
@@ -5317,12 +5237,6 @@ function ruleTriggerEntity(ruleId, suffix) {
|
|
|
5317
5237
|
}
|
|
5318
5238
|
function notificationCenterSpecs(input) {
|
|
5319
5239
|
return [
|
|
5320
|
-
{
|
|
5321
|
-
entity: "snoozed",
|
|
5322
|
-
platform: "binary_sensor",
|
|
5323
|
-
label: "Notifications snoozed",
|
|
5324
|
-
icon: "mdi:bell-sleep"
|
|
5325
|
-
},
|
|
5326
5240
|
{
|
|
5327
5241
|
entity: "rules_enabled",
|
|
5328
5242
|
platform: "sensor",
|
|
@@ -5332,21 +5246,6 @@ function notificationCenterSpecs(input) {
|
|
|
5332
5246
|
},
|
|
5333
5247
|
(
|
|
5334
5248
|
/**
|
|
5335
|
-
* The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
|
|
5336
|
-
* never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
|
|
5337
|
-
* it through the SAME `projectSnooze` a camera's snooze uses, so the rule
|
|
5338
|
-
* lives in one place — two expressions of it in two files is how
|
|
5339
|
-
* `audio_detected` went mute for a year.
|
|
5340
|
-
*/
|
|
5341
|
-
{
|
|
5342
|
-
entity: "snooze",
|
|
5343
|
-
platform: "select",
|
|
5344
|
-
label: "Snooze all notifications",
|
|
5345
|
-
writable: true,
|
|
5346
|
-
options: SNOOZE_OPTIONS
|
|
5347
|
-
}),
|
|
5348
|
-
(
|
|
5349
|
-
/**
|
|
5350
5249
|
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5351
5250
|
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5352
5251
|
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
@@ -5667,10 +5566,6 @@ function projectSynthetic(input, nowMs) {
|
|
|
5667
5566
|
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5668
5567
|
const online = input.nodes.filter((node) => node.online);
|
|
5669
5568
|
const values = [
|
|
5670
|
-
{
|
|
5671
|
-
topic: stateTopic(nc, "snoozed"),
|
|
5672
|
-
value: String(input.snoozed)
|
|
5673
|
-
},
|
|
5674
5569
|
{
|
|
5675
5570
|
topic: stateTopic(nc, "rules_enabled"),
|
|
5676
5571
|
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
@@ -6506,16 +6401,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6506
6401
|
});
|
|
6507
6402
|
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6508
6403
|
const bindableCapsByType = /* @__PURE__ */ new Map();
|
|
6509
|
-
/**
|
|
6510
|
-
* `notificationRules.listSnoozes` — ONE read for the whole pass, shared
|
|
6511
|
-
* by every camera's `snooze` select (Task B1) and the synthetic
|
|
6512
|
-
* `snooze` select on *Notification Center* (Task D4): two expressions
|
|
6513
|
-
* of the same read is how `audio_detected` went mute for a year.
|
|
6514
|
-
*/
|
|
6515
|
-
const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
|
|
6516
|
-
this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
|
|
6517
|
-
return [];
|
|
6518
|
-
});
|
|
6519
6404
|
const exported = /* @__PURE__ */ new Map();
|
|
6520
6405
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
6521
6406
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -6608,7 +6493,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6608
6493
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
6609
6494
|
* and not to a membership the operator picks per camera.
|
|
6610
6495
|
*/
|
|
6611
|
-
const synthetic = await this.buildSynthetic(
|
|
6496
|
+
const synthetic = await this.buildSynthetic(byId);
|
|
6612
6497
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
6613
6498
|
exported.set(plan.deviceKey, {
|
|
6614
6499
|
plan,
|
|
@@ -6648,16 +6533,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6648
6533
|
}
|
|
6649
6534
|
});
|
|
6650
6535
|
await this.announce(exported);
|
|
6651
|
-
await this.pushFullState(exported, snapshots
|
|
6652
|
-
if (synthetic !== null)
|
|
6653
|
-
/**
|
|
6654
|
-
* The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
|
|
6655
|
-
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6656
|
-
* pass already made — one expression of the rule, not two.
|
|
6657
|
-
*/
|
|
6658
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6659
|
-
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6660
|
-
}
|
|
6536
|
+
await this.pushFullState(exported, snapshots);
|
|
6537
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6661
6538
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6662
6539
|
reason,
|
|
6663
6540
|
brokers: enabled.length,
|
|
@@ -6691,7 +6568,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6691
6568
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
6692
6569
|
* announced empty on a hub that is still booting.
|
|
6693
6570
|
*/
|
|
6694
|
-
async buildSynthetic(
|
|
6571
|
+
async buildSynthetic(byId) {
|
|
6695
6572
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
6696
6573
|
id: rule.id,
|
|
6697
6574
|
name: rule.name,
|
|
@@ -6700,8 +6577,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6700
6577
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
6701
6578
|
return null;
|
|
6702
6579
|
});
|
|
6703
|
-
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6704
|
-
const anySnoozed = snoozes.length > 0;
|
|
6705
6580
|
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
6706
6581
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
6707
6582
|
return null;
|
|
@@ -6725,7 +6600,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6725
6600
|
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
6726
6601
|
return {
|
|
6727
6602
|
rules: effectiveRules,
|
|
6728
|
-
snoozed: anySnoozed,
|
|
6729
6603
|
lastTrigger,
|
|
6730
6604
|
lastTriggerByRule,
|
|
6731
6605
|
nodes,
|
|
@@ -6907,9 +6781,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6907
6781
|
}
|
|
6908
6782
|
}
|
|
6909
6783
|
}
|
|
6910
|
-
async pushFullState(exported, snapshots
|
|
6784
|
+
async pushFullState(exported, snapshots) {
|
|
6911
6785
|
for (const entry of exported.values()) try {
|
|
6912
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]
|
|
6786
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6913
6787
|
this.push(entry.deviceId, values);
|
|
6914
6788
|
} catch (err) {
|
|
6915
6789
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -6920,7 +6794,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6920
6794
|
for (const link of this.links.values()) await link.client.flush();
|
|
6921
6795
|
}
|
|
6922
6796
|
/** Everything readable without waiting for an event. */
|
|
6923
|
-
async currentStateFor(entry, snapshotRaw
|
|
6797
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
6924
6798
|
const key = entry.plan.deviceKey;
|
|
6925
6799
|
const values = [];
|
|
6926
6800
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -7000,16 +6874,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7000
6874
|
*/
|
|
7001
6875
|
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
7002
6876
|
if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
|
|
7003
|
-
/**
|
|
7004
|
-
* `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
|
|
7005
|
-
* never stored: the select's state is the SAME authority the write path
|
|
7006
|
-
* (`applySnooze`) already uses.
|
|
7007
|
-
*/
|
|
7008
|
-
const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
|
|
7009
|
-
if (snoozeValues.length === 0) {
|
|
7010
|
-
if (snoozes.filter((s) => s.scope === "device" && s.deviceId === entry.deviceId).length > 0) this.ctx.logger.debug("ha-export: an active snooze has a duration the select cannot express, publishing nothing", { tags: { deviceId: entry.deviceId } });
|
|
7011
|
-
}
|
|
7012
|
-
values.push(...snoozeValues);
|
|
7013
6877
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
7014
6878
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
7015
6879
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -7501,38 +7365,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7501
7365
|
* disagree with the admin UI is worse than no knob (D62).
|
|
7502
7366
|
*/
|
|
7503
7367
|
if (isSyntheticDeviceKey(parsed.deviceKey)) {
|
|
7504
|
-
/**
|
|
7505
|
-
* The global snooze select (Task D4) — the OTHER control this branch
|
|
7506
|
-
* routes, alongside the per-rule switches below. Same rule as the
|
|
7507
|
-
* camera's own snooze (`applySnooze`): the export writes
|
|
7508
|
-
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7509
|
-
* store of its own.
|
|
7510
|
-
*/
|
|
7511
|
-
if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7512
|
-
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7513
|
-
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7514
|
-
topic,
|
|
7515
|
-
value
|
|
7516
|
-
} });
|
|
7517
|
-
reply.status(422);
|
|
7518
|
-
reply.send({ error: "unroutable command" });
|
|
7519
|
-
return;
|
|
7520
|
-
}
|
|
7521
|
-
const minutes = SNOOZE_MINUTES[value] ?? null;
|
|
7522
|
-
try {
|
|
7523
|
-
await this.applyGlobalSnooze(minutes);
|
|
7524
|
-
reply.status(200);
|
|
7525
|
-
reply.send({});
|
|
7526
|
-
} catch (err) {
|
|
7527
|
-
this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
|
|
7528
|
-
minutes,
|
|
7529
|
-
error: errMsg(err)
|
|
7530
|
-
} });
|
|
7531
|
-
reply.status(422);
|
|
7532
|
-
reply.send({ error: "command not applied" });
|
|
7533
|
-
}
|
|
7534
|
-
return;
|
|
7535
|
-
}
|
|
7536
7368
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
7537
7369
|
if (ruleId === null) {
|
|
7538
7370
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -7702,7 +7534,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7702
7534
|
await device.ptz.goToPreset({ presetId: preset.id });
|
|
7703
7535
|
return true;
|
|
7704
7536
|
}
|
|
7705
|
-
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
7706
7537
|
}
|
|
7707
7538
|
} catch (err) {
|
|
7708
7539
|
/**
|
|
@@ -7728,78 +7559,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7728
7559
|
});
|
|
7729
7560
|
return false;
|
|
7730
7561
|
}
|
|
7731
|
-
async applySnooze(deviceId, minutes) {
|
|
7732
|
-
if (minutes === null) {
|
|
7733
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7734
|
-
const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
|
|
7735
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7736
|
-
this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
|
|
7737
|
-
tags: { deviceId },
|
|
7738
|
-
meta: { cancelled: mine.length }
|
|
7739
|
-
});
|
|
7740
|
-
} else {
|
|
7741
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7742
|
-
scope: "device",
|
|
7743
|
-
deviceId,
|
|
7744
|
-
durationMinutes: minutes
|
|
7745
|
-
} });
|
|
7746
|
-
this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
|
|
7747
|
-
tags: { deviceId },
|
|
7748
|
-
meta: { minutes }
|
|
7749
|
-
});
|
|
7750
|
-
}
|
|
7751
|
-
/**
|
|
7752
|
-
* Republish right away — the select is otherwise stale for up to
|
|
7753
|
-
* `reconcileIntervalSec` after a write Home Assistant itself made
|
|
7754
|
-
* (Task B1 passo 4).
|
|
7755
|
-
*/
|
|
7756
|
-
await this.republishSnooze(deviceId);
|
|
7757
|
-
return true;
|
|
7758
|
-
}
|
|
7759
|
-
/** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
|
|
7760
|
-
async republishSnooze(deviceId) {
|
|
7761
|
-
const key = this.keyByDeviceId.get(deviceId);
|
|
7762
|
-
if (key === void 0) return;
|
|
7763
|
-
try {
|
|
7764
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7765
|
-
this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
|
|
7766
|
-
} catch (err) {
|
|
7767
|
-
this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
|
|
7768
|
-
tags: { deviceId },
|
|
7769
|
-
meta: { error: errMsg(err) }
|
|
7770
|
-
});
|
|
7771
|
-
}
|
|
7772
|
-
}
|
|
7773
|
-
/**
|
|
7774
|
-
* The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
|
|
7775
|
-
* the same authority `applySnooze` already writes for a camera. `Off`
|
|
7776
|
-
* cancels every global snooze, exactly as a camera's `Off` cancels its own.
|
|
7777
|
-
*/
|
|
7778
|
-
async applyGlobalSnooze(minutes) {
|
|
7779
|
-
if (minutes === null) {
|
|
7780
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7781
|
-
const mine = snoozes.filter((snooze) => snooze.scope === "all");
|
|
7782
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7783
|
-
this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
|
|
7784
|
-
} else {
|
|
7785
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7786
|
-
scope: "all",
|
|
7787
|
-
durationMinutes: minutes
|
|
7788
|
-
} });
|
|
7789
|
-
this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
|
|
7790
|
-
}
|
|
7791
|
-
await this.republishGlobalSnooze();
|
|
7792
|
-
}
|
|
7793
|
-
/** Push the global `snooze` select's current state, outside the reconcile cadence. */
|
|
7794
|
-
async republishGlobalSnooze() {
|
|
7795
|
-
try {
|
|
7796
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7797
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7798
|
-
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7799
|
-
} catch (err) {
|
|
7800
|
-
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|
|
7801
|
-
}
|
|
7802
|
-
}
|
|
7803
7562
|
/**
|
|
7804
7563
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
7805
7564
|
*
|
|
@@ -8097,10 +7856,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8097
7856
|
{
|
|
8098
7857
|
label: "Capabilities unclassified",
|
|
8099
7858
|
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
8100
|
-
},
|
|
8101
|
-
{
|
|
8102
|
-
label: "Snooze options",
|
|
8103
|
-
value: SNOOZE_OPTIONS.join(", ")
|
|
8104
7859
|
}
|
|
8105
7860
|
],
|
|
8106
7861
|
note: "CamStack pushes to the CamStack custom component in Home Assistant. Home Assistant never polls the hub for a value; when the heartbeat stops, every entity is marked unavailable."
|
|
@@ -8193,29 +7948,6 @@ function toCameraStatusInput(status) {
|
|
|
8193
7948
|
recording: status.recording === null ? null : { active: status.recording.active }
|
|
8194
7949
|
};
|
|
8195
7950
|
}
|
|
8196
|
-
/**
|
|
8197
|
-
* The ACTIVE window for one scope, from a flat `listSnoozes` answer.
|
|
8198
|
-
*
|
|
8199
|
-
* "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
|
|
8200
|
-
* rows (expiry is a comparison, not a job), so a reader that skipped this
|
|
8201
|
-
* would keep publishing a duration that ended hours ago. When several windows
|
|
8202
|
-
* match, the one expiring LATEST wins: that is the duration still in effect.
|
|
8203
|
-
*/
|
|
8204
|
-
function latestSnoozeWindow(snoozes, matches, now) {
|
|
8205
|
-
const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
|
|
8206
|
-
return latest === null ? null : {
|
|
8207
|
-
startedAt: latest.startedAt,
|
|
8208
|
-
expiresAt: latest.expiresAt
|
|
8209
|
-
};
|
|
8210
|
-
}
|
|
8211
|
-
/** The camera's own `scope: 'device'` snoozes (Task B1). */
|
|
8212
|
-
function latestDeviceSnoozeWindow(snoozes, deviceId) {
|
|
8213
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
|
|
8214
|
-
}
|
|
8215
|
-
/** The global `scope: 'all'` snooze (Task D4). */
|
|
8216
|
-
function latestGlobalSnoozeWindow(snoozes) {
|
|
8217
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
|
|
8218
|
-
}
|
|
8219
7951
|
function toCountRecord(raw) {
|
|
8220
7952
|
if (raw === null || typeof raw !== "object") return {};
|
|
8221
7953
|
const out = {};
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-
|
|
1
|
+
import { At as EventCategory, C as buildAddonRouteProvider, Et as string, N as deviceExportCapability, X as oauthIntegrationCapability, a as COCO_TO_MACRO, c as FanDirectionSchema, i as CAMERA_SWITCH_ORDER, l as HvacModeSchema, o as CameraSwitchIdSchema, p as addonRoutesCapability, pt as BaseAddon, t as AlarmArmModeSchema, u as MediaPlayerRepeatSchema, vt as nodePin } from "../dist-ru9DzHlP.mjs";
|
|
2
2
|
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
3
3
|
//#region src/ha-export/topics.ts
|
|
4
4
|
/**
|
|
@@ -1070,31 +1070,6 @@ var ZONE_MACROS = [
|
|
|
1070
1070
|
* already answer for automation), the per-zone timestamps, and the
|
|
1071
1071
|
* doorbell's since-restart counter.
|
|
1072
1072
|
*/
|
|
1073
|
-
/**
|
|
1074
|
-
* The snooze surface, as a `select`.
|
|
1075
|
-
*
|
|
1076
|
-
* `NC_SNOOZE_MAX_MINUTES` is 1440 server-side and there is no enum of
|
|
1077
|
-
* preset durations, so the presets are chosen here. `Off` cancels.
|
|
1078
|
-
*/
|
|
1079
|
-
var SNOOZE_OPTIONS = [
|
|
1080
|
-
"Off",
|
|
1081
|
-
"15 minutes",
|
|
1082
|
-
"30 minutes",
|
|
1083
|
-
"1 hour",
|
|
1084
|
-
"2 hours",
|
|
1085
|
-
"4 hours",
|
|
1086
|
-
"8 hours",
|
|
1087
|
-
"24 hours"
|
|
1088
|
-
];
|
|
1089
|
-
var SNOOZE_MINUTES = {
|
|
1090
|
-
"15 minutes": 15,
|
|
1091
|
-
"30 minutes": 30,
|
|
1092
|
-
"1 hour": 60,
|
|
1093
|
-
"2 hours": 120,
|
|
1094
|
-
"4 hours": 240,
|
|
1095
|
-
"8 hours": 480,
|
|
1096
|
-
"24 hours": 1440
|
|
1097
|
-
};
|
|
1098
1073
|
var PTZ_BUTTONS = [
|
|
1099
1074
|
"ptz_up",
|
|
1100
1075
|
"ptz_down",
|
|
@@ -1499,14 +1474,6 @@ function cameraSpecs(device) {
|
|
|
1499
1474
|
deviceClass: "running",
|
|
1500
1475
|
enabledByDefault: true
|
|
1501
1476
|
});
|
|
1502
|
-
specs.push({
|
|
1503
|
-
entity: "snooze",
|
|
1504
|
-
platform: "select",
|
|
1505
|
-
label: "Snooze notifications",
|
|
1506
|
-
writable: true,
|
|
1507
|
-
options: SNOOZE_OPTIONS,
|
|
1508
|
-
enabledByDefault: true
|
|
1509
|
-
});
|
|
1510
1477
|
for (const macro of EXPORTED_MACROS) {
|
|
1511
1478
|
const label = humanise(macro);
|
|
1512
1479
|
specs.push({
|
|
@@ -3666,21 +3633,6 @@ function resolveCommand(target, entity, value) {
|
|
|
3666
3633
|
}
|
|
3667
3634
|
};
|
|
3668
3635
|
}
|
|
3669
|
-
if (entity === "snooze") {
|
|
3670
|
-
if (!SNOOZE_OPTIONS.includes(value)) return {
|
|
3671
|
-
ok: false,
|
|
3672
|
-
reason: "bad-value"
|
|
3673
|
-
};
|
|
3674
|
-
const minutes = SNOOZE_MINUTES[value];
|
|
3675
|
-
return {
|
|
3676
|
-
ok: true,
|
|
3677
|
-
command: {
|
|
3678
|
-
kind: "snooze",
|
|
3679
|
-
deviceId: target.deviceId,
|
|
3680
|
-
minutes: minutes ?? null
|
|
3681
|
-
}
|
|
3682
|
-
};
|
|
3683
|
-
}
|
|
3684
3636
|
return {
|
|
3685
3637
|
ok: false,
|
|
3686
3638
|
reason: "unknown-entity"
|
|
@@ -3779,8 +3731,7 @@ function isCameraCommand(command) {
|
|
|
3779
3731
|
case "camera-function":
|
|
3780
3732
|
case "reboot":
|
|
3781
3733
|
case "ptz-move":
|
|
3782
|
-
case "ptz-preset":
|
|
3783
|
-
case "snooze": return true;
|
|
3734
|
+
case "ptz-preset": return true;
|
|
3784
3735
|
default: return false;
|
|
3785
3736
|
}
|
|
3786
3737
|
}
|
|
@@ -5003,12 +4954,13 @@ function projectZoneOccupancy(deviceKey, input) {
|
|
|
5003
4954
|
}
|
|
5004
4955
|
function projectBatterySlice(deviceKey, slice) {
|
|
5005
4956
|
const values = [{
|
|
5006
|
-
topic: stateTopic(deviceKey, "charger"),
|
|
5007
|
-
value: bool(slice.charging !== "none")
|
|
5008
|
-
}, {
|
|
5009
4957
|
topic: stateTopic(deviceKey, "sleeping"),
|
|
5010
4958
|
value: bool(slice.sleeping)
|
|
5011
4959
|
}];
|
|
4960
|
+
if (slice.charging !== "unknown") values.push({
|
|
4961
|
+
topic: stateTopic(deviceKey, "charger"),
|
|
4962
|
+
value: bool(slice.charging !== "none")
|
|
4963
|
+
});
|
|
5012
4964
|
/**
|
|
5013
4965
|
* `binary: true` means the device reports "low" or "not low" and the
|
|
5014
4966
|
* percentage is a stand-in. Publishing it would put a fabricated
|
|
@@ -5122,38 +5074,6 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
5122
5074
|
});
|
|
5123
5075
|
return values;
|
|
5124
5076
|
}
|
|
5125
|
-
/** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
|
|
5126
|
-
var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
|
|
5127
|
-
/**
|
|
5128
|
-
* The `snooze` select's STATE. The authority is
|
|
5129
|
-
* `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
|
|
5130
|
-
* not the preset label an operator picked — there is nowhere to store that
|
|
5131
|
-
* label, and there must not be: it is reconstructed from the two timestamps
|
|
5132
|
-
* every time, so it can never drift from the authority.
|
|
5133
|
-
*
|
|
5134
|
-
* No active snooze publishes `Off`, never silence: Home Assistant reads a
|
|
5135
|
-
* missing state update as "the last value still holds", so a select gone
|
|
5136
|
-
* quiet after a snooze expires would keep showing the expired duration
|
|
5137
|
-
* forever.
|
|
5138
|
-
*
|
|
5139
|
-
* A duration the select's closed vocabulary cannot express — a snooze the
|
|
5140
|
-
* viewer created with an arbitrary minute count — publishes NOTHING.
|
|
5141
|
-
* Rounding it to the nearest preset would let Home Assistant re-send a
|
|
5142
|
-
* duration the operator never chose; `unknown` is the honest state.
|
|
5143
|
-
*/
|
|
5144
|
-
function projectSnooze(deviceKey, window) {
|
|
5145
|
-
if (window === null) return [{
|
|
5146
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5147
|
-
value: "Off"
|
|
5148
|
-
}];
|
|
5149
|
-
const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
|
|
5150
|
-
const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
|
|
5151
|
-
if (option === void 0) return [];
|
|
5152
|
-
return [{
|
|
5153
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5154
|
-
value: option
|
|
5155
|
-
}];
|
|
5156
|
-
}
|
|
5157
5077
|
/**
|
|
5158
5078
|
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5159
5079
|
* a camera that is genuinely broken must never paint the same picture in Home
|
|
@@ -5312,12 +5232,6 @@ function ruleTriggerEntity(ruleId, suffix) {
|
|
|
5312
5232
|
}
|
|
5313
5233
|
function notificationCenterSpecs(input) {
|
|
5314
5234
|
return [
|
|
5315
|
-
{
|
|
5316
|
-
entity: "snoozed",
|
|
5317
|
-
platform: "binary_sensor",
|
|
5318
|
-
label: "Notifications snoozed",
|
|
5319
|
-
icon: "mdi:bell-sleep"
|
|
5320
|
-
},
|
|
5321
5235
|
{
|
|
5322
5236
|
entity: "rules_enabled",
|
|
5323
5237
|
platform: "sensor",
|
|
@@ -5327,21 +5241,6 @@ function notificationCenterSpecs(input) {
|
|
|
5327
5241
|
},
|
|
5328
5242
|
(
|
|
5329
5243
|
/**
|
|
5330
|
-
* The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
|
|
5331
|
-
* never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
|
|
5332
|
-
* it through the SAME `projectSnooze` a camera's snooze uses, so the rule
|
|
5333
|
-
* lives in one place — two expressions of it in two files is how
|
|
5334
|
-
* `audio_detected` went mute for a year.
|
|
5335
|
-
*/
|
|
5336
|
-
{
|
|
5337
|
-
entity: "snooze",
|
|
5338
|
-
platform: "select",
|
|
5339
|
-
label: "Snooze all notifications",
|
|
5340
|
-
writable: true,
|
|
5341
|
-
options: SNOOZE_OPTIONS
|
|
5342
|
-
}),
|
|
5343
|
-
(
|
|
5344
|
-
/**
|
|
5345
5244
|
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5346
5245
|
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5347
5246
|
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
@@ -5662,10 +5561,6 @@ function projectSynthetic(input, nowMs) {
|
|
|
5662
5561
|
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5663
5562
|
const online = input.nodes.filter((node) => node.online);
|
|
5664
5563
|
const values = [
|
|
5665
|
-
{
|
|
5666
|
-
topic: stateTopic(nc, "snoozed"),
|
|
5667
|
-
value: String(input.snoozed)
|
|
5668
|
-
},
|
|
5669
5564
|
{
|
|
5670
5565
|
topic: stateTopic(nc, "rules_enabled"),
|
|
5671
5566
|
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
@@ -6501,16 +6396,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6501
6396
|
});
|
|
6502
6397
|
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6503
6398
|
const bindableCapsByType = /* @__PURE__ */ new Map();
|
|
6504
|
-
/**
|
|
6505
|
-
* `notificationRules.listSnoozes` — ONE read for the whole pass, shared
|
|
6506
|
-
* by every camera's `snooze` select (Task B1) and the synthetic
|
|
6507
|
-
* `snooze` select on *Notification Center* (Task D4): two expressions
|
|
6508
|
-
* of the same read is how `audio_detected` went mute for a year.
|
|
6509
|
-
*/
|
|
6510
|
-
const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
|
|
6511
|
-
this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
|
|
6512
|
-
return [];
|
|
6513
|
-
});
|
|
6514
6399
|
const exported = /* @__PURE__ */ new Map();
|
|
6515
6400
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
6516
6401
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -6603,7 +6488,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6603
6488
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
6604
6489
|
* and not to a membership the operator picks per camera.
|
|
6605
6490
|
*/
|
|
6606
|
-
const synthetic = await this.buildSynthetic(
|
|
6491
|
+
const synthetic = await this.buildSynthetic(byId);
|
|
6607
6492
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
6608
6493
|
exported.set(plan.deviceKey, {
|
|
6609
6494
|
plan,
|
|
@@ -6643,16 +6528,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6643
6528
|
}
|
|
6644
6529
|
});
|
|
6645
6530
|
await this.announce(exported);
|
|
6646
|
-
await this.pushFullState(exported, snapshots
|
|
6647
|
-
if (synthetic !== null)
|
|
6648
|
-
/**
|
|
6649
|
-
* The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
|
|
6650
|
-
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6651
|
-
* pass already made — one expression of the rule, not two.
|
|
6652
|
-
*/
|
|
6653
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6654
|
-
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6655
|
-
}
|
|
6531
|
+
await this.pushFullState(exported, snapshots);
|
|
6532
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6656
6533
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6657
6534
|
reason,
|
|
6658
6535
|
brokers: enabled.length,
|
|
@@ -6686,7 +6563,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6686
6563
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
6687
6564
|
* announced empty on a hub that is still booting.
|
|
6688
6565
|
*/
|
|
6689
|
-
async buildSynthetic(
|
|
6566
|
+
async buildSynthetic(byId) {
|
|
6690
6567
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
6691
6568
|
id: rule.id,
|
|
6692
6569
|
name: rule.name,
|
|
@@ -6695,8 +6572,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6695
6572
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
6696
6573
|
return null;
|
|
6697
6574
|
});
|
|
6698
|
-
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6699
|
-
const anySnoozed = snoozes.length > 0;
|
|
6700
6575
|
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
6701
6576
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
6702
6577
|
return null;
|
|
@@ -6720,7 +6595,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6720
6595
|
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
6721
6596
|
return {
|
|
6722
6597
|
rules: effectiveRules,
|
|
6723
|
-
snoozed: anySnoozed,
|
|
6724
6598
|
lastTrigger,
|
|
6725
6599
|
lastTriggerByRule,
|
|
6726
6600
|
nodes,
|
|
@@ -6902,9 +6776,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6902
6776
|
}
|
|
6903
6777
|
}
|
|
6904
6778
|
}
|
|
6905
|
-
async pushFullState(exported, snapshots
|
|
6779
|
+
async pushFullState(exported, snapshots) {
|
|
6906
6780
|
for (const entry of exported.values()) try {
|
|
6907
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]
|
|
6781
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6908
6782
|
this.push(entry.deviceId, values);
|
|
6909
6783
|
} catch (err) {
|
|
6910
6784
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -6915,7 +6789,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6915
6789
|
for (const link of this.links.values()) await link.client.flush();
|
|
6916
6790
|
}
|
|
6917
6791
|
/** Everything readable without waiting for an event. */
|
|
6918
|
-
async currentStateFor(entry, snapshotRaw
|
|
6792
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
6919
6793
|
const key = entry.plan.deviceKey;
|
|
6920
6794
|
const values = [];
|
|
6921
6795
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -6995,16 +6869,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6995
6869
|
*/
|
|
6996
6870
|
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
6997
6871
|
if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
|
|
6998
|
-
/**
|
|
6999
|
-
* `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
|
|
7000
|
-
* never stored: the select's state is the SAME authority the write path
|
|
7001
|
-
* (`applySnooze`) already uses.
|
|
7002
|
-
*/
|
|
7003
|
-
const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
|
|
7004
|
-
if (snoozeValues.length === 0) {
|
|
7005
|
-
if (snoozes.filter((s) => s.scope === "device" && s.deviceId === entry.deviceId).length > 0) this.ctx.logger.debug("ha-export: an active snooze has a duration the select cannot express, publishing nothing", { tags: { deviceId: entry.deviceId } });
|
|
7006
|
-
}
|
|
7007
|
-
values.push(...snoozeValues);
|
|
7008
6872
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
7009
6873
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
7010
6874
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -7496,38 +7360,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7496
7360
|
* disagree with the admin UI is worse than no knob (D62).
|
|
7497
7361
|
*/
|
|
7498
7362
|
if (isSyntheticDeviceKey(parsed.deviceKey)) {
|
|
7499
|
-
/**
|
|
7500
|
-
* The global snooze select (Task D4) — the OTHER control this branch
|
|
7501
|
-
* routes, alongside the per-rule switches below. Same rule as the
|
|
7502
|
-
* camera's own snooze (`applySnooze`): the export writes
|
|
7503
|
-
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7504
|
-
* store of its own.
|
|
7505
|
-
*/
|
|
7506
|
-
if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7507
|
-
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7508
|
-
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7509
|
-
topic,
|
|
7510
|
-
value
|
|
7511
|
-
} });
|
|
7512
|
-
reply.status(422);
|
|
7513
|
-
reply.send({ error: "unroutable command" });
|
|
7514
|
-
return;
|
|
7515
|
-
}
|
|
7516
|
-
const minutes = SNOOZE_MINUTES[value] ?? null;
|
|
7517
|
-
try {
|
|
7518
|
-
await this.applyGlobalSnooze(minutes);
|
|
7519
|
-
reply.status(200);
|
|
7520
|
-
reply.send({});
|
|
7521
|
-
} catch (err) {
|
|
7522
|
-
this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
|
|
7523
|
-
minutes,
|
|
7524
|
-
error: errMsg(err)
|
|
7525
|
-
} });
|
|
7526
|
-
reply.status(422);
|
|
7527
|
-
reply.send({ error: "command not applied" });
|
|
7528
|
-
}
|
|
7529
|
-
return;
|
|
7530
|
-
}
|
|
7531
7363
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
7532
7364
|
if (ruleId === null) {
|
|
7533
7365
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -7697,7 +7529,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7697
7529
|
await device.ptz.goToPreset({ presetId: preset.id });
|
|
7698
7530
|
return true;
|
|
7699
7531
|
}
|
|
7700
|
-
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
7701
7532
|
}
|
|
7702
7533
|
} catch (err) {
|
|
7703
7534
|
/**
|
|
@@ -7723,78 +7554,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7723
7554
|
});
|
|
7724
7555
|
return false;
|
|
7725
7556
|
}
|
|
7726
|
-
async applySnooze(deviceId, minutes) {
|
|
7727
|
-
if (minutes === null) {
|
|
7728
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7729
|
-
const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
|
|
7730
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7731
|
-
this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
|
|
7732
|
-
tags: { deviceId },
|
|
7733
|
-
meta: { cancelled: mine.length }
|
|
7734
|
-
});
|
|
7735
|
-
} else {
|
|
7736
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7737
|
-
scope: "device",
|
|
7738
|
-
deviceId,
|
|
7739
|
-
durationMinutes: minutes
|
|
7740
|
-
} });
|
|
7741
|
-
this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
|
|
7742
|
-
tags: { deviceId },
|
|
7743
|
-
meta: { minutes }
|
|
7744
|
-
});
|
|
7745
|
-
}
|
|
7746
|
-
/**
|
|
7747
|
-
* Republish right away — the select is otherwise stale for up to
|
|
7748
|
-
* `reconcileIntervalSec` after a write Home Assistant itself made
|
|
7749
|
-
* (Task B1 passo 4).
|
|
7750
|
-
*/
|
|
7751
|
-
await this.republishSnooze(deviceId);
|
|
7752
|
-
return true;
|
|
7753
|
-
}
|
|
7754
|
-
/** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
|
|
7755
|
-
async republishSnooze(deviceId) {
|
|
7756
|
-
const key = this.keyByDeviceId.get(deviceId);
|
|
7757
|
-
if (key === void 0) return;
|
|
7758
|
-
try {
|
|
7759
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7760
|
-
this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
|
|
7761
|
-
} catch (err) {
|
|
7762
|
-
this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
|
|
7763
|
-
tags: { deviceId },
|
|
7764
|
-
meta: { error: errMsg(err) }
|
|
7765
|
-
});
|
|
7766
|
-
}
|
|
7767
|
-
}
|
|
7768
|
-
/**
|
|
7769
|
-
* The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
|
|
7770
|
-
* the same authority `applySnooze` already writes for a camera. `Off`
|
|
7771
|
-
* cancels every global snooze, exactly as a camera's `Off` cancels its own.
|
|
7772
|
-
*/
|
|
7773
|
-
async applyGlobalSnooze(minutes) {
|
|
7774
|
-
if (minutes === null) {
|
|
7775
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7776
|
-
const mine = snoozes.filter((snooze) => snooze.scope === "all");
|
|
7777
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7778
|
-
this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
|
|
7779
|
-
} else {
|
|
7780
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7781
|
-
scope: "all",
|
|
7782
|
-
durationMinutes: minutes
|
|
7783
|
-
} });
|
|
7784
|
-
this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
|
|
7785
|
-
}
|
|
7786
|
-
await this.republishGlobalSnooze();
|
|
7787
|
-
}
|
|
7788
|
-
/** Push the global `snooze` select's current state, outside the reconcile cadence. */
|
|
7789
|
-
async republishGlobalSnooze() {
|
|
7790
|
-
try {
|
|
7791
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7792
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7793
|
-
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7794
|
-
} catch (err) {
|
|
7795
|
-
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|
|
7796
|
-
}
|
|
7797
|
-
}
|
|
7798
7557
|
/**
|
|
7799
7558
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
7800
7559
|
*
|
|
@@ -8092,10 +7851,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8092
7851
|
{
|
|
8093
7852
|
label: "Capabilities unclassified",
|
|
8094
7853
|
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
8095
|
-
},
|
|
8096
|
-
{
|
|
8097
|
-
label: "Snooze options",
|
|
8098
|
-
value: SNOOZE_OPTIONS.join(", ")
|
|
8099
7854
|
}
|
|
8100
7855
|
],
|
|
8101
7856
|
note: "CamStack pushes to the CamStack custom component in Home Assistant. Home Assistant never polls the hub for a value; when the heartbeat stops, every entity is marked unavailable."
|
|
@@ -8188,29 +7943,6 @@ function toCameraStatusInput(status) {
|
|
|
8188
7943
|
recording: status.recording === null ? null : { active: status.recording.active }
|
|
8189
7944
|
};
|
|
8190
7945
|
}
|
|
8191
|
-
/**
|
|
8192
|
-
* The ACTIVE window for one scope, from a flat `listSnoozes` answer.
|
|
8193
|
-
*
|
|
8194
|
-
* "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
|
|
8195
|
-
* rows (expiry is a comparison, not a job), so a reader that skipped this
|
|
8196
|
-
* would keep publishing a duration that ended hours ago. When several windows
|
|
8197
|
-
* match, the one expiring LATEST wins: that is the duration still in effect.
|
|
8198
|
-
*/
|
|
8199
|
-
function latestSnoozeWindow(snoozes, matches, now) {
|
|
8200
|
-
const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
|
|
8201
|
-
return latest === null ? null : {
|
|
8202
|
-
startedAt: latest.startedAt,
|
|
8203
|
-
expiresAt: latest.expiresAt
|
|
8204
|
-
};
|
|
8205
|
-
}
|
|
8206
|
-
/** The camera's own `scope: 'device'` snoozes (Task B1). */
|
|
8207
|
-
function latestDeviceSnoozeWindow(snoozes, deviceId) {
|
|
8208
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
|
|
8209
|
-
}
|
|
8210
|
-
/** The global `scope: 'all'` snooze (Task D4). */
|
|
8211
|
-
function latestGlobalSnoozeWindow(snoozes) {
|
|
8212
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
|
|
8213
|
-
}
|
|
8214
7946
|
function toCountRecord(raw) {
|
|
8215
7947
|
if (raw === null || typeof raw !== "object") return {};
|
|
8216
7948
|
const out = {};
|