@camstack/addon-post-analysis 1.2.33 → 1.2.35
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/{dist-DPAMoN4x.js → dist-Cmb3B0Ij.js} +543 -341
- package/dist/{dist-D3Ht9Ae5.mjs → dist-nh2kuzSC.mjs} +543 -341
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/{node-B1HAVV_-.js → node-BNgGpVnp.js} +1 -1
- package/dist/pipeline-analytics/_stub.js +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CBzk4ENk.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-DsOwA-XR.mjs} +2 -2
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-PQKnzGEk.mjs +26 -0
- package/dist/pipeline-analytics/{hostInit-Di4YF5GU.mjs → hostInit-BPatwh25.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +1079 -93
- package/dist/pipeline-analytics/index.mjs +1078 -92
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
- package/dist/pipeline-analytics/_virtual_mf___mfe_internal__addon_pipeline_analytics_widgets__loadShare___mf_0_camstack_mf_1_ui_mf_2_library__loadShare__.js-DmydN-_W.mjs +0 -26
|
@@ -1,8 +1,211 @@
|
|
|
1
|
-
import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-
|
|
1
|
+
import { $ as EventCategory, A as notificationRulesCapability, B as createEvent, C as cosineSimilarity, D as faceGalleryCapability, F as videoclipsCapability, G as array, H as isDeviceScopedCap, I as zoneAnalyticsCapability, J as number, K as boolean, L as errMsg, M as plateGalleryCapability, N as readDeviceStateFrom, P as subKindsOf, Q as unknown, R as BaseAddon, S as buildEventKindDescriptor, T as defineCustomActions, U as nodePin, V as hydrateSchema, W as _enum, X as record, Y as object, Z as string, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as alarmPanelCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as pipelineAnalyticsCapability, k as kebabToCamel, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as literal, r as EVENT_KIND_BY_CAP, s as NC_TAXONOMY, t as BaseDevice, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as customAction, x as audioMetricsCapability, y as addonWidgetsSourceCapability, z as DeviceType } from "../dist-nh2kuzSC.mjs";
|
|
2
2
|
import { promises } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
|
|
5
5
|
import sharp from "sharp";
|
|
6
|
+
//#region src/notification-center/action-token.ts
|
|
7
|
+
/**
|
|
8
|
+
* The authority behind a notification button.
|
|
9
|
+
*
|
|
10
|
+
* **Read this before changing anything here.** A button's URL travels through
|
|
11
|
+
* third-party infrastructure — ntfy's server, a push relay, whatever forwarded
|
|
12
|
+
* the message — and is therefore exactly as public as the notification is. The
|
|
13
|
+
* operator decided (2026-08-05) that the token ALONE authorises the action:
|
|
14
|
+
* there is no session check, and a tap does not identify who tapped. Whoever
|
|
15
|
+
* holds the link can run that one action. [D47](../../../../docs/decisions/adr-0047.md).
|
|
16
|
+
*
|
|
17
|
+
* Everything in this file exists to bound that blast radius, since the
|
|
18
|
+
* authorisation model does not:
|
|
19
|
+
*
|
|
20
|
+
* - **One token, one action, one notification.** The record carries the rule,
|
|
21
|
+
* the sequence and the device, so a token cannot be re-pointed at anything
|
|
22
|
+
* else. There is no "run arbitrary sequence" shape to abuse.
|
|
23
|
+
* - **Single use, claimed ATOMICALLY.** `claim()` is the only way to read a
|
|
24
|
+
* record and it marks it spent in the same synchronous step, so two taps
|
|
25
|
+
* arriving together cannot both open a gate. This is why the registry is a
|
|
26
|
+
* plain in-process Map and not the settings store: a read-then-write across
|
|
27
|
+
* an async store is a race that a physical actuator would pay for.
|
|
28
|
+
* - **Short-lived, and the expiry is a COMPARISON, not a sweeper.** A reaper
|
|
29
|
+
* that failed would leave tokens live indefinitely — the same reasoning the
|
|
30
|
+
* snooze policy is built on. The sweep here only reclaims memory.
|
|
31
|
+
* - **In-memory is deliberate.** Tokens do not survive a hub restart, so a
|
|
32
|
+
* link can never outlive the process that minted it. The cost is real and
|
|
33
|
+
* accepted: a deploy kills the buttons on notifications already delivered,
|
|
34
|
+
* which with a short TTL were nearly dead anyway.
|
|
35
|
+
*/
|
|
36
|
+
/** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
|
|
37
|
+
* the artifact plane's, deliberately: one signing convention in this addon. */
|
|
38
|
+
function signActionToken(secret, id, expMs) {
|
|
39
|
+
return createHmac("sha256", secret).update(`act:${id}:${expMs}`).digest("hex");
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* Verify a callback's `(id, exp, sig)`.
|
|
43
|
+
*
|
|
44
|
+
* Constant-time on the signature so a public route cannot be probed for it byte
|
|
45
|
+
* by byte, and expiry-checked BEFORE the compare so an expired link is refused
|
|
46
|
+
* even when its signature is perfect.
|
|
47
|
+
*/
|
|
48
|
+
function verifyActionSignature(input) {
|
|
49
|
+
if (!Number.isFinite(input.exp) || input.exp <= input.nowMs) return false;
|
|
50
|
+
const expected = signActionToken(input.secret, input.id, input.exp);
|
|
51
|
+
const a = Buffer.from(expected, "utf8");
|
|
52
|
+
const b = Buffer.from(input.sig, "utf8");
|
|
53
|
+
if (a.length !== b.length) return false;
|
|
54
|
+
return timingSafeEqual(a, b);
|
|
55
|
+
}
|
|
56
|
+
/** Build the fully-qualified, signed callback URL for one button. */
|
|
57
|
+
function buildActionUrl(input) {
|
|
58
|
+
const sig = signActionToken(input.secret, input.id, input.expMs);
|
|
59
|
+
return `${input.baseUrl.endsWith("/") ? input.baseUrl.slice(0, -1) : input.baseUrl}${input.routePrefix.startsWith("/") ? input.routePrefix : `/${input.routePrefix}`}/${encodeURIComponent(input.id)}?exp=${input.expMs}&sig=${sig}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* How long a button stays live.
|
|
63
|
+
*
|
|
64
|
+
* Fifteen minutes, not the artifact plane's 24 h. An image link is a read; this
|
|
65
|
+
* one moves something physical, and the window is the only thing limiting who
|
|
66
|
+
* can use a forwarded notification. An operator who wakes hours later opens the
|
|
67
|
+
* app — the button was never the path for that.
|
|
68
|
+
*/
|
|
69
|
+
var DEFAULT_ACTION_TTL_MS = 15 * 6e4;
|
|
70
|
+
var NcActionTokenRegistry = class {
|
|
71
|
+
grants = /* @__PURE__ */ new Map();
|
|
72
|
+
now;
|
|
73
|
+
constructor(now) {
|
|
74
|
+
this.now = now ?? (() => Date.now());
|
|
75
|
+
}
|
|
76
|
+
/** Register a freshly minted grant. The caller owns id generation (it needs
|
|
77
|
+
* the id to build the URL before the record exists). */
|
|
78
|
+
put(grant) {
|
|
79
|
+
this.grants.set(grant.id, {
|
|
80
|
+
grant,
|
|
81
|
+
used: false
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
/**
|
|
85
|
+
* Take the grant, once.
|
|
86
|
+
*
|
|
87
|
+
* Synchronous and mutating in one step ON PURPOSE — see the header. Anything
|
|
88
|
+
* that awaited between the read and the mark would let two taps through, and
|
|
89
|
+
* the thing on the other end is a lock or a gate.
|
|
90
|
+
*/
|
|
91
|
+
claim(id) {
|
|
92
|
+
const found = this.grants.get(id);
|
|
93
|
+
if (found === void 0) return {
|
|
94
|
+
ok: false,
|
|
95
|
+
reason: "unknown"
|
|
96
|
+
};
|
|
97
|
+
if (found.used) return {
|
|
98
|
+
ok: false,
|
|
99
|
+
reason: "already-used"
|
|
100
|
+
};
|
|
101
|
+
if (found.grant.expiresAt <= this.now()) return {
|
|
102
|
+
ok: false,
|
|
103
|
+
reason: "expired"
|
|
104
|
+
};
|
|
105
|
+
found.used = true;
|
|
106
|
+
return {
|
|
107
|
+
ok: true,
|
|
108
|
+
grant: found.grant
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
/** Drop records that can no longer be claimed. Memory hygiene only — an
|
|
112
|
+
* expired record is already refused by {@link claim}, so a sweep that never
|
|
113
|
+
* ran would cost bytes, never safety. */
|
|
114
|
+
sweep() {
|
|
115
|
+
const now = this.now();
|
|
116
|
+
let removed = 0;
|
|
117
|
+
for (const [id, entry] of this.grants) if (entry.used || entry.grant.expiresAt <= now) {
|
|
118
|
+
this.grants.delete(id);
|
|
119
|
+
removed += 1;
|
|
120
|
+
}
|
|
121
|
+
return removed;
|
|
122
|
+
}
|
|
123
|
+
/** Live (unclaimed, unexpired) token count — for the log line that tells an
|
|
124
|
+
* operator whether buttons are actually being minted. */
|
|
125
|
+
get liveCount() {
|
|
126
|
+
const now = this.now();
|
|
127
|
+
let n = 0;
|
|
128
|
+
for (const entry of this.grants.values()) if (!entry.used && entry.grant.expiresAt > now) n += 1;
|
|
129
|
+
return n;
|
|
130
|
+
}
|
|
131
|
+
};
|
|
132
|
+
//#endregion
|
|
133
|
+
//#region src/notification-center/action-plane.ts
|
|
134
|
+
/** What the human standing in front of the page is told. */
|
|
135
|
+
var FAILURE_COPY = {
|
|
136
|
+
unknown: "This button is no longer available.",
|
|
137
|
+
expired: "This button has expired. Open CamStack to do it there.",
|
|
138
|
+
"already-used": "Already done — this button had been used."
|
|
139
|
+
};
|
|
140
|
+
function page(title, detail) {
|
|
141
|
+
return `<!doctype html><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${title}</title><style>body{font:16px/1.5 system-ui,sans-serif;margin:0;min-height:100vh;display:grid;place-items:center;background:#111;color:#eee}main{max-width:24rem;padding:2rem;text-align:center}h1{font-size:1.25rem;margin:0 0 .5rem}p{margin:0;color:#aaa}</style><main><h1>${title}</h1><p>${detail}</p></main>`;
|
|
142
|
+
}
|
|
143
|
+
var NcActionPlane = class {
|
|
144
|
+
deps;
|
|
145
|
+
now;
|
|
146
|
+
constructor(deps) {
|
|
147
|
+
this.deps = deps;
|
|
148
|
+
this.now = deps.now ?? (() => Date.now());
|
|
149
|
+
}
|
|
150
|
+
handler = async (req, res) => {
|
|
151
|
+
const url = new URL(req.url ?? "/", "http://placeholder");
|
|
152
|
+
const id = decodeURIComponent(url.pathname.split("/").filter(Boolean).pop() ?? "");
|
|
153
|
+
if (!verifyActionSignature({
|
|
154
|
+
secret: this.deps.secret,
|
|
155
|
+
id,
|
|
156
|
+
exp: Number(url.searchParams.get("exp")),
|
|
157
|
+
sig: url.searchParams.get("sig") ?? "",
|
|
158
|
+
nowMs: this.now()
|
|
159
|
+
})) {
|
|
160
|
+
this.deps.logger.warn("nc action callback refused — bad or expired signature", { meta: { id } });
|
|
161
|
+
this.send(res, 404, page("Not found", "This link is not valid."));
|
|
162
|
+
return;
|
|
163
|
+
}
|
|
164
|
+
const claim = this.deps.registry.claim(id);
|
|
165
|
+
if (!claim.ok) {
|
|
166
|
+
this.deps.logger.info("nc action callback not claimable", { meta: {
|
|
167
|
+
id,
|
|
168
|
+
reason: claim.reason
|
|
169
|
+
} });
|
|
170
|
+
this.send(res, 200, page("Nothing to do", FAILURE_COPY[claim.reason]));
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
const grant = claim.grant;
|
|
174
|
+
try {
|
|
175
|
+
await this.deps.run(grant);
|
|
176
|
+
this.deps.logger.info("nc action ran from a notification button", {
|
|
177
|
+
tags: { deviceId: grant.deviceId },
|
|
178
|
+
meta: {
|
|
179
|
+
ruleId: grant.ruleId,
|
|
180
|
+
rule: grant.ruleName,
|
|
181
|
+
sequence: grant.sequence,
|
|
182
|
+
action: grant.actionId,
|
|
183
|
+
targetId: grant.targetId ?? null
|
|
184
|
+
}
|
|
185
|
+
});
|
|
186
|
+
this.send(res, 200, page("Done", `${grant.ruleName} — ${grant.sequence}`));
|
|
187
|
+
} catch (err) {
|
|
188
|
+
this.deps.logger.warn("nc action failed after the token was spent", {
|
|
189
|
+
tags: { deviceId: grant.deviceId },
|
|
190
|
+
meta: {
|
|
191
|
+
ruleId: grant.ruleId,
|
|
192
|
+
sequence: grant.sequence,
|
|
193
|
+
action: grant.actionId,
|
|
194
|
+
error: String(err)
|
|
195
|
+
}
|
|
196
|
+
});
|
|
197
|
+
this.send(res, 200, page("It did not work", "The action failed and this button is now spent. Open CamStack."));
|
|
198
|
+
}
|
|
199
|
+
};
|
|
200
|
+
send(res, status, html) {
|
|
201
|
+
res.writeHead(status, {
|
|
202
|
+
"content-type": "text/html; charset=utf-8",
|
|
203
|
+
"cache-control": "no-store"
|
|
204
|
+
});
|
|
205
|
+
res.end(html);
|
|
206
|
+
}
|
|
207
|
+
};
|
|
208
|
+
//#endregion
|
|
6
209
|
//#region src/notification-center/artifact-url.ts
|
|
7
210
|
/**
|
|
8
211
|
* Signed, externally-reachable URLs for notification artifacts.
|
|
@@ -341,7 +544,132 @@ function mimeFromExtension(file) {
|
|
|
341
544
|
return "application/octet-stream";
|
|
342
545
|
}
|
|
343
546
|
//#endregion
|
|
547
|
+
//#region src/notification-center/alarm/alarm-mode-coverage.ts
|
|
548
|
+
/**
|
|
549
|
+
* Whether the panel just BECAME armed, in the sense a person means.
|
|
550
|
+
*
|
|
551
|
+
* Two transitions reach an `armed_*` state and only one of them is an arm:
|
|
552
|
+
*
|
|
553
|
+
* - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
|
|
554
|
+
* - `triggered` → `armed_away` is the siren's duration ending and the panel
|
|
555
|
+
* re-arming itself. Announcing it would send "Away armed" in the middle of
|
|
556
|
+
* a break-in, seconds after the alarm notification, saying nothing new.
|
|
557
|
+
*
|
|
558
|
+
* `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
|
|
559
|
+
* set of devices covered just changed with it — which is the whole content of
|
|
560
|
+
* the message.
|
|
561
|
+
*
|
|
562
|
+
* A null `previous` is the FIRST publish and never announces. A hub restarting
|
|
563
|
+
* while armed re-publishes `armed_away`, and nobody armed anything — every
|
|
564
|
+
* deploy would otherwise send one. Handled here rather than by the caller
|
|
565
|
+
* happening to wire the hook late, because "it works because of the order two
|
|
566
|
+
* unrelated things run in" is how this stops working.
|
|
567
|
+
*/
|
|
568
|
+
function shouldAnnounceArm(previous, next) {
|
|
569
|
+
if (previous === null) return false;
|
|
570
|
+
if (!next.startsWith("armed_")) return false;
|
|
571
|
+
if (previous === next) return false;
|
|
572
|
+
if (previous === "triggered") return false;
|
|
573
|
+
return true;
|
|
574
|
+
}
|
|
575
|
+
/** The mode inside an `armed_<mode>` state, or null for any other state. */
|
|
576
|
+
function armModeOf(state, modes) {
|
|
577
|
+
for (const mode of modes) if (state === armedStateFor(mode)) return mode;
|
|
578
|
+
return null;
|
|
579
|
+
}
|
|
580
|
+
/** The `deviceState` word a rule must gate on for `mode` to cover it. */
|
|
581
|
+
function armedStateFor(mode) {
|
|
582
|
+
return `armed_${mode}`;
|
|
583
|
+
}
|
|
584
|
+
/**
|
|
585
|
+
* One entry per mode the panel offers, including modes nothing is gated on.
|
|
586
|
+
*
|
|
587
|
+
* Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
|
|
588
|
+
* most useful thing this can tell an operator, and a list that omits it looks
|
|
589
|
+
* identical to a list where night is covered.
|
|
590
|
+
*/
|
|
591
|
+
function alarmModeCoverage(rules, panelDeviceId, modes) {
|
|
592
|
+
return modes.map((mode) => coverageFor(rules, panelDeviceId, mode));
|
|
593
|
+
}
|
|
594
|
+
function coverageFor(rules, panelDeviceId, mode) {
|
|
595
|
+
const wanted = armedStateFor(mode);
|
|
596
|
+
const ids = /* @__PURE__ */ new Set();
|
|
597
|
+
let ruleCount = 0;
|
|
598
|
+
let allDevices = false;
|
|
599
|
+
for (const rule of rules) {
|
|
600
|
+
if (!rule.enabled) continue;
|
|
601
|
+
const gate = rule.conditions.deviceState;
|
|
602
|
+
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
603
|
+
if (!gate.states.includes(wanted)) continue;
|
|
604
|
+
ruleCount += 1;
|
|
605
|
+
const scope = rule.conditions.devices;
|
|
606
|
+
if (scope === void 0 || scope.length === 0) {
|
|
607
|
+
allDevices = true;
|
|
608
|
+
continue;
|
|
609
|
+
}
|
|
610
|
+
for (const id of scope) ids.add(id);
|
|
611
|
+
}
|
|
612
|
+
return {
|
|
613
|
+
mode,
|
|
614
|
+
ruleCount,
|
|
615
|
+
allDevices,
|
|
616
|
+
deviceIds: [...ids].toSorted((a, b) => a - b)
|
|
617
|
+
};
|
|
618
|
+
}
|
|
619
|
+
/**
|
|
620
|
+
* "Away armed — 3 cameras: Front door, Garage, Gate."
|
|
621
|
+
*
|
|
622
|
+
* Names, not ids: this is read by a person standing at a door. An id that has
|
|
623
|
+
* no name falls back to `Device <id>` rather than being dropped — a silently
|
|
624
|
+
* shorter list would understate what is armed, which is the one error this
|
|
625
|
+
* message must not make.
|
|
626
|
+
*
|
|
627
|
+
* Returns null when the mode covers NOTHING and no rule is gated on it. A
|
|
628
|
+
* notification saying "Night armed" that is followed by nothing happening all
|
|
629
|
+
* night is worse than no notification: it is a false assurance. The caller
|
|
630
|
+
* still logs the arm.
|
|
631
|
+
*/
|
|
632
|
+
function buildArmAnnouncement(coverage, deviceNames) {
|
|
633
|
+
if (coverage.ruleCount === 0) return null;
|
|
634
|
+
const title = `${modeLabel(coverage.mode)} armed`;
|
|
635
|
+
if (coverage.allDevices) return {
|
|
636
|
+
title,
|
|
637
|
+
body: `Every device is armed (${plural$1(coverage.ruleCount, "rule")}).`
|
|
638
|
+
};
|
|
639
|
+
if (coverage.deviceIds.length === 0) return {
|
|
640
|
+
title,
|
|
641
|
+
body: `${plural$1(coverage.ruleCount, "rule")}, no device restriction.`
|
|
642
|
+
};
|
|
643
|
+
const names = coverage.deviceIds.map((id) => deviceNames.get(id) ?? `Device ${id}`);
|
|
644
|
+
return {
|
|
645
|
+
title,
|
|
646
|
+
body: `${plural$1(names.length, "device")} armed: ${names.join(", ")}.`
|
|
647
|
+
};
|
|
648
|
+
}
|
|
649
|
+
function modeLabel(mode) {
|
|
650
|
+
return mode.charAt(0).toUpperCase() + mode.slice(1).replace(/_/g, " ");
|
|
651
|
+
}
|
|
652
|
+
function plural$1(n, one) {
|
|
653
|
+
return n === 1 ? `1 ${one}` : `${n} ${one}s`;
|
|
654
|
+
}
|
|
655
|
+
//#endregion
|
|
344
656
|
//#region src/notification-center/alarm/alarm-state-machine.ts
|
|
657
|
+
/**
|
|
658
|
+
* Defaults an operator can change per panel. `home` has no exit delay on
|
|
659
|
+
* purpose — nobody is leaving — but the shared value is applied to every mode
|
|
660
|
+
* until per-mode delays are asked for.
|
|
661
|
+
*
|
|
662
|
+
* `triggeredDurationSec: 0` = "sound until somebody disarms it". That is what
|
|
663
|
+
* the panel did before the field existed, so an install that never opens the
|
|
664
|
+
* tab keeps behaving exactly as it did — a default that silently re-armed
|
|
665
|
+
* every existing alarm after N seconds would be a behaviour change nobody
|
|
666
|
+
* asked for.
|
|
667
|
+
*/
|
|
668
|
+
var DEFAULT_DELAYS = {
|
|
669
|
+
exitDelaySec: 30,
|
|
670
|
+
entryDelaySec: 20,
|
|
671
|
+
triggeredDurationSec: 0
|
|
672
|
+
};
|
|
345
673
|
var DISARMED = {
|
|
346
674
|
target: "disarmed",
|
|
347
675
|
fired: false,
|
|
@@ -358,6 +686,7 @@ function armedState(mode) {
|
|
|
358
686
|
* answer is still correct the moment anyone asks.
|
|
359
687
|
*/
|
|
360
688
|
function stateAt(m, now) {
|
|
689
|
+
if (m.clearAt !== void 0 && now >= m.clearAt) return m.target;
|
|
361
690
|
if (m.fired) return "triggered";
|
|
362
691
|
if (m.triggerAt !== void 0) return now >= m.triggerAt ? "triggered" : "pending";
|
|
363
692
|
if (m.armedAt !== void 0) return now >= m.armedAt ? m.target : "arming";
|
|
@@ -411,19 +740,24 @@ function disarm(now) {
|
|
|
411
740
|
* intruder walking past two sensors would otherwise postpone the alarm.
|
|
412
741
|
*/
|
|
413
742
|
function trigger(m, delays, now) {
|
|
743
|
+
const state = stateAt(m, now);
|
|
744
|
+
if (state === "triggered" || state === "pending") return m;
|
|
414
745
|
if (!isArmed(m, now)) return m;
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
...m,
|
|
418
|
-
fired: true,
|
|
419
|
-
changedAt: now
|
|
420
|
-
};
|
|
746
|
+
const base = settle(m, now);
|
|
747
|
+
const firesAt = now + Math.max(0, delays.entryDelaySec) * 1e3;
|
|
421
748
|
return {
|
|
422
|
-
...
|
|
423
|
-
triggerAt:
|
|
749
|
+
...base,
|
|
750
|
+
triggerAt: firesAt,
|
|
751
|
+
...autoClear(delays, firesAt),
|
|
752
|
+
fired: false,
|
|
424
753
|
changedAt: now
|
|
425
754
|
};
|
|
426
755
|
}
|
|
756
|
+
/** `clearAt` iff the operator asked for a bounded siren — see {@link AlarmDelays}. */
|
|
757
|
+
function autoClear(delays, firesAt) {
|
|
758
|
+
if (delays.triggeredDurationSec <= 0) return {};
|
|
759
|
+
return { clearAt: firesAt + delays.triggeredDurationSec * 1e3 };
|
|
760
|
+
}
|
|
427
761
|
/**
|
|
428
762
|
* Settle the machine so a stored value never depends on when it is read.
|
|
429
763
|
*
|
|
@@ -432,10 +766,16 @@ function trigger(m, delays, now) {
|
|
|
432
766
|
* than only in the reading of it.
|
|
433
767
|
*/
|
|
434
768
|
function settle(m, now) {
|
|
769
|
+
if (m.clearAt !== void 0 && now >= m.clearAt) return {
|
|
770
|
+
target: m.target,
|
|
771
|
+
fired: false,
|
|
772
|
+
changedAt: m.clearAt
|
|
773
|
+
};
|
|
435
774
|
if (m.triggerAt !== void 0 && now >= m.triggerAt) return {
|
|
436
775
|
target: m.target,
|
|
437
776
|
fired: true,
|
|
438
|
-
changedAt: m.triggerAt
|
|
777
|
+
changedAt: m.triggerAt,
|
|
778
|
+
...m.clearAt !== void 0 ? { clearAt: m.clearAt } : {}
|
|
439
779
|
};
|
|
440
780
|
if (m.armedAt !== void 0 && now >= m.armedAt) return {
|
|
441
781
|
target: m.target,
|
|
@@ -464,21 +804,12 @@ function settle(m, now) {
|
|
|
464
804
|
* and re-publish when a delay elapses.
|
|
465
805
|
*/
|
|
466
806
|
/** Every arm mode a camstack-owned panel offers. */
|
|
467
|
-
var
|
|
807
|
+
var NC_ALARM_MODES = [
|
|
468
808
|
"home",
|
|
469
809
|
"away",
|
|
470
810
|
"night"
|
|
471
811
|
];
|
|
472
812
|
/**
|
|
473
|
-
* Defaults an operator can change per panel. `home` has no exit delay on
|
|
474
|
-
* purpose — nobody is leaving — but the shared value is applied to every mode
|
|
475
|
-
* until per-mode delays are asked for.
|
|
476
|
-
*/
|
|
477
|
-
var DEFAULT_DELAYS = {
|
|
478
|
-
exitDelaySec: 30,
|
|
479
|
-
entryDelaySec: 20
|
|
480
|
-
};
|
|
481
|
-
/**
|
|
482
813
|
* How often the panel re-publishes while a delay is running.
|
|
483
814
|
*
|
|
484
815
|
* The state is computed from an INSTANT, so this tick only decides how quickly
|
|
@@ -494,30 +825,94 @@ var TICK_MS = 1e3;
|
|
|
494
825
|
var ncAlarmPanelSchema = object({
|
|
495
826
|
exitDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
|
|
496
827
|
entryDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
|
|
828
|
+
triggeredDurationSec: number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
|
|
829
|
+
/** Send a notification when a mode takes effect. Off until asked for. */
|
|
830
|
+
announceArm: boolean().default(false),
|
|
831
|
+
/** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
|
|
832
|
+
announceTargets: array(string()).default([]),
|
|
497
833
|
machine: record(string(), unknown()).optional()
|
|
498
834
|
});
|
|
835
|
+
/** `armed_home` and friends — the only targets a machine may settle into. */
|
|
836
|
+
function isArmTarget(value) {
|
|
837
|
+
return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
|
|
838
|
+
}
|
|
839
|
+
function optionalInstant(value) {
|
|
840
|
+
return typeof value === "number" && Number.isFinite(value) ? value : void 0;
|
|
841
|
+
}
|
|
842
|
+
/**
|
|
843
|
+
* Rebuild the machine from the persisted blob, or null when there is nothing
|
|
844
|
+
* trustworthy in it.
|
|
845
|
+
*
|
|
846
|
+
* A type GUARD, not a cast: the blob has survived a schema change once already
|
|
847
|
+
* (`clearAt` did not exist), and the failure mode of a cast here is an alarm
|
|
848
|
+
* that reports a state it cannot reach. Anything unrecognised falls back to
|
|
849
|
+
* DISARMED, which is the state an operator will notice.
|
|
850
|
+
*
|
|
851
|
+
* Exported for the test that proves a pre-`clearAt` blob still restores.
|
|
852
|
+
*/
|
|
853
|
+
function machineFromPersisted(raw) {
|
|
854
|
+
if (raw === null || typeof raw !== "object") return null;
|
|
855
|
+
const rec = { ...raw };
|
|
856
|
+
if (!isArmTarget(rec["target"]) || typeof rec["changedAt"] !== "number") return null;
|
|
857
|
+
const armedAt = optionalInstant(rec["armedAt"]);
|
|
858
|
+
const triggerAt = optionalInstant(rec["triggerAt"]);
|
|
859
|
+
const clearAt = optionalInstant(rec["clearAt"]);
|
|
860
|
+
return {
|
|
861
|
+
target: rec["target"],
|
|
862
|
+
...armedAt !== void 0 ? { armedAt } : {},
|
|
863
|
+
...triggerAt !== void 0 ? { triggerAt } : {},
|
|
864
|
+
...clearAt !== void 0 ? { clearAt } : {},
|
|
865
|
+
fired: rec["fired"] === true,
|
|
866
|
+
changedAt: rec["changedAt"]
|
|
867
|
+
};
|
|
868
|
+
}
|
|
499
869
|
var NcAlarmPanelDevice = class extends BaseDevice {
|
|
500
870
|
features = [];
|
|
501
871
|
machine = DISARMED;
|
|
502
872
|
delays = DEFAULT_DELAYS;
|
|
503
873
|
ticker = null;
|
|
874
|
+
onArmed = null;
|
|
875
|
+
/**
|
|
876
|
+
* The state the last publish reported. Null until the first one, so the
|
|
877
|
+
* BOOT publish never announces: a hub restarting while armed would otherwise
|
|
878
|
+
* announce "Away armed" on every deploy.
|
|
879
|
+
*/
|
|
880
|
+
lastPublished = null;
|
|
504
881
|
constructor(ctx) {
|
|
505
882
|
super(ctx, ncAlarmPanelSchema, { type: ctx.deviceMeta.type });
|
|
506
|
-
this.delays =
|
|
507
|
-
exitDelaySec: this.config.get("exitDelaySec"),
|
|
508
|
-
entryDelaySec: this.config.get("entryDelaySec")
|
|
509
|
-
};
|
|
883
|
+
this.delays = this.readDelays();
|
|
510
884
|
this.restore();
|
|
511
885
|
this.registerAlarmCap();
|
|
512
886
|
this.publish();
|
|
513
887
|
}
|
|
514
|
-
/**
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
888
|
+
/** The operator-visible configuration, as the cap serves it. */
|
|
889
|
+
settings() {
|
|
890
|
+
return {
|
|
891
|
+
...this.readDelays(),
|
|
892
|
+
announceArm: this.config.get("announceArm"),
|
|
893
|
+
announceTargets: [...this.config.get("announceTargets")]
|
|
894
|
+
};
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Apply an editor's patch and return what the panel settled on.
|
|
898
|
+
*
|
|
899
|
+
* The new delays take effect on the NEXT transition, never retroactively: a
|
|
900
|
+
* machine already counting down keeps the instants it was given. Re-deriving
|
|
901
|
+
* `armedAt` from a delay the operator changed mid-exit would move the arm
|
|
902
|
+
* instant under somebody who is walking out of the door.
|
|
903
|
+
*/
|
|
904
|
+
async applySettings(patch) {
|
|
905
|
+
if (patch.exitDelaySec !== void 0) await this.config.set("exitDelaySec", patch.exitDelaySec);
|
|
906
|
+
if (patch.entryDelaySec !== void 0) await this.config.set("entryDelaySec", patch.entryDelaySec);
|
|
907
|
+
if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
|
|
908
|
+
if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
|
|
909
|
+
if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
|
|
910
|
+
this.delays = this.readDelays();
|
|
911
|
+
return this.settings();
|
|
912
|
+
}
|
|
913
|
+
/** Wire the arm announcement. See {@link NcAlarmArmedHook}. */
|
|
914
|
+
setArmedHook(hook) {
|
|
915
|
+
this.onArmed = hook;
|
|
521
916
|
}
|
|
522
917
|
/** Current lifecycle word — what the `deviceState` gate compares against. */
|
|
523
918
|
currentState(now = Date.now()) {
|
|
@@ -540,19 +935,24 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
540
935
|
this.publish();
|
|
541
936
|
},
|
|
542
937
|
trigger: async () => {
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
938
|
+
const now = Date.now();
|
|
939
|
+
const next = trigger(this.machine, this.delays, now);
|
|
940
|
+
if (next === this.machine) {
|
|
941
|
+
this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
|
|
942
|
+
tags: { deviceId: this.id },
|
|
943
|
+
meta: { state: stateAt(this.machine, now) }
|
|
944
|
+
});
|
|
945
|
+
return;
|
|
946
|
+
}
|
|
947
|
+
this.machine = next;
|
|
948
|
+
this.publish(now);
|
|
549
949
|
}
|
|
550
950
|
});
|
|
551
951
|
}
|
|
552
952
|
status(now = Date.now()) {
|
|
553
953
|
return {
|
|
554
954
|
state: stateAt(this.machine, now),
|
|
555
|
-
availableModes: [...
|
|
955
|
+
availableModes: [...NC_ALARM_MODES],
|
|
556
956
|
requiresCode: false,
|
|
557
957
|
lastChangedAt: this.machine.changedAt
|
|
558
958
|
};
|
|
@@ -566,11 +966,48 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
566
966
|
*/
|
|
567
967
|
publish(now = Date.now()) {
|
|
568
968
|
this.machine = settle(this.machine, now);
|
|
969
|
+
const state = stateAt(this.machine, now);
|
|
569
970
|
this.runtimeState.setCapState("alarm-panel", this.status(now));
|
|
570
971
|
this.persist();
|
|
571
|
-
|
|
972
|
+
this.announceIfArmed(state);
|
|
973
|
+
if (this.machine.armedAt !== void 0 || this.machine.triggerAt !== void 0 || this.machine.clearAt !== void 0) this.startTicking();
|
|
572
974
|
else this.stopTicking();
|
|
573
975
|
}
|
|
976
|
+
/**
|
|
977
|
+
* Fire the arm hook on the transition INTO an armed mode.
|
|
978
|
+
*
|
|
979
|
+
* Never throws into the publish path: an announcement that could take the
|
|
980
|
+
* panel's own state update with it would make a notification failure look
|
|
981
|
+
* like an alarm failure.
|
|
982
|
+
*/
|
|
983
|
+
announceIfArmed(state) {
|
|
984
|
+
const previous = this.lastPublished;
|
|
985
|
+
this.lastPublished = state;
|
|
986
|
+
const hook = this.onArmed;
|
|
987
|
+
if (hook === null) return;
|
|
988
|
+
if (!shouldAnnounceArm(previous, state)) return;
|
|
989
|
+
const mode = armModeOf(state, NC_ALARM_MODES);
|
|
990
|
+
if (mode === null) return;
|
|
991
|
+
try {
|
|
992
|
+
hook(mode);
|
|
993
|
+
} catch (err) {
|
|
994
|
+
this.ctx.logger.warn("alarm arm announcement failed", {
|
|
995
|
+
tags: { deviceId: this.id },
|
|
996
|
+
meta: {
|
|
997
|
+
mode,
|
|
998
|
+
error: String(err)
|
|
999
|
+
}
|
|
1000
|
+
});
|
|
1001
|
+
}
|
|
1002
|
+
}
|
|
1003
|
+
/** The three durations, read from the persisted config. */
|
|
1004
|
+
readDelays() {
|
|
1005
|
+
return {
|
|
1006
|
+
exitDelaySec: this.config.get("exitDelaySec"),
|
|
1007
|
+
entryDelaySec: this.config.get("entryDelaySec"),
|
|
1008
|
+
triggeredDurationSec: this.config.get("triggeredDurationSec")
|
|
1009
|
+
};
|
|
1010
|
+
}
|
|
574
1011
|
startTicking() {
|
|
575
1012
|
if (this.ticker !== null) return;
|
|
576
1013
|
this.ticker = setInterval(() => this.publish(), TICK_MS);
|
|
@@ -587,17 +1024,9 @@ var NcAlarmPanelDevice = class extends BaseDevice {
|
|
|
587
1024
|
this.config.set("machine", { ...this.machine });
|
|
588
1025
|
}
|
|
589
1026
|
restore() {
|
|
590
|
-
const
|
|
591
|
-
if (
|
|
592
|
-
|
|
593
|
-
if (typeof m.target !== "string" || typeof m.changedAt !== "number") return;
|
|
594
|
-
this.machine = {
|
|
595
|
-
target: m.target,
|
|
596
|
-
...typeof m.armedAt === "number" ? { armedAt: m.armedAt } : {},
|
|
597
|
-
...typeof m.triggerAt === "number" ? { triggerAt: m.triggerAt } : {},
|
|
598
|
-
fired: m.fired === true,
|
|
599
|
-
changedAt: m.changedAt
|
|
600
|
-
};
|
|
1027
|
+
const restored = machineFromPersisted(this.config.get("machine"));
|
|
1028
|
+
if (restored === null) return;
|
|
1029
|
+
this.machine = restored;
|
|
601
1030
|
}
|
|
602
1031
|
};
|
|
603
1032
|
//#endregion
|
|
@@ -4865,14 +5294,21 @@ function readSensorEventType(value) {
|
|
|
4865
5294
|
const eventType = last["eventType"];
|
|
4866
5295
|
return typeof eventType === "string" && eventType.length > 0 ? eventType : void 0;
|
|
4867
5296
|
}
|
|
4868
|
-
/**
|
|
4869
|
-
*
|
|
5297
|
+
/**
|
|
5298
|
+
* Build the subject for a `device-event` evaluation (a persisted SensorEvent —
|
|
5299
|
+
* one row per linked camera; `deviceId` is the CAMERA).
|
|
5300
|
+
*
|
|
5301
|
+
* BOTH ids ride along. The camera is what the notification shows; the sensor is
|
|
5302
|
+
* what the operator named in the rule. Carrying only the camera is what made a
|
|
5303
|
+
* sensor-scoped rule unmatched — see {@link NcRuleSubject.sourceDeviceId}.
|
|
5304
|
+
*/
|
|
4870
5305
|
function subjectFromSensorEvent(ev) {
|
|
4871
5306
|
const eventType = readSensorEventType(ev.value);
|
|
4872
5307
|
return {
|
|
4873
5308
|
kind: "device-event",
|
|
4874
5309
|
recordId: ev.id,
|
|
4875
5310
|
deviceId: ev.deviceId,
|
|
5311
|
+
...ev.sourceDeviceId !== ev.deviceId ? { sourceDeviceId: ev.sourceDeviceId } : {},
|
|
4876
5312
|
timestamp: ev.timestamp,
|
|
4877
5313
|
classNames: [],
|
|
4878
5314
|
zones: [],
|
|
@@ -5104,7 +5540,7 @@ function evaluateRule(rule, subject, deviceState) {
|
|
|
5104
5540
|
if (current === void 0) return fail("deviceState");
|
|
5105
5541
|
if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
|
|
5106
5542
|
}
|
|
5107
|
-
if (c.devices !== void 0 && c.devices.length > 0 && !c.devices
|
|
5543
|
+
if (c.devices !== void 0 && c.devices.length > 0 && !matchesDeviceScope(c.devices, subject)) return fail("devices");
|
|
5108
5544
|
if (c.source !== void 0 && c.source !== "any") {
|
|
5109
5545
|
if ((subject.source ?? "pipeline") !== c.source) return fail("source");
|
|
5110
5546
|
}
|
|
@@ -5291,6 +5727,28 @@ function keysPerClass(rule, subject) {
|
|
|
5291
5727
|
return rule.throttle.granularity === "per-class";
|
|
5292
5728
|
}
|
|
5293
5729
|
/** Stable cooldown key per the rule's throttle scope + class granularity. */
|
|
5730
|
+
/**
|
|
5731
|
+
* Does the rule's device scope cover this subject?
|
|
5732
|
+
*
|
|
5733
|
+
* EITHER id matches, and both are needed for the same rule to be authorable
|
|
5734
|
+
* the two ways an operator thinks about it:
|
|
5735
|
+
*
|
|
5736
|
+
* - "when anything happens on the front-door CAMERA" → the camera id, which
|
|
5737
|
+
* is what a sensor row is attributed to;
|
|
5738
|
+
* - "when the front-door CONTACT opens" → the sensor id, which is the device
|
|
5739
|
+
* the operator actually cares about and the one they pick in the Devices
|
|
5740
|
+
* tab. Before this, that rule matched nothing, ever, silently.
|
|
5741
|
+
*
|
|
5742
|
+
* Consequence worth knowing: a sensor linked to N cameras persists N rows, so
|
|
5743
|
+
* a SENSOR-scoped rule is evaluated N times for one door opening. The rule's
|
|
5744
|
+
* own throttle is what collapses that — `scope: 'rule'` gives one
|
|
5745
|
+
* notification, `scope: 'device'` gives one per camera, which is the right
|
|
5746
|
+
* choice when each carries its own picture.
|
|
5747
|
+
*/
|
|
5748
|
+
function matchesDeviceScope(scope, subject) {
|
|
5749
|
+
if (scope.includes(subject.deviceId)) return true;
|
|
5750
|
+
return subject.sourceDeviceId !== void 0 && scope.includes(subject.sourceDeviceId);
|
|
5751
|
+
}
|
|
5294
5752
|
function cooldownKey(rule, subject) {
|
|
5295
5753
|
const first = subject.classNames[0];
|
|
5296
5754
|
const classKey = keysPerClass(rule, subject) && first !== void 0 ? `:c:${first}` : "";
|
|
@@ -5467,7 +5925,7 @@ var NcDispatcher = class {
|
|
|
5467
5925
|
return { ok: true };
|
|
5468
5926
|
}
|
|
5469
5927
|
}
|
|
5470
|
-
const notification = await this.buildNotification(entry);
|
|
5928
|
+
const notification = await this.buildNotification(entry, target);
|
|
5471
5929
|
try {
|
|
5472
5930
|
const result = await this.deps.send({
|
|
5473
5931
|
addonId: target.addonId,
|
|
@@ -5565,6 +6023,82 @@ var NcDispatcher = class {
|
|
|
5565
6023
|
return false;
|
|
5566
6024
|
}
|
|
5567
6025
|
}
|
|
6026
|
+
/**
|
|
6027
|
+
* Send a Notification-Center-composed message to ONE target.
|
|
6028
|
+
*
|
|
6029
|
+
* Never throws and never retries — see {@link NcAnnouncementSendInput}. Every
|
|
6030
|
+
* way of not delivering produces a line: an announcement that silently did
|
|
6031
|
+
* not arrive is indistinguishable from the alarm not having armed, which is
|
|
6032
|
+
* the exact confusion this feature exists to remove.
|
|
6033
|
+
*/
|
|
6034
|
+
async deliverAnnouncement(input) {
|
|
6035
|
+
const tags = input.deviceId !== void 0 ? { deviceId: input.deviceId } : void 0;
|
|
6036
|
+
let target;
|
|
6037
|
+
try {
|
|
6038
|
+
target = await this.resolveTarget(input.targetId);
|
|
6039
|
+
} catch (err) {
|
|
6040
|
+
this.deps.logger.warn("announcement: target catalog unreachable", {
|
|
6041
|
+
...tags !== void 0 ? { tags } : {},
|
|
6042
|
+
meta: {
|
|
6043
|
+
reason: input.reason,
|
|
6044
|
+
targetId: input.targetId,
|
|
6045
|
+
error: String(err)
|
|
6046
|
+
}
|
|
6047
|
+
});
|
|
6048
|
+
return false;
|
|
6049
|
+
}
|
|
6050
|
+
if (target === null || !target.enabled) {
|
|
6051
|
+
this.deps.logger.warn("announcement: target gone or disabled", {
|
|
6052
|
+
...tags !== void 0 ? { tags } : {},
|
|
6053
|
+
meta: {
|
|
6054
|
+
reason: input.reason,
|
|
6055
|
+
targetId: input.targetId
|
|
6056
|
+
}
|
|
6057
|
+
});
|
|
6058
|
+
return false;
|
|
6059
|
+
}
|
|
6060
|
+
try {
|
|
6061
|
+
const result = await this.deps.send({
|
|
6062
|
+
addonId: target.addonId,
|
|
6063
|
+
targetId: target.id,
|
|
6064
|
+
notification: {
|
|
6065
|
+
title: input.title,
|
|
6066
|
+
body: input.body,
|
|
6067
|
+
priority: 2,
|
|
6068
|
+
...input.deviceId !== void 0 ? { deviceId: input.deviceId } : {}
|
|
6069
|
+
}
|
|
6070
|
+
});
|
|
6071
|
+
if (!result.success) {
|
|
6072
|
+
this.deps.logger.warn("announcement send failed", {
|
|
6073
|
+
...tags !== void 0 ? { tags } : {},
|
|
6074
|
+
meta: {
|
|
6075
|
+
reason: input.reason,
|
|
6076
|
+
targetId: target.id,
|
|
6077
|
+
error: result.error
|
|
6078
|
+
}
|
|
6079
|
+
});
|
|
6080
|
+
return false;
|
|
6081
|
+
}
|
|
6082
|
+
this.deps.logger.info("announcement delivered", {
|
|
6083
|
+
...tags !== void 0 ? { tags } : {},
|
|
6084
|
+
meta: {
|
|
6085
|
+
reason: input.reason,
|
|
6086
|
+
target: target.name
|
|
6087
|
+
}
|
|
6088
|
+
});
|
|
6089
|
+
return true;
|
|
6090
|
+
} catch (err) {
|
|
6091
|
+
this.deps.logger.warn("announcement send threw", {
|
|
6092
|
+
...tags !== void 0 ? { tags } : {},
|
|
6093
|
+
meta: {
|
|
6094
|
+
reason: input.reason,
|
|
6095
|
+
targetId: target.id,
|
|
6096
|
+
error: String(err)
|
|
6097
|
+
}
|
|
6098
|
+
});
|
|
6099
|
+
return false;
|
|
6100
|
+
}
|
|
6101
|
+
}
|
|
5568
6102
|
/** Device display names for a set of ids, for the digest's lines. */
|
|
5569
6103
|
async resolveDeviceNames(deviceIds) {
|
|
5570
6104
|
const out = /* @__PURE__ */ new Map();
|
|
@@ -5597,7 +6131,37 @@ var NcDispatcher = class {
|
|
|
5597
6131
|
if (this.now() - this.targetCacheAt > this.targetCacheTtlMs) return null;
|
|
5598
6132
|
return this.targetCache.get(targetId) ?? null;
|
|
5599
6133
|
}
|
|
5600
|
-
|
|
6134
|
+
/**
|
|
6135
|
+
* This delivery's buttons, or none.
|
|
6136
|
+
*
|
|
6137
|
+
* Never throws. A notification that arrives without its buttons is a
|
|
6138
|
+
* degraded notification; one that does not arrive because the token registry
|
|
6139
|
+
* hiccupped is a missed event. The failure is logged rather than swallowed —
|
|
6140
|
+
* a button the operator authored and never saw is exactly the silence this
|
|
6141
|
+
* repo keeps paying for.
|
|
6142
|
+
*/
|
|
6143
|
+
async mintActions(entry, target) {
|
|
6144
|
+
if (this.deps.buildActions === void 0) return [];
|
|
6145
|
+
try {
|
|
6146
|
+
return await this.deps.buildActions({
|
|
6147
|
+
ruleId: entry.ruleId,
|
|
6148
|
+
ruleName: entry.payload.ruleName,
|
|
6149
|
+
deviceId: entry.deviceId,
|
|
6150
|
+
targetId: target.id
|
|
6151
|
+
});
|
|
6152
|
+
} catch (err) {
|
|
6153
|
+
this.deps.logger.warn("notification buttons could not be minted — sending without them", {
|
|
6154
|
+
tags: { deviceId: entry.deviceId },
|
|
6155
|
+
meta: {
|
|
6156
|
+
ruleId: entry.ruleId,
|
|
6157
|
+
targetId: target.id,
|
|
6158
|
+
error: String(err)
|
|
6159
|
+
}
|
|
6160
|
+
});
|
|
6161
|
+
return [];
|
|
6162
|
+
}
|
|
6163
|
+
}
|
|
6164
|
+
async buildNotification(entry, target) {
|
|
5601
6165
|
const subject = entry.payload.subject;
|
|
5602
6166
|
const deviceName = await this.deps.getDeviceName(subject.deviceId).catch(() => null) ?? `camera ${subject.deviceId}`;
|
|
5603
6167
|
const zoneLabels = await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
|
|
@@ -5606,6 +6170,7 @@ var NcDispatcher = class {
|
|
|
5606
6170
|
const body = renderTemplate(entry.payload.template?.body, vars) ?? defaultBody(entry, deviceName, zoneLabels);
|
|
5607
6171
|
const attachments = await this.withArtifactUrls(await this.resolveAttachments(entry));
|
|
5608
6172
|
const params = pickParams(entry.payload.params);
|
|
6173
|
+
const actions = await this.mintActions(entry, target);
|
|
5609
6174
|
return {
|
|
5610
6175
|
body,
|
|
5611
6176
|
title,
|
|
@@ -5614,7 +6179,8 @@ var NcDispatcher = class {
|
|
|
5614
6179
|
tag: entry.ruleId,
|
|
5615
6180
|
deviceId: subject.deviceId,
|
|
5616
6181
|
...subject.eventId !== void 0 ? { eventId: subject.eventId } : {},
|
|
5617
|
-
...attachments.length > 0 ? { attachments } : {}
|
|
6182
|
+
...attachments.length > 0 ? { attachments } : {},
|
|
6183
|
+
...actions.length > 0 ? { actions } : {}
|
|
5618
6184
|
};
|
|
5619
6185
|
}
|
|
5620
6186
|
/**
|
|
@@ -6732,6 +7298,60 @@ function rowToEntry$1(id, data) {
|
|
|
6732
7298
|
};
|
|
6733
7299
|
}
|
|
6734
7300
|
//#endregion
|
|
7301
|
+
//#region src/notification-center/action-buttons.ts
|
|
7302
|
+
/** Sequence names the rule actually declares. A button may only name one. */
|
|
7303
|
+
function declaredSequences(actions) {
|
|
7304
|
+
const names = /* @__PURE__ */ new Set();
|
|
7305
|
+
for (const sequence of actions.onTrigger ?? []) names.add(sequence.name);
|
|
7306
|
+
return names;
|
|
7307
|
+
}
|
|
7308
|
+
/**
|
|
7309
|
+
* Build the notification's buttons.
|
|
7310
|
+
*
|
|
7311
|
+
* Returns an empty array rather than `undefined` — the caller decides whether
|
|
7312
|
+
* to set the field, and an empty array is the honest answer to "which buttons
|
|
7313
|
+
* survived", which `undefined` would conflate with "the rule declared none".
|
|
7314
|
+
*/
|
|
7315
|
+
function buildActionButtons(input) {
|
|
7316
|
+
const actions = input.actions;
|
|
7317
|
+
if (actions === void 0) return [];
|
|
7318
|
+
const buttons = actions.buttons ?? [];
|
|
7319
|
+
if (buttons.length === 0) return [];
|
|
7320
|
+
const known = declaredSequences(actions);
|
|
7321
|
+
const seen = /* @__PURE__ */ new Set();
|
|
7322
|
+
const out = [];
|
|
7323
|
+
for (const button of buttons) {
|
|
7324
|
+
if (!known.has(button.sequence)) continue;
|
|
7325
|
+
if (seen.has(button.id)) continue;
|
|
7326
|
+
seen.add(button.id);
|
|
7327
|
+
out.push({
|
|
7328
|
+
id: button.id,
|
|
7329
|
+
label: button.label,
|
|
7330
|
+
url: input.mintUrl({
|
|
7331
|
+
sequence: button.sequence,
|
|
7332
|
+
actionId: button.id
|
|
7333
|
+
}),
|
|
7334
|
+
...button.icon !== void 0 ? { icon: button.icon } : {},
|
|
7335
|
+
...button.destructive !== void 0 ? { destructive: button.destructive } : {}
|
|
7336
|
+
});
|
|
7337
|
+
}
|
|
7338
|
+
return out;
|
|
7339
|
+
}
|
|
7340
|
+
/**
|
|
7341
|
+
* Which of a rule's buttons name a sequence it does not have.
|
|
7342
|
+
*
|
|
7343
|
+
* Exported so the caller can LOG the drop. A button silently missing from a
|
|
7344
|
+
* notification is the exact shape of failure this repo keeps paying for — the
|
|
7345
|
+
* operator authored it, it never appeared, and nothing said why.
|
|
7346
|
+
*/
|
|
7347
|
+
function unresolvableButtons(actions) {
|
|
7348
|
+
if (actions === void 0) return [];
|
|
7349
|
+
const buttons = actions.buttons ?? [];
|
|
7350
|
+
if (buttons.length === 0) return [];
|
|
7351
|
+
const known = declaredSequences(actions);
|
|
7352
|
+
return buttons.filter((b) => !known.has(b.sequence)).map((b) => `${b.id}→${b.sequence}`);
|
|
7353
|
+
}
|
|
7354
|
+
//#endregion
|
|
6735
7355
|
//#region src/notification-center/rule-actions.ts
|
|
6736
7356
|
var NcRuleActionRunner = class {
|
|
6737
7357
|
deps;
|
|
@@ -7810,6 +8430,44 @@ var TimelapseStore = class {
|
|
|
7810
8430
|
//#endregion
|
|
7811
8431
|
//#region src/notification-center/index.ts
|
|
7812
8432
|
/**
|
|
8433
|
+
* NotificationCenter — the P1 core of the Notification Center (spec
|
|
8434
|
+
* `2026-07-22-notification-center-requirements.md`, decisions D-1/D-2/D-3).
|
|
8435
|
+
*
|
|
8436
|
+
* A deps-injected, extractable module (TrackCloser pattern) hosted by
|
|
8437
|
+
* pipeline-analytics. It couples rule evaluation to the DURABLE persist
|
|
8438
|
+
* moments (D-2), never to the telemetry bus:
|
|
8439
|
+
*
|
|
8440
|
+
* object-event insert ─▶ onObjectEventPersisted ─▶ immediate rules
|
|
8441
|
+
* TrackCloser.closeExpired ─▶ onTrackClosed ─▶ track-end rules
|
|
8442
|
+
* │ match + throttle
|
|
8443
|
+
* ▼
|
|
8444
|
+
* NcOutbox (durable rows, unique key rule:track:target)
|
|
8445
|
+
* │ drain loop (backoff / dead-letter)
|
|
8446
|
+
* ▼
|
|
8447
|
+
* NcDispatcher ─▶ ctx.api notification-output.send (RPC, hub-routed)
|
|
8448
|
+
*
|
|
8449
|
+
* Crash gap: on start (evaluation mode) the module scans object events
|
|
8450
|
+
* newer than the persisted watermark and re-evaluates them — idempotent
|
|
8451
|
+
* through the outbox dedup key (D8: reconcile, never trust an event).
|
|
8452
|
+
*
|
|
8453
|
+
* Multi-node: rules live in the centralized settings-store; only the
|
|
8454
|
+
* designated post-processing node runs evaluation + the drain loop, so a
|
|
8455
|
+
* CRUD served elsewhere becomes effective within one rule-reload tick.
|
|
8456
|
+
*/
|
|
8457
|
+
/**
|
|
8458
|
+
* What `getAlarmConfig` answers on a node with no panel.
|
|
8459
|
+
*
|
|
8460
|
+
* The DEFAULTS, not zeroes: paired with `deviceId: null` this tells a client
|
|
8461
|
+
* "there is no panel here" while still describing the shape it would have.
|
|
8462
|
+
* Zeroes would render as "no entry delay", which is a claim about an alarm
|
|
8463
|
+
* that does not exist.
|
|
8464
|
+
*/
|
|
8465
|
+
var NC_ALARM_SETTINGS_FALLBACK = {
|
|
8466
|
+
...DEFAULT_DELAYS,
|
|
8467
|
+
announceArm: false,
|
|
8468
|
+
announceTargets: []
|
|
8469
|
+
};
|
|
8470
|
+
/**
|
|
7813
8471
|
* How often the "matched NO rule" report may fire per device. Long enough that
|
|
7814
8472
|
* a busy camera prints one line rather than one per event, short enough that a
|
|
7815
8473
|
* rule which has stopped matching is visible within minutes rather than by
|
|
@@ -7929,9 +8587,14 @@ var NotificationCenter = class NotificationCenter {
|
|
|
7929
8587
|
deviceStates;
|
|
7930
8588
|
/** Runs a matched rule's action sequences. Null when no actuator is wired. */
|
|
7931
8589
|
actionRunner;
|
|
8590
|
+
/** Grants behind the tap-through buttons. In-process and single-use — read
|
|
8591
|
+
* `action-token.ts` before assuming anything about what a tap proves. */
|
|
8592
|
+
actionTokens = new NcActionTokenRegistry();
|
|
7932
8593
|
/** True when ≥1 enabled `device-event` rule declares an occupancy condition —
|
|
7933
8594
|
* the watcher is idle (zero per-frame cost) otherwise. */
|
|
7934
8595
|
occupancyEnabled = false;
|
|
8596
|
+
/** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
|
|
8597
|
+
alarmPanel = null;
|
|
7935
8598
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
7936
8599
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
7937
8600
|
/** Per-device rate limit for the "matched NO rule" report — see `reportNoMatch`. */
|
|
@@ -7949,6 +8612,109 @@ var NotificationCenter = class NotificationCenter {
|
|
|
7949
8612
|
reloadTimer = null;
|
|
7950
8613
|
drainTicks = 0;
|
|
7951
8614
|
evaluationActive = false;
|
|
8615
|
+
/**
|
|
8616
|
+
* The tap-through buttons for ONE delivery, each with its own single-use
|
|
8617
|
+
* token.
|
|
8618
|
+
*
|
|
8619
|
+
* Per delivery, not per notification: the first tap spends the token, so two
|
|
8620
|
+
* recipients sharing one would mean the second person's button was already
|
|
8621
|
+
* dead when it arrived.
|
|
8622
|
+
*
|
|
8623
|
+
* Returns `[]` for every ordinary reason — the rule declares no buttons, no
|
|
8624
|
+
* actuator is wired, no reachable URL exists. A button is an enhancement; not
|
|
8625
|
+
* being able to mint one must never cost the notification.
|
|
8626
|
+
*/
|
|
8627
|
+
async mintButtons(input) {
|
|
8628
|
+
const mint = this.deps.mintActionUrl;
|
|
8629
|
+
if (mint === void 0 || this.actionRunner === null) return [];
|
|
8630
|
+
const rule = this.rules.get(input.ruleId);
|
|
8631
|
+
if (rule === null) return [];
|
|
8632
|
+
if ((rule.actions?.buttons ?? []).length === 0) return [];
|
|
8633
|
+
const expMs = this.now() + DEFAULT_ACTION_TTL_MS;
|
|
8634
|
+
const pending = [];
|
|
8635
|
+
const out = buildActionButtons({
|
|
8636
|
+
actions: rule.actions,
|
|
8637
|
+
mintUrl: ({ sequence, actionId }) => {
|
|
8638
|
+
const id = randomUUID();
|
|
8639
|
+
pending.push({
|
|
8640
|
+
id,
|
|
8641
|
+
url: "",
|
|
8642
|
+
sequence,
|
|
8643
|
+
actionId
|
|
8644
|
+
});
|
|
8645
|
+
return id;
|
|
8646
|
+
}
|
|
8647
|
+
});
|
|
8648
|
+
if (out.length === 0) return [];
|
|
8649
|
+
const resolved = [];
|
|
8650
|
+
for (const [index, action] of out.entries()) {
|
|
8651
|
+
const slot = pending[index];
|
|
8652
|
+
if (slot === void 0) continue;
|
|
8653
|
+
const url = await mint({
|
|
8654
|
+
id: slot.id,
|
|
8655
|
+
expMs
|
|
8656
|
+
});
|
|
8657
|
+
if (url === null) {
|
|
8658
|
+
this.logger.debug("no reachable base URL — notification buttons omitted", {
|
|
8659
|
+
tags: { deviceId: input.deviceId },
|
|
8660
|
+
meta: { ruleId: input.ruleId }
|
|
8661
|
+
});
|
|
8662
|
+
return [];
|
|
8663
|
+
}
|
|
8664
|
+
this.actionTokens.put({
|
|
8665
|
+
id: slot.id,
|
|
8666
|
+
ruleId: input.ruleId,
|
|
8667
|
+
ruleName: input.ruleName,
|
|
8668
|
+
sequence: slot.sequence,
|
|
8669
|
+
deviceId: input.deviceId,
|
|
8670
|
+
actionId: slot.actionId,
|
|
8671
|
+
targetId: input.targetId,
|
|
8672
|
+
expiresAt: expMs
|
|
8673
|
+
});
|
|
8674
|
+
resolved.push({
|
|
8675
|
+
...action,
|
|
8676
|
+
url
|
|
8677
|
+
});
|
|
8678
|
+
}
|
|
8679
|
+
return resolved;
|
|
8680
|
+
}
|
|
8681
|
+
/** The registry the action plane claims against. */
|
|
8682
|
+
get actionTokenRegistry() {
|
|
8683
|
+
return this.actionTokens;
|
|
8684
|
+
}
|
|
8685
|
+
/**
|
|
8686
|
+
* Run the sequence a spent token granted.
|
|
8687
|
+
*
|
|
8688
|
+
* **The grant overrides `enabled`, deliberately.** A sequence reachable only
|
|
8689
|
+
* by a button is written as `enabled: false` — that is how "do not run this
|
|
8690
|
+
* automatically" is expressed — and the runner skips a disabled sequence. For
|
|
8691
|
+
* a granted run the BUTTON is the enablement, and the operator tapping it is
|
|
8692
|
+
* a more explicit instruction than the flag it overrides. The per-sequence
|
|
8693
|
+
* throttle still applies: `minDelaySec` is about how often a gate may
|
|
8694
|
+
* physically move, and a tap does not change that.
|
|
8695
|
+
*
|
|
8696
|
+
* Throws when the sequence is gone or a step failed — the plane turns that
|
|
8697
|
+
* into the page that says so.
|
|
8698
|
+
*/
|
|
8699
|
+
async runGrantedAction(grant) {
|
|
8700
|
+
if (this.actionRunner === null) throw new Error("no actuator is wired on this node");
|
|
8701
|
+
const rule = this.rules.get(grant.ruleId);
|
|
8702
|
+
if (rule === null) throw new Error(`rule ${grant.ruleId} no longer exists`);
|
|
8703
|
+
const sequence = (rule.actions?.onTrigger ?? []).find((s) => s.name === grant.sequence);
|
|
8704
|
+
if (sequence === void 0) throw new Error(`sequence "${grant.sequence}" no longer exists on rule ${rule.name}`);
|
|
8705
|
+
const [outcome] = await this.actionRunner.run({
|
|
8706
|
+
ruleId: rule.id,
|
|
8707
|
+
ruleName: rule.name,
|
|
8708
|
+
deviceId: grant.deviceId,
|
|
8709
|
+
sequences: [{
|
|
8710
|
+
...sequence,
|
|
8711
|
+
enabled: true
|
|
8712
|
+
}]
|
|
8713
|
+
});
|
|
8714
|
+
if (outcome === void 0) throw new Error("the sequence did not run");
|
|
8715
|
+
if (outcome.failedAt !== void 0) throw new Error(`step ${String(outcome.failedAt)} of "${sequence.name}" failed`);
|
|
8716
|
+
if (!outcome.ran) throw new Error(`"${sequence.name}" did not run (${outcome.skippedReason ?? "unknown"})`);
|
|
8717
|
+
}
|
|
7952
8718
|
constructor(deps) {
|
|
7953
8719
|
this.deps = deps;
|
|
7954
8720
|
this.logger = deps.logger;
|
|
@@ -8259,9 +9025,105 @@ var NotificationCenter = class NotificationCenter {
|
|
|
8259
9025
|
cancelSnooze: async ({ snoozeId, caller }) => {
|
|
8260
9026
|
await this.cancelSnooze(snoozeId, caller);
|
|
8261
9027
|
return { success: true };
|
|
9028
|
+
},
|
|
9029
|
+
getAlarmConfig: async () => this.alarmConfig(),
|
|
9030
|
+
setAlarmConfig: async ({ patch }) => {
|
|
9031
|
+
const panel = this.alarmPanel;
|
|
9032
|
+
if (panel === null) throw new Error("this node has no alarm panel");
|
|
9033
|
+
const settings = await panel.applySettings(patch);
|
|
9034
|
+
this.logger.info("alarm settings changed", {
|
|
9035
|
+
tags: { deviceId: panel.deviceId },
|
|
9036
|
+
meta: {
|
|
9037
|
+
exitDelaySec: settings.exitDelaySec,
|
|
9038
|
+
entryDelaySec: settings.entryDelaySec,
|
|
9039
|
+
triggeredDurationSec: settings.triggeredDurationSec,
|
|
9040
|
+
announceArm: settings.announceArm,
|
|
9041
|
+
announceTargets: settings.announceTargets.length
|
|
9042
|
+
}
|
|
9043
|
+
});
|
|
9044
|
+
return this.alarmConfig();
|
|
8262
9045
|
}
|
|
8263
9046
|
};
|
|
8264
9047
|
}
|
|
9048
|
+
/**
|
|
9049
|
+
* Adopt the panel this node owns. Called once by the addon after
|
|
9050
|
+
* `ensureAlarmPanel`; a node without one never calls it and the cap then
|
|
9051
|
+
* answers `deviceId: null`.
|
|
9052
|
+
*/
|
|
9053
|
+
setAlarmPanel(panel) {
|
|
9054
|
+
this.alarmPanel = panel;
|
|
9055
|
+
}
|
|
9056
|
+
/** Settings + the DERIVED coverage. Read together — see the cap doc. */
|
|
9057
|
+
alarmConfig() {
|
|
9058
|
+
const panel = this.alarmPanel;
|
|
9059
|
+
if (panel === null) return {
|
|
9060
|
+
deviceId: null,
|
|
9061
|
+
settings: NC_ALARM_SETTINGS_FALLBACK,
|
|
9062
|
+
coverage: []
|
|
9063
|
+
};
|
|
9064
|
+
return {
|
|
9065
|
+
deviceId: panel.deviceId,
|
|
9066
|
+
settings: panel.settings(),
|
|
9067
|
+
coverage: this.coverageFor(panel).map((c) => ({
|
|
9068
|
+
...c,
|
|
9069
|
+
deviceIds: [...c.deviceIds]
|
|
9070
|
+
}))
|
|
9071
|
+
};
|
|
9072
|
+
}
|
|
9073
|
+
coverageFor(panel) {
|
|
9074
|
+
return alarmModeCoverage(this.rules.list(), panel.deviceId, panel.availableModes());
|
|
9075
|
+
}
|
|
9076
|
+
/**
|
|
9077
|
+
* Say which devices a mode just armed.
|
|
9078
|
+
*
|
|
9079
|
+
* Called by the panel on the transition INTO an armed mode. Fire-and-forget
|
|
9080
|
+
* and never able to fail the panel: `announceArm` returns void, and every
|
|
9081
|
+
* branch that sends nothing logs why — a mode that arms nothing is the most
|
|
9082
|
+
* important thing this can report, and it is exactly the case where no
|
|
9083
|
+
* message goes out.
|
|
9084
|
+
*/
|
|
9085
|
+
announceArm(mode) {
|
|
9086
|
+
this.deliverArmAnnouncement(mode).catch((err) => {
|
|
9087
|
+
this.logger.warn("alarm arm announcement failed", { meta: {
|
|
9088
|
+
mode,
|
|
9089
|
+
error: String(err)
|
|
9090
|
+
} });
|
|
9091
|
+
});
|
|
9092
|
+
}
|
|
9093
|
+
async deliverArmAnnouncement(mode) {
|
|
9094
|
+
const panel = this.alarmPanel;
|
|
9095
|
+
if (panel === null) return;
|
|
9096
|
+
const settings = panel.settings();
|
|
9097
|
+
const tags = { deviceId: panel.deviceId };
|
|
9098
|
+
if (!settings.announceArm) return;
|
|
9099
|
+
if (settings.announceTargets.length === 0) {
|
|
9100
|
+
this.logger.info("alarm armed but no announcement target is configured", {
|
|
9101
|
+
tags,
|
|
9102
|
+
meta: { mode }
|
|
9103
|
+
});
|
|
9104
|
+
return;
|
|
9105
|
+
}
|
|
9106
|
+
const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
|
|
9107
|
+
if (coverage === void 0) return;
|
|
9108
|
+
const message = buildArmAnnouncement(coverage, await this.dispatcher.resolveDeviceNames(coverage.deviceIds));
|
|
9109
|
+
if (message === null) {
|
|
9110
|
+
this.logger.warn("alarm armed into a mode no enabled rule is gated on", {
|
|
9111
|
+
tags,
|
|
9112
|
+
meta: {
|
|
9113
|
+
mode,
|
|
9114
|
+
state: `armed_${mode}`
|
|
9115
|
+
}
|
|
9116
|
+
});
|
|
9117
|
+
return;
|
|
9118
|
+
}
|
|
9119
|
+
for (const targetId of settings.announceTargets) await this.dispatcher.deliverAnnouncement({
|
|
9120
|
+
reason: "alarm-arm",
|
|
9121
|
+
targetId,
|
|
9122
|
+
title: message.title,
|
|
9123
|
+
body: message.body,
|
|
9124
|
+
deviceId: panel.deviceId
|
|
9125
|
+
});
|
|
9126
|
+
}
|
|
8265
9127
|
/** Append one evaluation to the serialized chain (see {@link evalChain}). */
|
|
8266
9128
|
scheduleEvaluation(subject, kind, logContext) {
|
|
8267
9129
|
this.evalChain = this.evalChain.then(async () => {
|
|
@@ -8349,6 +9211,15 @@ var NotificationCenter = class NotificationCenter {
|
|
|
8349
9211
|
deviceId: subject.deviceId,
|
|
8350
9212
|
sequences
|
|
8351
9213
|
});
|
|
9214
|
+
const unresolvable = unresolvableButtons(rule.actions);
|
|
9215
|
+
if (unresolvable.length > 0) this.logger.warn("rule buttons reference sequences that do not exist — not sent", {
|
|
9216
|
+
tags: { deviceId: subject.deviceId },
|
|
9217
|
+
meta: {
|
|
9218
|
+
ruleId: rule.id,
|
|
9219
|
+
rule: rule.name,
|
|
9220
|
+
buttons: unresolvable.join(", ")
|
|
9221
|
+
}
|
|
9222
|
+
});
|
|
8352
9223
|
const userTargets = await this.resolveUserTargets(rule, subject.deviceId);
|
|
8353
9224
|
if (await this.outbox.enqueue(this.buildEntries(rule, subject, kind, evaluation.matchedOn, userTargets)) > 0) this.lastFiredAt.set(key, now);
|
|
8354
9225
|
anyMatched = true;
|
|
@@ -22046,6 +22917,95 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22046
22917
|
}
|
|
22047
22918
|
}
|
|
22048
22919
|
/**
|
|
22920
|
+
* The ranked base-URL candidates a notification LINK may use.
|
|
22921
|
+
*
|
|
22922
|
+
* Shared by the artifact plane (media) and the action plane (buttons) — one
|
|
22923
|
+
* resolver, because a second copy would drift and the two would start
|
|
22924
|
+
* minting links on different hosts for the same notification.
|
|
22925
|
+
*/
|
|
22926
|
+
async ncEndpoints() {
|
|
22927
|
+
return collectArtifactEndpoints({
|
|
22928
|
+
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
22929
|
+
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
22930
|
+
getConnected: async () => {
|
|
22931
|
+
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
22932
|
+
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
22933
|
+
connected: status.connected,
|
|
22934
|
+
url: status.endpoint?.url ?? null,
|
|
22935
|
+
protocol: status.endpoint?.protocol ?? null
|
|
22936
|
+
} });
|
|
22937
|
+
return status.connected && status.endpoint !== null ? {
|
|
22938
|
+
url: status.endpoint.url,
|
|
22939
|
+
protocol: status.endpoint.protocol
|
|
22940
|
+
} : null;
|
|
22941
|
+
},
|
|
22942
|
+
listExternal: async () => {
|
|
22943
|
+
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
22944
|
+
url: e.url,
|
|
22945
|
+
protocol: e.protocol
|
|
22946
|
+
}));
|
|
22947
|
+
},
|
|
22948
|
+
listLan: async (port) => {
|
|
22949
|
+
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
22950
|
+
baseUrl: e.baseUrl,
|
|
22951
|
+
kind: e.kind,
|
|
22952
|
+
priority: e.priority
|
|
22953
|
+
}));
|
|
22954
|
+
},
|
|
22955
|
+
logger: this.ctx.logger
|
|
22956
|
+
});
|
|
22957
|
+
}
|
|
22958
|
+
/**
|
|
22959
|
+
* Serve the NC ACTION plane: the public route a notification button lands on.
|
|
22960
|
+
*
|
|
22961
|
+
* A sibling of the artifact plane and a strictly more dangerous one — that
|
|
22962
|
+
* route serves bytes, this one MOVES something. Its authority is the token in
|
|
22963
|
+
* the link and nothing else, which is the operator's explicit choice; the
|
|
22964
|
+
* bounds are in `notification-center/action-token.ts` and D47.
|
|
22965
|
+
*
|
|
22966
|
+
* Best-effort like every plane here: no facility ⇒ no route, and the
|
|
22967
|
+
* dispatcher simply mints no buttons, because `mintActionUrl` returns null.
|
|
22968
|
+
*/
|
|
22969
|
+
async serveNcActionPlane(center) {
|
|
22970
|
+
try {
|
|
22971
|
+
const secretState = this.state("ncActionSecret", NcArtifactSecretSchema, "");
|
|
22972
|
+
let secret = await secretState.get();
|
|
22973
|
+
if (secret === "") {
|
|
22974
|
+
secret = randomUUID().replace(/-/g, "");
|
|
22975
|
+
await secretState.set(secret);
|
|
22976
|
+
}
|
|
22977
|
+
const routePrefix = `/addon/${this.ctx.id}/nc-action`;
|
|
22978
|
+
const plane = new NcActionPlane({
|
|
22979
|
+
registry: center.actionTokenRegistry,
|
|
22980
|
+
secret,
|
|
22981
|
+
logger: this.ctx.logger.child("nc-actions"),
|
|
22982
|
+
run: (grant) => center.runGrantedAction(grant)
|
|
22983
|
+
});
|
|
22984
|
+
const served = await this.ctx.dataPlane?.serve({
|
|
22985
|
+
prefix: "nc-action",
|
|
22986
|
+
access: "public",
|
|
22987
|
+
handler: plane.handler
|
|
22988
|
+
}) ?? null;
|
|
22989
|
+
this.ncActionMintUrl = served === null ? null : async ({ id, expMs }) => {
|
|
22990
|
+
const baseUrl = pickArtifactBaseUrl(await this.ncEndpoints());
|
|
22991
|
+
if (baseUrl === null) return null;
|
|
22992
|
+
return buildActionUrl({
|
|
22993
|
+
baseUrl,
|
|
22994
|
+
routePrefix,
|
|
22995
|
+
id,
|
|
22996
|
+
secret,
|
|
22997
|
+
expMs
|
|
22998
|
+
});
|
|
22999
|
+
};
|
|
23000
|
+
this.ctx.logger.info("nc-action data-plane served", { meta: { served: served !== null } });
|
|
23001
|
+
} catch (err) {
|
|
23002
|
+
this.ctx.logger.warn("nc-action data-plane failed to serve", { meta: { error: errMsg(err) } });
|
|
23003
|
+
}
|
|
23004
|
+
}
|
|
23005
|
+
/** Mints a button's callback URL once the action plane is served; null until
|
|
23006
|
+
* then, and null forever on an install with no reachable public address. */
|
|
23007
|
+
ncActionMintUrl = null;
|
|
23008
|
+
/**
|
|
22049
23009
|
* Serve the NC artifact plane: a PUBLIC route whose authority is the HMAC on
|
|
22050
23010
|
* each link (see `notification-center/artifact-url.ts`). It exists because
|
|
22051
23011
|
* attachments used to carry BYTES only, and the degrade engine drops a
|
|
@@ -22073,36 +23033,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22073
23033
|
secret,
|
|
22074
23034
|
logger: this.ctx.logger.child("nc-artifacts"),
|
|
22075
23035
|
routePrefix: `/addon/${this.ctx.id}/nc-artifact`,
|
|
22076
|
-
listEndpoints:
|
|
22077
|
-
markedBaseUrl: await this.markedNotificationEndpoint(),
|
|
22078
|
-
configuredPublicUrl: process.env["CAMSTACK_HUB_PUBLIC_URL"],
|
|
22079
|
-
getConnected: async () => {
|
|
22080
|
-
const status = await this.ctx.api.networkAccess.getStatus.query();
|
|
22081
|
-
this.ctx.logger.debug("artifact base-url: connected ingress", { meta: {
|
|
22082
|
-
connected: status.connected,
|
|
22083
|
-
url: status.endpoint?.url ?? null,
|
|
22084
|
-
protocol: status.endpoint?.protocol ?? null
|
|
22085
|
-
} });
|
|
22086
|
-
return status.connected && status.endpoint !== null ? {
|
|
22087
|
-
url: status.endpoint.url,
|
|
22088
|
-
protocol: status.endpoint.protocol
|
|
22089
|
-
} : null;
|
|
22090
|
-
},
|
|
22091
|
-
listExternal: async () => {
|
|
22092
|
-
return (await this.ctx.api.networkAccess.listEndpoints.query()).map((e) => ({
|
|
22093
|
-
url: e.url,
|
|
22094
|
-
protocol: e.protocol
|
|
22095
|
-
}));
|
|
22096
|
-
},
|
|
22097
|
-
listLan: async (port) => {
|
|
22098
|
-
return (await this.ctx.api.localNetwork.getConnectionEndpoints.query({ port })).endpoints.map((e) => ({
|
|
22099
|
-
baseUrl: e.baseUrl,
|
|
22100
|
-
kind: e.kind,
|
|
22101
|
-
priority: e.priority
|
|
22102
|
-
}));
|
|
22103
|
-
},
|
|
22104
|
-
logger: this.ctx.logger
|
|
22105
|
-
})
|
|
23036
|
+
listEndpoints: () => this.ncEndpoints()
|
|
22106
23037
|
});
|
|
22107
23038
|
this.ncArtifactDataPlane = await this.ctx.dataPlane?.serve({
|
|
22108
23039
|
prefix: "nc-artifact",
|
|
@@ -22171,6 +23102,17 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22171
23102
|
* `notification-rules` provider (CRUD over the central store) is
|
|
22172
23103
|
* registered from every node. */
|
|
22173
23104
|
notificationCenter = null;
|
|
23105
|
+
/**
|
|
23106
|
+
* The live alarm panel on THIS node, or null.
|
|
23107
|
+
*
|
|
23108
|
+
* Held, where the earlier note said not to. The objection then was a field
|
|
23109
|
+
* nothing reads growing into a second source of truth for which device the
|
|
23110
|
+
* alarm is — and it stands: the panel is still addressed by stable id
|
|
23111
|
+
* everywhere else. This reference exists because two things now READ it, and
|
|
23112
|
+
* neither can go through the device id: the settings tab, which needs the
|
|
23113
|
+
* panel's own config, and the arm announcement, which the panel pushes.
|
|
23114
|
+
*/
|
|
23115
|
+
alarmPanelDevice = null;
|
|
22174
23116
|
/** THE single owner of capture pressure (S3, refactor spec 2026-07-22):
|
|
22175
23117
|
* every native-fetch call site routed so far (the per-frame batch media
|
|
22176
23118
|
* dispatch + `persistKeyFrames`; part B routes the rest) goes through ONE
|
|
@@ -22461,14 +23403,18 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22461
23403
|
return found === void 0 ? null : { id: found.id };
|
|
22462
23404
|
},
|
|
22463
23405
|
createDevice: async ({ stableId, integrationId, name }) => {
|
|
22464
|
-
|
|
23406
|
+
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, {
|
|
22465
23407
|
type: NC_ALARM_DEVICE_TYPE,
|
|
22466
23408
|
name,
|
|
22467
23409
|
integrationId
|
|
22468
|
-
})
|
|
23410
|
+
});
|
|
23411
|
+
this.holdAlarmPanel(device);
|
|
23412
|
+
return { id: device.id };
|
|
22469
23413
|
},
|
|
22470
23414
|
adoptDevice: async ({ stableId }) => {
|
|
22471
|
-
|
|
23415
|
+
const device = await devices.create(stableId, NC_ALARM_DEVICE_CLASS, {}, null, void 0);
|
|
23416
|
+
this.holdAlarmPanel(device);
|
|
23417
|
+
return { id: device.id };
|
|
22472
23418
|
}
|
|
22473
23419
|
});
|
|
22474
23420
|
if (result.created) this.ctx.logger.info("notification-center alarm panel ready", { tags: { deviceId: result.deviceId } });
|
|
@@ -22476,6 +23422,41 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22476
23422
|
this.ctx.logger.warn("alarm panel could not be ensured — rules still notify", { meta: { error: err instanceof Error ? err.message : String(err) } });
|
|
22477
23423
|
}
|
|
22478
23424
|
}
|
|
23425
|
+
/**
|
|
23426
|
+
* Keep the constructed panel, if that is what came back.
|
|
23427
|
+
*
|
|
23428
|
+
* An `instanceof` narrowing rather than a cast: `devices.create` promises an
|
|
23429
|
+
* `IDevice`, and the only honest way to know it is the alarm is to ask. A
|
|
23430
|
+
* cast here would compile against any future device class and fail at the
|
|
23431
|
+
* first `settings()` call, in production, with the tab already open.
|
|
23432
|
+
*/
|
|
23433
|
+
holdAlarmPanel(device) {
|
|
23434
|
+
if (!(device instanceof NcAlarmPanelDevice)) {
|
|
23435
|
+
this.ctx.logger.warn("alarm panel is not the expected device class — settings tab disabled");
|
|
23436
|
+
return;
|
|
23437
|
+
}
|
|
23438
|
+
this.alarmPanelDevice = device;
|
|
23439
|
+
}
|
|
23440
|
+
/**
|
|
23441
|
+
* Join the panel and the Notification Center, once both exist.
|
|
23442
|
+
*
|
|
23443
|
+
* They are built in that order (`ensureAlarmPanel` runs before the centre is
|
|
23444
|
+
* constructed) and neither can reach the other on its own, so the join is
|
|
23445
|
+
* here. A node with no panel simply never calls it, and the cap answers
|
|
23446
|
+
* `deviceId: null` — which is what an agent node should say.
|
|
23447
|
+
*/
|
|
23448
|
+
wireAlarmPanel(center) {
|
|
23449
|
+
const panel = this.alarmPanelDevice;
|
|
23450
|
+
if (panel === null) return;
|
|
23451
|
+
center.setAlarmPanel({
|
|
23452
|
+
deviceId: panel.id,
|
|
23453
|
+
settings: () => panel.settings(),
|
|
23454
|
+
applySettings: (patch) => panel.applySettings(patch),
|
|
23455
|
+
availableModes: () => NC_ALARM_MODES
|
|
23456
|
+
});
|
|
23457
|
+
panel.setArmedHook((mode) => center.announceArm(mode));
|
|
23458
|
+
this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
|
|
23459
|
+
}
|
|
22479
23460
|
async declareCollections(api) {
|
|
22480
23461
|
await TrackStore.declare(api.settingsStore);
|
|
22481
23462
|
await MediaStore.declare(api.settingsStore);
|
|
@@ -22870,6 +23851,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22870
23851
|
});
|
|
22871
23852
|
},
|
|
22872
23853
|
isCapDeviceScoped: isDeviceScopedCap,
|
|
23854
|
+
mintActionUrl: (input) => this.ncActionMintUrl?.(input) ?? Promise.resolve(null),
|
|
22873
23855
|
readDeviceStates: async (ids) => {
|
|
22874
23856
|
const out = /* @__PURE__ */ new Map();
|
|
22875
23857
|
for (const id of ids) try {
|
|
@@ -22931,18 +23913,20 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22931
23913
|
});
|
|
22932
23914
|
},
|
|
22933
23915
|
send: async (input) => {
|
|
22934
|
-
const { attachments, ...notification } = input.notification;
|
|
23916
|
+
const { attachments, actions, ...notification } = input.notification;
|
|
22935
23917
|
return api.notificationOutput.send.mutate({
|
|
22936
23918
|
addonId: input.addonId,
|
|
22937
23919
|
targetId: input.targetId,
|
|
22938
23920
|
notification: {
|
|
22939
23921
|
...notification,
|
|
22940
|
-
...attachments !== void 0 ? { attachments: [...attachments] } : {}
|
|
23922
|
+
...attachments !== void 0 ? { attachments: [...attachments] } : {},
|
|
23923
|
+
...actions !== void 0 ? { actions: [...actions] } : {}
|
|
22941
23924
|
}
|
|
22942
23925
|
});
|
|
22943
23926
|
},
|
|
22944
23927
|
getMediaForOwner: (ownerKind, ownerId) => stores.mediaStore.listByOwner(ownerKind, ownerId),
|
|
22945
|
-
getDeviceName
|
|
23928
|
+
getDeviceName,
|
|
23929
|
+
buildActions: (input) => this.notificationCenter?.mintButtons(input) ?? Promise.resolve([])
|
|
22946
23930
|
},
|
|
22947
23931
|
listObjectEventsSince: (since, limit) => stores.eventStore.queryObjectSince({
|
|
22948
23932
|
since,
|
|
@@ -22958,6 +23942,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
|
|
|
22958
23942
|
})).tracks;
|
|
22959
23943
|
}
|
|
22960
23944
|
});
|
|
23945
|
+
this.wireAlarmPanel(this.notificationCenter);
|
|
23946
|
+
await this.serveNcActionPlane(this.notificationCenter);
|
|
22961
23947
|
await this.notificationCenter.start({ evaluation: this.isPostProcessingNode });
|
|
22962
23948
|
}
|
|
22963
23949
|
/** Event-media data-plane: serve thumbnail JPEGs at
|