@camstack/addon-provider-homeassistant 1.2.79 → 1.2.81
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/{dist-CXuG5e6V.mjs → dist-BpePMREY.mjs} +6 -1
- package/dist/{dist-BI0EGQ5s.js → dist-HZncHwuU.js} +6 -1
- package/dist/ha-export/ha-export.addon.js +34 -285
- package/dist/ha-export/ha-export.addon.mjs +34 -285
- 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-HZncHwuU.js");
|
|
3
3
|
let node_crypto = require("node:crypto");
|
|
4
4
|
let node_zlib = require("node:zlib");
|
|
5
5
|
//#region src/ha-device-source.ts
|
package/dist/addon.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { $ as 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-BpePMREY.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
|
|
@@ -13401,7 +13401,12 @@ var ConfigEntrySchema = object({
|
|
|
13401
13401
|
value: unknown(),
|
|
13402
13402
|
description: string().optional()
|
|
13403
13403
|
});
|
|
13404
|
-
|
|
13404
|
+
/**
|
|
13405
|
+
* Only `manual` since D356: the operator picks, nothing links itself. The
|
|
13406
|
+
* field survives on the wire because a viewer already OTA'd parses it —
|
|
13407
|
+
* drop it once no shipped client reads `mode`.
|
|
13408
|
+
*/
|
|
13409
|
+
var LinkedDevicesModeSchema = _enum(["manual"]);
|
|
13405
13410
|
/** One resolved linked device — the compact projection consumers need. */
|
|
13406
13411
|
var LinkedDeviceSchema = object({
|
|
13407
13412
|
deviceId: number(),
|
|
@@ -13401,7 +13401,12 @@ var ConfigEntrySchema = object({
|
|
|
13401
13401
|
value: unknown(),
|
|
13402
13402
|
description: string().optional()
|
|
13403
13403
|
});
|
|
13404
|
-
|
|
13404
|
+
/**
|
|
13405
|
+
* Only `manual` since D356: the operator picks, nothing links itself. The
|
|
13406
|
+
* field survives on the wire because a viewer already OTA'd parses it —
|
|
13407
|
+
* drop it once no shipped client reads `mode`.
|
|
13408
|
+
*/
|
|
13409
|
+
var LinkedDevicesModeSchema = _enum(["manual"]);
|
|
13405
13410
|
/** One resolved linked device — the compact projection consumers need. */
|
|
13406
13411
|
var LinkedDeviceSchema = object({
|
|
13407
13412
|
deviceId: number(),
|
|
@@ -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-HZncHwuU.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
|
}
|
|
@@ -4807,14 +4758,19 @@ function projectCapSlice(deviceKey, capName, slice) {
|
|
|
4807
4758
|
* the second in Home Assistant's registry with nothing arriving on it —
|
|
4808
4759
|
* which is precisely the defect the descriptor table replaced.
|
|
4809
4760
|
*/
|
|
4761
|
+
const missingFields = [];
|
|
4810
4762
|
for (const ref of capEntities(capName)) {
|
|
4811
4763
|
if (ref.descriptor.platform === "button") continue;
|
|
4812
|
-
if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field))
|
|
4813
|
-
|
|
4814
|
-
|
|
4815
|
-
|
|
4816
|
-
|
|
4817
|
-
|
|
4764
|
+
if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field)) {
|
|
4765
|
+
if (ref.entity === entity) return {
|
|
4766
|
+
kind: "missing-field",
|
|
4767
|
+
capName,
|
|
4768
|
+
field: ref.descriptor.field,
|
|
4769
|
+
sliceFields: Object.keys(slice)
|
|
4770
|
+
};
|
|
4771
|
+
missingFields.push(ref.descriptor.field);
|
|
4772
|
+
continue;
|
|
4773
|
+
}
|
|
4818
4774
|
const raw = slice[ref.descriptor.field];
|
|
4819
4775
|
if (raw === null || raw === void 0) continue;
|
|
4820
4776
|
const rendered = renderCapValue(ref.descriptor, deriveCapValue(ref.descriptor.derive, raw));
|
|
@@ -4833,7 +4789,12 @@ function projectCapSlice(deviceKey, capName, slice) {
|
|
|
4833
4789
|
kind: "no-entity",
|
|
4834
4790
|
capName
|
|
4835
4791
|
};
|
|
4836
|
-
return {
|
|
4792
|
+
return missingFields.length > 0 ? {
|
|
4793
|
+
kind: "values",
|
|
4794
|
+
entity,
|
|
4795
|
+
values,
|
|
4796
|
+
missingFields
|
|
4797
|
+
} : {
|
|
4837
4798
|
kind: "values",
|
|
4838
4799
|
entity,
|
|
4839
4800
|
values
|
|
@@ -5117,38 +5078,6 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
5117
5078
|
});
|
|
5118
5079
|
return values;
|
|
5119
5080
|
}
|
|
5120
|
-
/** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
|
|
5121
|
-
var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
|
|
5122
|
-
/**
|
|
5123
|
-
* The `snooze` select's STATE. The authority is
|
|
5124
|
-
* `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
|
|
5125
|
-
* not the preset label an operator picked — there is nowhere to store that
|
|
5126
|
-
* label, and there must not be: it is reconstructed from the two timestamps
|
|
5127
|
-
* every time, so it can never drift from the authority.
|
|
5128
|
-
*
|
|
5129
|
-
* No active snooze publishes `Off`, never silence: Home Assistant reads a
|
|
5130
|
-
* missing state update as "the last value still holds", so a select gone
|
|
5131
|
-
* quiet after a snooze expires would keep showing the expired duration
|
|
5132
|
-
* forever.
|
|
5133
|
-
*
|
|
5134
|
-
* A duration the select's closed vocabulary cannot express — a snooze the
|
|
5135
|
-
* viewer created with an arbitrary minute count — publishes NOTHING.
|
|
5136
|
-
* Rounding it to the nearest preset would let Home Assistant re-send a
|
|
5137
|
-
* duration the operator never chose; `unknown` is the honest state.
|
|
5138
|
-
*/
|
|
5139
|
-
function projectSnooze(deviceKey, window) {
|
|
5140
|
-
if (window === null) return [{
|
|
5141
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5142
|
-
value: "Off"
|
|
5143
|
-
}];
|
|
5144
|
-
const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
|
|
5145
|
-
const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
|
|
5146
|
-
if (option === void 0) return [];
|
|
5147
|
-
return [{
|
|
5148
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5149
|
-
value: option
|
|
5150
|
-
}];
|
|
5151
|
-
}
|
|
5152
5081
|
/**
|
|
5153
5082
|
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5154
5083
|
* a camera that is genuinely broken must never paint the same picture in Home
|
|
@@ -5307,12 +5236,6 @@ function ruleTriggerEntity(ruleId, suffix) {
|
|
|
5307
5236
|
}
|
|
5308
5237
|
function notificationCenterSpecs(input) {
|
|
5309
5238
|
return [
|
|
5310
|
-
{
|
|
5311
|
-
entity: "snoozed",
|
|
5312
|
-
platform: "binary_sensor",
|
|
5313
|
-
label: "Notifications snoozed",
|
|
5314
|
-
icon: "mdi:bell-sleep"
|
|
5315
|
-
},
|
|
5316
5239
|
{
|
|
5317
5240
|
entity: "rules_enabled",
|
|
5318
5241
|
platform: "sensor",
|
|
@@ -5322,21 +5245,6 @@ function notificationCenterSpecs(input) {
|
|
|
5322
5245
|
},
|
|
5323
5246
|
(
|
|
5324
5247
|
/**
|
|
5325
|
-
* The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
|
|
5326
|
-
* never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
|
|
5327
|
-
* it through the SAME `projectSnooze` a camera's snooze uses, so the rule
|
|
5328
|
-
* lives in one place — two expressions of it in two files is how
|
|
5329
|
-
* `audio_detected` went mute for a year.
|
|
5330
|
-
*/
|
|
5331
|
-
{
|
|
5332
|
-
entity: "snooze",
|
|
5333
|
-
platform: "select",
|
|
5334
|
-
label: "Snooze all notifications",
|
|
5335
|
-
writable: true,
|
|
5336
|
-
options: SNOOZE_OPTIONS
|
|
5337
|
-
}),
|
|
5338
|
-
(
|
|
5339
|
-
/**
|
|
5340
5248
|
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5341
5249
|
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5342
5250
|
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
@@ -5657,10 +5565,6 @@ function projectSynthetic(input, nowMs) {
|
|
|
5657
5565
|
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5658
5566
|
const online = input.nodes.filter((node) => node.online);
|
|
5659
5567
|
const values = [
|
|
5660
|
-
{
|
|
5661
|
-
topic: stateTopic(nc, "snoozed"),
|
|
5662
|
-
value: String(input.snoozed)
|
|
5663
|
-
},
|
|
5664
5568
|
{
|
|
5665
5569
|
topic: stateTopic(nc, "rules_enabled"),
|
|
5666
5570
|
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
@@ -6210,6 +6114,14 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6210
6114
|
switch (projection.kind) {
|
|
6211
6115
|
case "values":
|
|
6212
6116
|
if (projection.values.length > 0) this.push(deviceId, projection.values);
|
|
6117
|
+
for (const field of projection.missingFields ?? []) this.warnOnce(deviceId, capName, field, {
|
|
6118
|
+
message: "ha-export: a derived extra entity reads a field its slice does not carry",
|
|
6119
|
+
meta: {
|
|
6120
|
+
capName,
|
|
6121
|
+
field,
|
|
6122
|
+
sliceFields: Object.keys(slice)
|
|
6123
|
+
}
|
|
6124
|
+
});
|
|
6213
6125
|
return;
|
|
6214
6126
|
case "no-entity": return;
|
|
6215
6127
|
case "missing-field":
|
|
@@ -6488,16 +6400,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6488
6400
|
});
|
|
6489
6401
|
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6490
6402
|
const bindableCapsByType = /* @__PURE__ */ new Map();
|
|
6491
|
-
/**
|
|
6492
|
-
* `notificationRules.listSnoozes` — ONE read for the whole pass, shared
|
|
6493
|
-
* by every camera's `snooze` select (Task B1) and the synthetic
|
|
6494
|
-
* `snooze` select on *Notification Center* (Task D4): two expressions
|
|
6495
|
-
* of the same read is how `audio_detected` went mute for a year.
|
|
6496
|
-
*/
|
|
6497
|
-
const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
|
|
6498
|
-
this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
|
|
6499
|
-
return [];
|
|
6500
|
-
});
|
|
6501
6403
|
const exported = /* @__PURE__ */ new Map();
|
|
6502
6404
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
6503
6405
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -6590,7 +6492,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6590
6492
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
6591
6493
|
* and not to a membership the operator picks per camera.
|
|
6592
6494
|
*/
|
|
6593
|
-
const synthetic = await this.buildSynthetic(
|
|
6495
|
+
const synthetic = await this.buildSynthetic(byId);
|
|
6594
6496
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
6595
6497
|
exported.set(plan.deviceKey, {
|
|
6596
6498
|
plan,
|
|
@@ -6630,16 +6532,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6630
6532
|
}
|
|
6631
6533
|
});
|
|
6632
6534
|
await this.announce(exported);
|
|
6633
|
-
await this.pushFullState(exported, snapshots
|
|
6634
|
-
if (synthetic !== null)
|
|
6635
|
-
/**
|
|
6636
|
-
* The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
|
|
6637
|
-
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6638
|
-
* pass already made — one expression of the rule, not two.
|
|
6639
|
-
*/
|
|
6640
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6641
|
-
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6642
|
-
}
|
|
6535
|
+
await this.pushFullState(exported, snapshots);
|
|
6536
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6643
6537
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6644
6538
|
reason,
|
|
6645
6539
|
brokers: enabled.length,
|
|
@@ -6673,7 +6567,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6673
6567
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
6674
6568
|
* announced empty on a hub that is still booting.
|
|
6675
6569
|
*/
|
|
6676
|
-
async buildSynthetic(
|
|
6570
|
+
async buildSynthetic(byId) {
|
|
6677
6571
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
6678
6572
|
id: rule.id,
|
|
6679
6573
|
name: rule.name,
|
|
@@ -6682,8 +6576,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6682
6576
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
6683
6577
|
return null;
|
|
6684
6578
|
});
|
|
6685
|
-
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6686
|
-
const anySnoozed = snoozes.length > 0;
|
|
6687
6579
|
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
6688
6580
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
6689
6581
|
return null;
|
|
@@ -6707,7 +6599,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6707
6599
|
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
6708
6600
|
return {
|
|
6709
6601
|
rules: effectiveRules,
|
|
6710
|
-
snoozed: anySnoozed,
|
|
6711
6602
|
lastTrigger,
|
|
6712
6603
|
lastTriggerByRule,
|
|
6713
6604
|
nodes,
|
|
@@ -6889,9 +6780,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6889
6780
|
}
|
|
6890
6781
|
}
|
|
6891
6782
|
}
|
|
6892
|
-
async pushFullState(exported, snapshots
|
|
6783
|
+
async pushFullState(exported, snapshots) {
|
|
6893
6784
|
for (const entry of exported.values()) try {
|
|
6894
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]
|
|
6785
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6895
6786
|
this.push(entry.deviceId, values);
|
|
6896
6787
|
} catch (err) {
|
|
6897
6788
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -6902,7 +6793,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6902
6793
|
for (const link of this.links.values()) await link.client.flush();
|
|
6903
6794
|
}
|
|
6904
6795
|
/** Everything readable without waiting for an event. */
|
|
6905
|
-
async currentStateFor(entry, snapshotRaw
|
|
6796
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
6906
6797
|
const key = entry.plan.deviceKey;
|
|
6907
6798
|
const values = [];
|
|
6908
6799
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -6982,16 +6873,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6982
6873
|
*/
|
|
6983
6874
|
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
6984
6875
|
if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
|
|
6985
|
-
/**
|
|
6986
|
-
* `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
|
|
6987
|
-
* never stored: the select's state is the SAME authority the write path
|
|
6988
|
-
* (`applySnooze`) already uses.
|
|
6989
|
-
*/
|
|
6990
|
-
const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
|
|
6991
|
-
if (snoozeValues.length === 0) {
|
|
6992
|
-
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 } });
|
|
6993
|
-
}
|
|
6994
|
-
values.push(...snoozeValues);
|
|
6995
6876
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
6996
6877
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
6997
6878
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -7483,38 +7364,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7483
7364
|
* disagree with the admin UI is worse than no knob (D62).
|
|
7484
7365
|
*/
|
|
7485
7366
|
if (isSyntheticDeviceKey(parsed.deviceKey)) {
|
|
7486
|
-
/**
|
|
7487
|
-
* The global snooze select (Task D4) — the OTHER control this branch
|
|
7488
|
-
* routes, alongside the per-rule switches below. Same rule as the
|
|
7489
|
-
* camera's own snooze (`applySnooze`): the export writes
|
|
7490
|
-
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7491
|
-
* store of its own.
|
|
7492
|
-
*/
|
|
7493
|
-
if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7494
|
-
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7495
|
-
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7496
|
-
topic,
|
|
7497
|
-
value
|
|
7498
|
-
} });
|
|
7499
|
-
reply.status(422);
|
|
7500
|
-
reply.send({ error: "unroutable command" });
|
|
7501
|
-
return;
|
|
7502
|
-
}
|
|
7503
|
-
const minutes = SNOOZE_MINUTES[value] ?? null;
|
|
7504
|
-
try {
|
|
7505
|
-
await this.applyGlobalSnooze(minutes);
|
|
7506
|
-
reply.status(200);
|
|
7507
|
-
reply.send({});
|
|
7508
|
-
} catch (err) {
|
|
7509
|
-
this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
|
|
7510
|
-
minutes,
|
|
7511
|
-
error: errMsg(err)
|
|
7512
|
-
} });
|
|
7513
|
-
reply.status(422);
|
|
7514
|
-
reply.send({ error: "command not applied" });
|
|
7515
|
-
}
|
|
7516
|
-
return;
|
|
7517
|
-
}
|
|
7518
7367
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
7519
7368
|
if (ruleId === null) {
|
|
7520
7369
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -7684,7 +7533,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7684
7533
|
await device.ptz.goToPreset({ presetId: preset.id });
|
|
7685
7534
|
return true;
|
|
7686
7535
|
}
|
|
7687
|
-
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
7688
7536
|
}
|
|
7689
7537
|
} catch (err) {
|
|
7690
7538
|
/**
|
|
@@ -7710,78 +7558,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7710
7558
|
});
|
|
7711
7559
|
return false;
|
|
7712
7560
|
}
|
|
7713
|
-
async applySnooze(deviceId, minutes) {
|
|
7714
|
-
if (minutes === null) {
|
|
7715
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7716
|
-
const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
|
|
7717
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7718
|
-
this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
|
|
7719
|
-
tags: { deviceId },
|
|
7720
|
-
meta: { cancelled: mine.length }
|
|
7721
|
-
});
|
|
7722
|
-
} else {
|
|
7723
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7724
|
-
scope: "device",
|
|
7725
|
-
deviceId,
|
|
7726
|
-
durationMinutes: minutes
|
|
7727
|
-
} });
|
|
7728
|
-
this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
|
|
7729
|
-
tags: { deviceId },
|
|
7730
|
-
meta: { minutes }
|
|
7731
|
-
});
|
|
7732
|
-
}
|
|
7733
|
-
/**
|
|
7734
|
-
* Republish right away — the select is otherwise stale for up to
|
|
7735
|
-
* `reconcileIntervalSec` after a write Home Assistant itself made
|
|
7736
|
-
* (Task B1 passo 4).
|
|
7737
|
-
*/
|
|
7738
|
-
await this.republishSnooze(deviceId);
|
|
7739
|
-
return true;
|
|
7740
|
-
}
|
|
7741
|
-
/** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
|
|
7742
|
-
async republishSnooze(deviceId) {
|
|
7743
|
-
const key = this.keyByDeviceId.get(deviceId);
|
|
7744
|
-
if (key === void 0) return;
|
|
7745
|
-
try {
|
|
7746
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7747
|
-
this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
|
|
7748
|
-
} catch (err) {
|
|
7749
|
-
this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
|
|
7750
|
-
tags: { deviceId },
|
|
7751
|
-
meta: { error: errMsg(err) }
|
|
7752
|
-
});
|
|
7753
|
-
}
|
|
7754
|
-
}
|
|
7755
|
-
/**
|
|
7756
|
-
* The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
|
|
7757
|
-
* the same authority `applySnooze` already writes for a camera. `Off`
|
|
7758
|
-
* cancels every global snooze, exactly as a camera's `Off` cancels its own.
|
|
7759
|
-
*/
|
|
7760
|
-
async applyGlobalSnooze(minutes) {
|
|
7761
|
-
if (minutes === null) {
|
|
7762
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7763
|
-
const mine = snoozes.filter((snooze) => snooze.scope === "all");
|
|
7764
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7765
|
-
this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
|
|
7766
|
-
} else {
|
|
7767
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7768
|
-
scope: "all",
|
|
7769
|
-
durationMinutes: minutes
|
|
7770
|
-
} });
|
|
7771
|
-
this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
|
|
7772
|
-
}
|
|
7773
|
-
await this.republishGlobalSnooze();
|
|
7774
|
-
}
|
|
7775
|
-
/** Push the global `snooze` select's current state, outside the reconcile cadence. */
|
|
7776
|
-
async republishGlobalSnooze() {
|
|
7777
|
-
try {
|
|
7778
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7779
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7780
|
-
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7781
|
-
} catch (err) {
|
|
7782
|
-
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|
|
7783
|
-
}
|
|
7784
|
-
}
|
|
7785
7561
|
/**
|
|
7786
7562
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
7787
7563
|
*
|
|
@@ -8079,10 +7855,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8079
7855
|
{
|
|
8080
7856
|
label: "Capabilities unclassified",
|
|
8081
7857
|
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
8082
|
-
},
|
|
8083
|
-
{
|
|
8084
|
-
label: "Snooze options",
|
|
8085
|
-
value: SNOOZE_OPTIONS.join(", ")
|
|
8086
7858
|
}
|
|
8087
7859
|
],
|
|
8088
7860
|
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."
|
|
@@ -8175,29 +7947,6 @@ function toCameraStatusInput(status) {
|
|
|
8175
7947
|
recording: status.recording === null ? null : { active: status.recording.active }
|
|
8176
7948
|
};
|
|
8177
7949
|
}
|
|
8178
|
-
/**
|
|
8179
|
-
* The ACTIVE window for one scope, from a flat `listSnoozes` answer.
|
|
8180
|
-
*
|
|
8181
|
-
* "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
|
|
8182
|
-
* rows (expiry is a comparison, not a job), so a reader that skipped this
|
|
8183
|
-
* would keep publishing a duration that ended hours ago. When several windows
|
|
8184
|
-
* match, the one expiring LATEST wins: that is the duration still in effect.
|
|
8185
|
-
*/
|
|
8186
|
-
function latestSnoozeWindow(snoozes, matches, now) {
|
|
8187
|
-
const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
|
|
8188
|
-
return latest === null ? null : {
|
|
8189
|
-
startedAt: latest.startedAt,
|
|
8190
|
-
expiresAt: latest.expiresAt
|
|
8191
|
-
};
|
|
8192
|
-
}
|
|
8193
|
-
/** The camera's own `scope: 'device'` snoozes (Task B1). */
|
|
8194
|
-
function latestDeviceSnoozeWindow(snoozes, deviceId) {
|
|
8195
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
|
|
8196
|
-
}
|
|
8197
|
-
/** The global `scope: 'all'` snooze (Task D4). */
|
|
8198
|
-
function latestGlobalSnoozeWindow(snoozes) {
|
|
8199
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
|
|
8200
|
-
}
|
|
8201
7950
|
function toCountRecord(raw) {
|
|
8202
7951
|
if (raw === null || typeof raw !== "object") return {};
|
|
8203
7952
|
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-BpePMREY.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
|
}
|
|
@@ -4802,14 +4753,19 @@ function projectCapSlice(deviceKey, capName, slice) {
|
|
|
4802
4753
|
* the second in Home Assistant's registry with nothing arriving on it —
|
|
4803
4754
|
* which is precisely the defect the descriptor table replaced.
|
|
4804
4755
|
*/
|
|
4756
|
+
const missingFields = [];
|
|
4805
4757
|
for (const ref of capEntities(capName)) {
|
|
4806
4758
|
if (ref.descriptor.platform === "button") continue;
|
|
4807
|
-
if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field))
|
|
4808
|
-
|
|
4809
|
-
|
|
4810
|
-
|
|
4811
|
-
|
|
4812
|
-
|
|
4759
|
+
if (!Object.prototype.hasOwnProperty.call(slice, ref.descriptor.field)) {
|
|
4760
|
+
if (ref.entity === entity) return {
|
|
4761
|
+
kind: "missing-field",
|
|
4762
|
+
capName,
|
|
4763
|
+
field: ref.descriptor.field,
|
|
4764
|
+
sliceFields: Object.keys(slice)
|
|
4765
|
+
};
|
|
4766
|
+
missingFields.push(ref.descriptor.field);
|
|
4767
|
+
continue;
|
|
4768
|
+
}
|
|
4813
4769
|
const raw = slice[ref.descriptor.field];
|
|
4814
4770
|
if (raw === null || raw === void 0) continue;
|
|
4815
4771
|
const rendered = renderCapValue(ref.descriptor, deriveCapValue(ref.descriptor.derive, raw));
|
|
@@ -4828,7 +4784,12 @@ function projectCapSlice(deviceKey, capName, slice) {
|
|
|
4828
4784
|
kind: "no-entity",
|
|
4829
4785
|
capName
|
|
4830
4786
|
};
|
|
4831
|
-
return {
|
|
4787
|
+
return missingFields.length > 0 ? {
|
|
4788
|
+
kind: "values",
|
|
4789
|
+
entity,
|
|
4790
|
+
values,
|
|
4791
|
+
missingFields
|
|
4792
|
+
} : {
|
|
4832
4793
|
kind: "values",
|
|
4833
4794
|
entity,
|
|
4834
4795
|
values
|
|
@@ -5112,38 +5073,6 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
5112
5073
|
});
|
|
5113
5074
|
return values;
|
|
5114
5075
|
}
|
|
5115
|
-
/** Inverse of `SNOOZE_MINUTES` — a minute count back to the option label. */
|
|
5116
|
-
var SNOOZE_OPTION_BY_MINUTES = new Map(Object.entries(SNOOZE_MINUTES).map(([option, minutes]) => [minutes, option]));
|
|
5117
|
-
/**
|
|
5118
|
-
* The `snooze` select's STATE. The authority is
|
|
5119
|
-
* `notificationRules.listSnoozes`, which carries `startedAt`/`expiresAt` but
|
|
5120
|
-
* not the preset label an operator picked — there is nowhere to store that
|
|
5121
|
-
* label, and there must not be: it is reconstructed from the two timestamps
|
|
5122
|
-
* every time, so it can never drift from the authority.
|
|
5123
|
-
*
|
|
5124
|
-
* No active snooze publishes `Off`, never silence: Home Assistant reads a
|
|
5125
|
-
* missing state update as "the last value still holds", so a select gone
|
|
5126
|
-
* quiet after a snooze expires would keep showing the expired duration
|
|
5127
|
-
* forever.
|
|
5128
|
-
*
|
|
5129
|
-
* A duration the select's closed vocabulary cannot express — a snooze the
|
|
5130
|
-
* viewer created with an arbitrary minute count — publishes NOTHING.
|
|
5131
|
-
* Rounding it to the nearest preset would let Home Assistant re-send a
|
|
5132
|
-
* duration the operator never chose; `unknown` is the honest state.
|
|
5133
|
-
*/
|
|
5134
|
-
function projectSnooze(deviceKey, window) {
|
|
5135
|
-
if (window === null) return [{
|
|
5136
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5137
|
-
value: "Off"
|
|
5138
|
-
}];
|
|
5139
|
-
const minutes = Math.round((window.expiresAt - window.startedAt) / 6e4);
|
|
5140
|
-
const option = SNOOZE_OPTION_BY_MINUTES.get(minutes);
|
|
5141
|
-
if (option === void 0) return [];
|
|
5142
|
-
return [{
|
|
5143
|
-
topic: stateTopic(deviceKey, "snooze"),
|
|
5144
|
-
value: option
|
|
5145
|
-
}];
|
|
5146
|
-
}
|
|
5147
5076
|
/**
|
|
5148
5077
|
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5149
5078
|
* a camera that is genuinely broken must never paint the same picture in Home
|
|
@@ -5302,12 +5231,6 @@ function ruleTriggerEntity(ruleId, suffix) {
|
|
|
5302
5231
|
}
|
|
5303
5232
|
function notificationCenterSpecs(input) {
|
|
5304
5233
|
return [
|
|
5305
|
-
{
|
|
5306
|
-
entity: "snoozed",
|
|
5307
|
-
platform: "binary_sensor",
|
|
5308
|
-
label: "Notifications snoozed",
|
|
5309
|
-
icon: "mdi:bell-sleep"
|
|
5310
|
-
},
|
|
5311
5234
|
{
|
|
5312
5235
|
entity: "rules_enabled",
|
|
5313
5236
|
platform: "sensor",
|
|
@@ -5317,21 +5240,6 @@ function notificationCenterSpecs(input) {
|
|
|
5317
5240
|
},
|
|
5318
5241
|
(
|
|
5319
5242
|
/**
|
|
5320
|
-
* The GLOBAL snooze, as a control (Task D4) — the half `snoozed` (above)
|
|
5321
|
-
* never had. Its state is NOT projected here: `ha-export.addon.ts` pushes
|
|
5322
|
-
* it through the SAME `projectSnooze` a camera's snooze uses, so the rule
|
|
5323
|
-
* lives in one place — two expressions of it in two files is how
|
|
5324
|
-
* `audio_detected` went mute for a year.
|
|
5325
|
-
*/
|
|
5326
|
-
{
|
|
5327
|
-
entity: "snooze",
|
|
5328
|
-
platform: "select",
|
|
5329
|
-
label: "Snooze all notifications",
|
|
5330
|
-
writable: true,
|
|
5331
|
-
options: SNOOZE_OPTIONS
|
|
5332
|
-
}),
|
|
5333
|
-
(
|
|
5334
|
-
/**
|
|
5335
5243
|
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5336
5244
|
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5337
5245
|
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
@@ -5652,10 +5560,6 @@ function projectSynthetic(input, nowMs) {
|
|
|
5652
5560
|
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5653
5561
|
const online = input.nodes.filter((node) => node.online);
|
|
5654
5562
|
const values = [
|
|
5655
|
-
{
|
|
5656
|
-
topic: stateTopic(nc, "snoozed"),
|
|
5657
|
-
value: String(input.snoozed)
|
|
5658
|
-
},
|
|
5659
5563
|
{
|
|
5660
5564
|
topic: stateTopic(nc, "rules_enabled"),
|
|
5661
5565
|
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
@@ -6205,6 +6109,14 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6205
6109
|
switch (projection.kind) {
|
|
6206
6110
|
case "values":
|
|
6207
6111
|
if (projection.values.length > 0) this.push(deviceId, projection.values);
|
|
6112
|
+
for (const field of projection.missingFields ?? []) this.warnOnce(deviceId, capName, field, {
|
|
6113
|
+
message: "ha-export: a derived extra entity reads a field its slice does not carry",
|
|
6114
|
+
meta: {
|
|
6115
|
+
capName,
|
|
6116
|
+
field,
|
|
6117
|
+
sliceFields: Object.keys(slice)
|
|
6118
|
+
}
|
|
6119
|
+
});
|
|
6208
6120
|
return;
|
|
6209
6121
|
case "no-entity": return;
|
|
6210
6122
|
case "missing-field":
|
|
@@ -6483,16 +6395,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6483
6395
|
});
|
|
6484
6396
|
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6485
6397
|
const bindableCapsByType = /* @__PURE__ */ new Map();
|
|
6486
|
-
/**
|
|
6487
|
-
* `notificationRules.listSnoozes` — ONE read for the whole pass, shared
|
|
6488
|
-
* by every camera's `snooze` select (Task B1) and the synthetic
|
|
6489
|
-
* `snooze` select on *Notification Center* (Task D4): two expressions
|
|
6490
|
-
* of the same read is how `audio_detected` went mute for a year.
|
|
6491
|
-
*/
|
|
6492
|
-
const snoozes = await this.ctx.api.notificationRules.listSnoozes.query({}).then((r) => r.snoozes).catch((err) => {
|
|
6493
|
-
this.ctx.logger.warn("ha-export: could not read snoozes, snooze selects go unknown", { meta: { error: errMsg(err) } });
|
|
6494
|
-
return [];
|
|
6495
|
-
});
|
|
6496
6398
|
const exported = /* @__PURE__ */ new Map();
|
|
6497
6399
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
6498
6400
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -6585,7 +6487,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6585
6487
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
6586
6488
|
* and not to a membership the operator picks per camera.
|
|
6587
6489
|
*/
|
|
6588
|
-
const synthetic = await this.buildSynthetic(
|
|
6490
|
+
const synthetic = await this.buildSynthetic(byId);
|
|
6589
6491
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
6590
6492
|
exported.set(plan.deviceKey, {
|
|
6591
6493
|
plan,
|
|
@@ -6625,16 +6527,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6625
6527
|
}
|
|
6626
6528
|
});
|
|
6627
6529
|
await this.announce(exported);
|
|
6628
|
-
await this.pushFullState(exported, snapshots
|
|
6629
|
-
if (synthetic !== null)
|
|
6630
|
-
/**
|
|
6631
|
-
* The global snooze's STATE (Task D4) rides the SAME `projectSnooze`
|
|
6632
|
-
* a camera's `snooze` select uses, over the SAME `snoozes` read this
|
|
6633
|
-
* pass already made — one expression of the rule, not two.
|
|
6634
|
-
*/
|
|
6635
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
6636
|
-
await this.pushSynthetic([...projectSynthetic(synthetic, Date.now()), ...projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes))], [...enabled]);
|
|
6637
|
-
}
|
|
6530
|
+
await this.pushFullState(exported, snapshots);
|
|
6531
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6638
6532
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6639
6533
|
reason,
|
|
6640
6534
|
brokers: enabled.length,
|
|
@@ -6668,7 +6562,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6668
6562
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
6669
6563
|
* announced empty on a hub that is still booting.
|
|
6670
6564
|
*/
|
|
6671
|
-
async buildSynthetic(
|
|
6565
|
+
async buildSynthetic(byId) {
|
|
6672
6566
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
6673
6567
|
id: rule.id,
|
|
6674
6568
|
name: rule.name,
|
|
@@ -6677,8 +6571,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6677
6571
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
6678
6572
|
return null;
|
|
6679
6573
|
});
|
|
6680
|
-
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6681
|
-
const anySnoozed = snoozes.length > 0;
|
|
6682
6574
|
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
6683
6575
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
6684
6576
|
return null;
|
|
@@ -6702,7 +6594,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6702
6594
|
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
6703
6595
|
return {
|
|
6704
6596
|
rules: effectiveRules,
|
|
6705
|
-
snoozed: anySnoozed,
|
|
6706
6597
|
lastTrigger,
|
|
6707
6598
|
lastTriggerByRule,
|
|
6708
6599
|
nodes,
|
|
@@ -6884,9 +6775,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6884
6775
|
}
|
|
6885
6776
|
}
|
|
6886
6777
|
}
|
|
6887
|
-
async pushFullState(exported, snapshots
|
|
6778
|
+
async pushFullState(exported, snapshots) {
|
|
6888
6779
|
for (const entry of exported.values()) try {
|
|
6889
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]
|
|
6780
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6890
6781
|
this.push(entry.deviceId, values);
|
|
6891
6782
|
} catch (err) {
|
|
6892
6783
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -6897,7 +6788,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6897
6788
|
for (const link of this.links.values()) await link.client.flush();
|
|
6898
6789
|
}
|
|
6899
6790
|
/** Everything readable without waiting for an event. */
|
|
6900
|
-
async currentStateFor(entry, snapshotRaw
|
|
6791
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
6901
6792
|
const key = entry.plan.deviceKey;
|
|
6902
6793
|
const values = [];
|
|
6903
6794
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -6977,16 +6868,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6977
6868
|
*/
|
|
6978
6869
|
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
6979
6870
|
if (cameraStatus !== void 0) values.push(...projectCameraStatus(key, toCameraStatusInput(cameraStatus)));
|
|
6980
|
-
/**
|
|
6981
|
-
* `snooze` (Task B1) — reconstructed from `notificationRules.listSnoozes`,
|
|
6982
|
-
* never stored: the select's state is the SAME authority the write path
|
|
6983
|
-
* (`applySnooze`) already uses.
|
|
6984
|
-
*/
|
|
6985
|
-
const snoozeValues = projectSnooze(key, latestDeviceSnoozeWindow(snoozes, entry.deviceId));
|
|
6986
|
-
if (snoozeValues.length === 0) {
|
|
6987
|
-
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 } });
|
|
6988
|
-
}
|
|
6989
|
-
values.push(...snoozeValues);
|
|
6990
6871
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
6991
6872
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
6992
6873
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -7478,38 +7359,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7478
7359
|
* disagree with the admin UI is worse than no knob (D62).
|
|
7479
7360
|
*/
|
|
7480
7361
|
if (isSyntheticDeviceKey(parsed.deviceKey)) {
|
|
7481
|
-
/**
|
|
7482
|
-
* The global snooze select (Task D4) — the OTHER control this branch
|
|
7483
|
-
* routes, alongside the per-rule switches below. Same rule as the
|
|
7484
|
-
* camera's own snooze (`applySnooze`): the export writes
|
|
7485
|
-
* `createSnooze`/`cancelSnooze` directly, `scope: 'all'`, never a
|
|
7486
|
-
* store of its own.
|
|
7487
|
-
*/
|
|
7488
|
-
if (parsed.deviceKey === syntheticDeviceKeyFor("synthetic-notification-center") && parsed.entity === "snooze") {
|
|
7489
|
-
if (!SNOOZE_OPTIONS.includes(value)) {
|
|
7490
|
-
this.ctx.logger.warn("ha-export: dropping a global snooze command, unknown option", { meta: {
|
|
7491
|
-
topic,
|
|
7492
|
-
value
|
|
7493
|
-
} });
|
|
7494
|
-
reply.status(422);
|
|
7495
|
-
reply.send({ error: "unroutable command" });
|
|
7496
|
-
return;
|
|
7497
|
-
}
|
|
7498
|
-
const minutes = SNOOZE_MINUTES[value] ?? null;
|
|
7499
|
-
try {
|
|
7500
|
-
await this.applyGlobalSnooze(minutes);
|
|
7501
|
-
reply.status(200);
|
|
7502
|
-
reply.send({});
|
|
7503
|
-
} catch (err) {
|
|
7504
|
-
this.ctx.logger.warn("ha-export: could not apply the global snooze from Home Assistant", { meta: {
|
|
7505
|
-
minutes,
|
|
7506
|
-
error: errMsg(err)
|
|
7507
|
-
} });
|
|
7508
|
-
reply.status(422);
|
|
7509
|
-
reply.send({ error: "command not applied" });
|
|
7510
|
-
}
|
|
7511
|
-
return;
|
|
7512
|
-
}
|
|
7513
7362
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
7514
7363
|
if (ruleId === null) {
|
|
7515
7364
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -7679,7 +7528,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7679
7528
|
await device.ptz.goToPreset({ presetId: preset.id });
|
|
7680
7529
|
return true;
|
|
7681
7530
|
}
|
|
7682
|
-
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
7683
7531
|
}
|
|
7684
7532
|
} catch (err) {
|
|
7685
7533
|
/**
|
|
@@ -7705,78 +7553,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7705
7553
|
});
|
|
7706
7554
|
return false;
|
|
7707
7555
|
}
|
|
7708
|
-
async applySnooze(deviceId, minutes) {
|
|
7709
|
-
if (minutes === null) {
|
|
7710
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7711
|
-
const mine = snoozes.filter((snooze) => snooze.deviceId === deviceId);
|
|
7712
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7713
|
-
this.ctx.logger.info("ha-export: cancelled snoozes from Home Assistant", {
|
|
7714
|
-
tags: { deviceId },
|
|
7715
|
-
meta: { cancelled: mine.length }
|
|
7716
|
-
});
|
|
7717
|
-
} else {
|
|
7718
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7719
|
-
scope: "device",
|
|
7720
|
-
deviceId,
|
|
7721
|
-
durationMinutes: minutes
|
|
7722
|
-
} });
|
|
7723
|
-
this.ctx.logger.info("ha-export: snoozed from Home Assistant", {
|
|
7724
|
-
tags: { deviceId },
|
|
7725
|
-
meta: { minutes }
|
|
7726
|
-
});
|
|
7727
|
-
}
|
|
7728
|
-
/**
|
|
7729
|
-
* Republish right away — the select is otherwise stale for up to
|
|
7730
|
-
* `reconcileIntervalSec` after a write Home Assistant itself made
|
|
7731
|
-
* (Task B1 passo 4).
|
|
7732
|
-
*/
|
|
7733
|
-
await this.republishSnooze(deviceId);
|
|
7734
|
-
return true;
|
|
7735
|
-
}
|
|
7736
|
-
/** Push `snooze`'s current state for one camera, outside the reconcile cadence. */
|
|
7737
|
-
async republishSnooze(deviceId) {
|
|
7738
|
-
const key = this.keyByDeviceId.get(deviceId);
|
|
7739
|
-
if (key === void 0) return;
|
|
7740
|
-
try {
|
|
7741
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7742
|
-
this.push(deviceId, projectSnooze(key, latestDeviceSnoozeWindow(snoozes, deviceId)));
|
|
7743
|
-
} catch (err) {
|
|
7744
|
-
this.ctx.logger.warn("ha-export: could not republish snooze after a write from Home Assistant", {
|
|
7745
|
-
tags: { deviceId },
|
|
7746
|
-
meta: { error: errMsg(err) }
|
|
7747
|
-
});
|
|
7748
|
-
}
|
|
7749
|
-
}
|
|
7750
|
-
/**
|
|
7751
|
-
* The global `select` on *Notification Center* (Task D4) — `scope: 'all'`,
|
|
7752
|
-
* the same authority `applySnooze` already writes for a camera. `Off`
|
|
7753
|
-
* cancels every global snooze, exactly as a camera's `Off` cancels its own.
|
|
7754
|
-
*/
|
|
7755
|
-
async applyGlobalSnooze(minutes) {
|
|
7756
|
-
if (minutes === null) {
|
|
7757
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7758
|
-
const mine = snoozes.filter((snooze) => snooze.scope === "all");
|
|
7759
|
-
for (const snooze of mine) await this.ctx.api.notificationRules.cancelSnooze.mutate({ snoozeId: snooze.id });
|
|
7760
|
-
this.ctx.logger.info("ha-export: cancelled the global snooze from Home Assistant", { meta: { cancelled: mine.length } });
|
|
7761
|
-
} else {
|
|
7762
|
-
await this.ctx.api.notificationRules.createSnooze.mutate({ snooze: {
|
|
7763
|
-
scope: "all",
|
|
7764
|
-
durationMinutes: minutes
|
|
7765
|
-
} });
|
|
7766
|
-
this.ctx.logger.info("ha-export: snoozed globally from Home Assistant", { meta: { minutes } });
|
|
7767
|
-
}
|
|
7768
|
-
await this.republishGlobalSnooze();
|
|
7769
|
-
}
|
|
7770
|
-
/** Push the global `snooze` select's current state, outside the reconcile cadence. */
|
|
7771
|
-
async republishGlobalSnooze() {
|
|
7772
|
-
try {
|
|
7773
|
-
const { snoozes } = await this.ctx.api.notificationRules.listSnoozes.query({});
|
|
7774
|
-
const ncKey = syntheticDeviceKeyFor(NOTIFICATION_CENTER_STABLE_ID);
|
|
7775
|
-
await this.pushSynthetic(projectSnooze(ncKey, latestGlobalSnoozeWindow(snoozes)), this.enabledBrokerIds());
|
|
7776
|
-
} catch (err) {
|
|
7777
|
-
this.ctx.logger.warn("ha-export: could not republish the global snooze after a write from Home Assistant", { meta: { error: errMsg(err) } });
|
|
7778
|
-
}
|
|
7779
|
-
}
|
|
7780
7556
|
/**
|
|
7781
7557
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
7782
7558
|
*
|
|
@@ -8074,10 +7850,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8074
7850
|
{
|
|
8075
7851
|
label: "Capabilities unclassified",
|
|
8076
7852
|
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
8077
|
-
},
|
|
8078
|
-
{
|
|
8079
|
-
label: "Snooze options",
|
|
8080
|
-
value: SNOOZE_OPTIONS.join(", ")
|
|
8081
7853
|
}
|
|
8082
7854
|
],
|
|
8083
7855
|
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."
|
|
@@ -8170,29 +7942,6 @@ function toCameraStatusInput(status) {
|
|
|
8170
7942
|
recording: status.recording === null ? null : { active: status.recording.active }
|
|
8171
7943
|
};
|
|
8172
7944
|
}
|
|
8173
|
-
/**
|
|
8174
|
-
* The ACTIVE window for one scope, from a flat `listSnoozes` answer.
|
|
8175
|
-
*
|
|
8176
|
-
* "Active" is `expiresAt` in the future — `listSnoozes` never sweeps expired
|
|
8177
|
-
* rows (expiry is a comparison, not a job), so a reader that skipped this
|
|
8178
|
-
* would keep publishing a duration that ended hours ago. When several windows
|
|
8179
|
-
* match, the one expiring LATEST wins: that is the duration still in effect.
|
|
8180
|
-
*/
|
|
8181
|
-
function latestSnoozeWindow(snoozes, matches, now) {
|
|
8182
|
-
const latest = snoozes.filter((s) => matches(s) && s.expiresAt > now).reduce((top, s) => top === null || s.expiresAt > top.expiresAt ? s : top, null);
|
|
8183
|
-
return latest === null ? null : {
|
|
8184
|
-
startedAt: latest.startedAt,
|
|
8185
|
-
expiresAt: latest.expiresAt
|
|
8186
|
-
};
|
|
8187
|
-
}
|
|
8188
|
-
/** The camera's own `scope: 'device'` snoozes (Task B1). */
|
|
8189
|
-
function latestDeviceSnoozeWindow(snoozes, deviceId) {
|
|
8190
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "device" && s.deviceId === deviceId, Date.now());
|
|
8191
|
-
}
|
|
8192
|
-
/** The global `scope: 'all'` snooze (Task D4). */
|
|
8193
|
-
function latestGlobalSnoozeWindow(snoozes) {
|
|
8194
|
-
return latestSnoozeWindow(snoozes, (s) => s.scope === "all", Date.now());
|
|
8195
|
-
}
|
|
8196
7945
|
function toCountRecord(raw) {
|
|
8197
7946
|
if (raw === null || typeof raw !== "object") return {};
|
|
8198
7947
|
const out = {};
|