@camstack/addon-provider-homeassistant 1.2.80 → 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.
|
@@ -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
|
}
|
|
@@ -5127,38 +5078,6 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
5127
5078
|
});
|
|
5128
5079
|
return values;
|
|
5129
5080
|
}
|
|
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
5081
|
/**
|
|
5163
5082
|
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5164
5083
|
* a camera that is genuinely broken must never paint the same picture in Home
|
|
@@ -5317,12 +5236,6 @@ function ruleTriggerEntity(ruleId, suffix) {
|
|
|
5317
5236
|
}
|
|
5318
5237
|
function notificationCenterSpecs(input) {
|
|
5319
5238
|
return [
|
|
5320
|
-
{
|
|
5321
|
-
entity: "snoozed",
|
|
5322
|
-
platform: "binary_sensor",
|
|
5323
|
-
label: "Notifications snoozed",
|
|
5324
|
-
icon: "mdi:bell-sleep"
|
|
5325
|
-
},
|
|
5326
5239
|
{
|
|
5327
5240
|
entity: "rules_enabled",
|
|
5328
5241
|
platform: "sensor",
|
|
@@ -5332,21 +5245,6 @@ function notificationCenterSpecs(input) {
|
|
|
5332
5245
|
},
|
|
5333
5246
|
(
|
|
5334
5247
|
/**
|
|
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
5248
|
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5351
5249
|
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5352
5250
|
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
@@ -5667,10 +5565,6 @@ function projectSynthetic(input, nowMs) {
|
|
|
5667
5565
|
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5668
5566
|
const online = input.nodes.filter((node) => node.online);
|
|
5669
5567
|
const values = [
|
|
5670
|
-
{
|
|
5671
|
-
topic: stateTopic(nc, "snoozed"),
|
|
5672
|
-
value: String(input.snoozed)
|
|
5673
|
-
},
|
|
5674
5568
|
{
|
|
5675
5569
|
topic: stateTopic(nc, "rules_enabled"),
|
|
5676
5570
|
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
@@ -6506,16 +6400,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6506
6400
|
});
|
|
6507
6401
|
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6508
6402
|
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
6403
|
const exported = /* @__PURE__ */ new Map();
|
|
6520
6404
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
6521
6405
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -6608,7 +6492,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6608
6492
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
6609
6493
|
* and not to a membership the operator picks per camera.
|
|
6610
6494
|
*/
|
|
6611
|
-
const synthetic = await this.buildSynthetic(
|
|
6495
|
+
const synthetic = await this.buildSynthetic(byId);
|
|
6612
6496
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
6613
6497
|
exported.set(plan.deviceKey, {
|
|
6614
6498
|
plan,
|
|
@@ -6648,16 +6532,8 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6648
6532
|
}
|
|
6649
6533
|
});
|
|
6650
6534
|
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
|
-
}
|
|
6535
|
+
await this.pushFullState(exported, snapshots);
|
|
6536
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6661
6537
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6662
6538
|
reason,
|
|
6663
6539
|
brokers: enabled.length,
|
|
@@ -6691,7 +6567,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6691
6567
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
6692
6568
|
* announced empty on a hub that is still booting.
|
|
6693
6569
|
*/
|
|
6694
|
-
async buildSynthetic(
|
|
6570
|
+
async buildSynthetic(byId) {
|
|
6695
6571
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
6696
6572
|
id: rule.id,
|
|
6697
6573
|
name: rule.name,
|
|
@@ -6700,8 +6576,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6700
6576
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
6701
6577
|
return null;
|
|
6702
6578
|
});
|
|
6703
|
-
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6704
|
-
const anySnoozed = snoozes.length > 0;
|
|
6705
6579
|
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
6706
6580
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
6707
6581
|
return null;
|
|
@@ -6725,7 +6599,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6725
6599
|
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
6726
6600
|
return {
|
|
6727
6601
|
rules: effectiveRules,
|
|
6728
|
-
snoozed: anySnoozed,
|
|
6729
6602
|
lastTrigger,
|
|
6730
6603
|
lastTriggerByRule,
|
|
6731
6604
|
nodes,
|
|
@@ -6907,9 +6780,9 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6907
6780
|
}
|
|
6908
6781
|
}
|
|
6909
6782
|
}
|
|
6910
|
-
async pushFullState(exported, snapshots
|
|
6783
|
+
async pushFullState(exported, snapshots) {
|
|
6911
6784
|
for (const entry of exported.values()) try {
|
|
6912
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]
|
|
6785
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6913
6786
|
this.push(entry.deviceId, values);
|
|
6914
6787
|
} catch (err) {
|
|
6915
6788
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -6920,7 +6793,7 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
6920
6793
|
for (const link of this.links.values()) await link.client.flush();
|
|
6921
6794
|
}
|
|
6922
6795
|
/** Everything readable without waiting for an event. */
|
|
6923
|
-
async currentStateFor(entry, snapshotRaw
|
|
6796
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
6924
6797
|
const key = entry.plan.deviceKey;
|
|
6925
6798
|
const values = [];
|
|
6926
6799
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -7000,16 +6873,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7000
6873
|
*/
|
|
7001
6874
|
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
7002
6875
|
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
6876
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
7014
6877
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
7015
6878
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -7501,38 +7364,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7501
7364
|
* disagree with the admin UI is worse than no knob (D62).
|
|
7502
7365
|
*/
|
|
7503
7366
|
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
7367
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
7537
7368
|
if (ruleId === null) {
|
|
7538
7369
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -7702,7 +7533,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7702
7533
|
await device.ptz.goToPreset({ presetId: preset.id });
|
|
7703
7534
|
return true;
|
|
7704
7535
|
}
|
|
7705
|
-
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
7706
7536
|
}
|
|
7707
7537
|
} catch (err) {
|
|
7708
7538
|
/**
|
|
@@ -7728,78 +7558,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
7728
7558
|
});
|
|
7729
7559
|
return false;
|
|
7730
7560
|
}
|
|
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
7561
|
/**
|
|
7804
7562
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
7805
7563
|
*
|
|
@@ -8097,10 +7855,6 @@ var HaExportAddon = class extends require_dist.BaseAddon {
|
|
|
8097
7855
|
{
|
|
8098
7856
|
label: "Capabilities unclassified",
|
|
8099
7857
|
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
8100
|
-
},
|
|
8101
|
-
{
|
|
8102
|
-
label: "Snooze options",
|
|
8103
|
-
value: SNOOZE_OPTIONS.join(", ")
|
|
8104
7858
|
}
|
|
8105
7859
|
],
|
|
8106
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."
|
|
@@ -8193,29 +7947,6 @@ function toCameraStatusInput(status) {
|
|
|
8193
7947
|
recording: status.recording === null ? null : { active: status.recording.active }
|
|
8194
7948
|
};
|
|
8195
7949
|
}
|
|
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
7950
|
function toCountRecord(raw) {
|
|
8220
7951
|
if (raw === null || typeof raw !== "object") return {};
|
|
8221
7952
|
const out = {};
|
|
@@ -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
|
}
|
|
@@ -5122,38 +5073,6 @@ function projectAudioWindow(deviceKey, input) {
|
|
|
5122
5073
|
});
|
|
5123
5074
|
return values;
|
|
5124
5075
|
}
|
|
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
5076
|
/**
|
|
5158
5077
|
* `status` — the entity D62 exists for. A camera an operator switched off and
|
|
5159
5078
|
* a camera that is genuinely broken must never paint the same picture in Home
|
|
@@ -5312,12 +5231,6 @@ function ruleTriggerEntity(ruleId, suffix) {
|
|
|
5312
5231
|
}
|
|
5313
5232
|
function notificationCenterSpecs(input) {
|
|
5314
5233
|
return [
|
|
5315
|
-
{
|
|
5316
|
-
entity: "snoozed",
|
|
5317
|
-
platform: "binary_sensor",
|
|
5318
|
-
label: "Notifications snoozed",
|
|
5319
|
-
icon: "mdi:bell-sleep"
|
|
5320
|
-
},
|
|
5321
5234
|
{
|
|
5322
5235
|
entity: "rules_enabled",
|
|
5323
5236
|
platform: "sensor",
|
|
@@ -5327,21 +5240,6 @@ function notificationCenterSpecs(input) {
|
|
|
5327
5240
|
},
|
|
5328
5241
|
(
|
|
5329
5242
|
/**
|
|
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
5243
|
* The last trigger, six entities: five facts plus its picture — "conoscere
|
|
5346
5244
|
* gli ultimi trigger, le ultime immagini... e da quale label" (operator,
|
|
5347
5245
|
* 2026-08-25). ENABLED by default like every other entity on this device
|
|
@@ -5662,10 +5560,6 @@ function projectSynthetic(input, nowMs) {
|
|
|
5662
5560
|
const srv = syntheticDeviceKeyFor(SERVER_STABLE_ID);
|
|
5663
5561
|
const online = input.nodes.filter((node) => node.online);
|
|
5664
5562
|
const values = [
|
|
5665
|
-
{
|
|
5666
|
-
topic: stateTopic(nc, "snoozed"),
|
|
5667
|
-
value: String(input.snoozed)
|
|
5668
|
-
},
|
|
5669
5563
|
{
|
|
5670
5564
|
topic: stateTopic(nc, "rules_enabled"),
|
|
5671
5565
|
value: String(input.rules.filter((rule) => rule.enabled).length)
|
|
@@ -6501,16 +6395,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6501
6395
|
});
|
|
6502
6396
|
/** `deviceManager.listBindableCapsForDeviceType` — once per TYPE, not per device. */
|
|
6503
6397
|
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
6398
|
const exported = /* @__PURE__ */ new Map();
|
|
6515
6399
|
const keyByDeviceId = /* @__PURE__ */ new Map();
|
|
6516
6400
|
const allCaps = /* @__PURE__ */ new Set();
|
|
@@ -6603,7 +6487,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6603
6487
|
* `brokerIds` is every enabled broker, because they belong to the server
|
|
6604
6488
|
* and not to a membership the operator picks per camera.
|
|
6605
6489
|
*/
|
|
6606
|
-
const synthetic = await this.buildSynthetic(
|
|
6490
|
+
const synthetic = await this.buildSynthetic(byId);
|
|
6607
6491
|
if (synthetic !== null) for (const plan of syntheticPlans(synthetic)) {
|
|
6608
6492
|
exported.set(plan.deviceKey, {
|
|
6609
6493
|
plan,
|
|
@@ -6643,16 +6527,8 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6643
6527
|
}
|
|
6644
6528
|
});
|
|
6645
6529
|
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
|
-
}
|
|
6530
|
+
await this.pushFullState(exported, snapshots);
|
|
6531
|
+
if (synthetic !== null) await this.pushSynthetic(projectSynthetic(synthetic, Date.now()), [...enabled]);
|
|
6656
6532
|
this.ctx.logger.info("ha-export: reconciled", { meta: {
|
|
6657
6533
|
reason,
|
|
6658
6534
|
brokers: enabled.length,
|
|
@@ -6686,7 +6562,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6686
6562
|
* Returns `null` only when NOTHING answered, so the devices are not
|
|
6687
6563
|
* announced empty on a hub that is still booting.
|
|
6688
6564
|
*/
|
|
6689
|
-
async buildSynthetic(
|
|
6565
|
+
async buildSynthetic(byId) {
|
|
6690
6566
|
const rules = await this.ctx.api.notificationRules.listRules.query({}).then((r) => r.rules.map((rule) => ({
|
|
6691
6567
|
id: rule.id,
|
|
6692
6568
|
name: rule.name,
|
|
@@ -6695,8 +6571,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6695
6571
|
this.ctx.logger.warn("ha-export: could not read notification rules", { meta: { error: errMsg(err) } });
|
|
6696
6572
|
return null;
|
|
6697
6573
|
});
|
|
6698
|
-
/** Reuses the ONE `listSnoozes` read the pass already made (Task B1/D4). */
|
|
6699
|
-
const anySnoozed = snoozes.length > 0;
|
|
6700
6574
|
const topology = await this.ctx.api.nodes.topology.query().catch((err) => {
|
|
6701
6575
|
this.ctx.logger.warn("ha-export: could not read the cluster topology", { meta: { error: errMsg(err) } });
|
|
6702
6576
|
return null;
|
|
@@ -6720,7 +6594,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6720
6594
|
const { nodes, oldestFootageMs } = await this.buildSyntheticNodes(topology ?? []);
|
|
6721
6595
|
return {
|
|
6722
6596
|
rules: effectiveRules,
|
|
6723
|
-
snoozed: anySnoozed,
|
|
6724
6597
|
lastTrigger,
|
|
6725
6598
|
lastTriggerByRule,
|
|
6726
6599
|
nodes,
|
|
@@ -6902,9 +6775,9 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6902
6775
|
}
|
|
6903
6776
|
}
|
|
6904
6777
|
}
|
|
6905
|
-
async pushFullState(exported, snapshots
|
|
6778
|
+
async pushFullState(exported, snapshots) {
|
|
6906
6779
|
for (const entry of exported.values()) try {
|
|
6907
|
-
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]
|
|
6780
|
+
const values = await this.currentStateFor(entry, snapshots[String(entry.deviceId)]);
|
|
6908
6781
|
this.push(entry.deviceId, values);
|
|
6909
6782
|
} catch (err) {
|
|
6910
6783
|
this.ctx.logger.warn("ha-export: could not read current state for a device", {
|
|
@@ -6915,7 +6788,7 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6915
6788
|
for (const link of this.links.values()) await link.client.flush();
|
|
6916
6789
|
}
|
|
6917
6790
|
/** Everything readable without waiting for an event. */
|
|
6918
|
-
async currentStateFor(entry, snapshotRaw
|
|
6791
|
+
async currentStateFor(entry, snapshotRaw) {
|
|
6919
6792
|
const key = entry.plan.deviceKey;
|
|
6920
6793
|
const values = [];
|
|
6921
6794
|
const snapshot = toSnapshot(snapshotRaw);
|
|
@@ -6995,16 +6868,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
6995
6868
|
*/
|
|
6996
6869
|
const cameraStatus = this.cameraStatusByDeviceId.get(entry.deviceId);
|
|
6997
6870
|
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
6871
|
const occupancy = await this.ctx.api.zoneAnalytics.getCurrentSnapshot.query({ deviceId: entry.deviceId });
|
|
7009
6872
|
if (occupancy !== null) values.push(...projectZoneOccupancy(key, {
|
|
7010
6873
|
zones: occupancy.zones.map((zone) => ({
|
|
@@ -7496,38 +7359,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7496
7359
|
* disagree with the admin UI is worse than no knob (D62).
|
|
7497
7360
|
*/
|
|
7498
7361
|
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
7362
|
const ruleId = ruleIdFromEntity(parsed.entity, this.syntheticRules);
|
|
7532
7363
|
if (ruleId === null) {
|
|
7533
7364
|
this.ctx.logger.warn("ha-export: dropping a synthetic command with no authority behind it", { meta: {
|
|
@@ -7697,7 +7528,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7697
7528
|
await device.ptz.goToPreset({ presetId: preset.id });
|
|
7698
7529
|
return true;
|
|
7699
7530
|
}
|
|
7700
|
-
case "snooze": return await this.applySnooze(command.deviceId, command.minutes);
|
|
7701
7531
|
}
|
|
7702
7532
|
} catch (err) {
|
|
7703
7533
|
/**
|
|
@@ -7723,78 +7553,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
7723
7553
|
});
|
|
7724
7554
|
return false;
|
|
7725
7555
|
}
|
|
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
7556
|
/**
|
|
7799
7557
|
* A public, signed, expiring route Home Assistant can fetch bytes from.
|
|
7800
7558
|
*
|
|
@@ -8092,10 +7850,6 @@ var HaExportAddon = class extends BaseAddon {
|
|
|
8092
7850
|
{
|
|
8093
7851
|
label: "Capabilities unclassified",
|
|
8094
7852
|
value: this.unclassified.length === 0 ? "none" : this.unclassified.join(", ")
|
|
8095
|
-
},
|
|
8096
|
-
{
|
|
8097
|
-
label: "Snooze options",
|
|
8098
|
-
value: SNOOZE_OPTIONS.join(", ")
|
|
8099
7853
|
}
|
|
8100
7854
|
],
|
|
8101
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."
|
|
@@ -8188,29 +7942,6 @@ function toCameraStatusInput(status) {
|
|
|
8188
7942
|
recording: status.recording === null ? null : { active: status.recording.active }
|
|
8189
7943
|
};
|
|
8190
7944
|
}
|
|
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
7945
|
function toCountRecord(raw) {
|
|
8215
7946
|
if (raw === null || typeof raw !== "object") return {};
|
|
8216
7947
|
const out = {};
|