@camstack/addon-post-analysis 1.2.275 → 1.2.276
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-D3Yub96m.mjs → dist-58Om1e5O.mjs} +69 -19
- package/dist/{dist-Br0p61E2.js → dist-BZaBVEUL.js} +98 -18
- package/dist/embedding-encoder/index.js +1 -1
- package/dist/embedding-encoder/index.mjs +1 -1
- package/dist/pipeline-analytics/{_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Byz9Hzo0.mjs → _virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CXIsZS8q.mjs} +2 -2
- package/dist/pipeline-analytics/{hostInit-Dza3GRpt.mjs → hostInit-DlwUheDb.mjs} +2 -2
- package/dist/pipeline-analytics/index.js +2521 -916
- package/dist/pipeline-analytics/index.mjs +2521 -916
- package/dist/pipeline-analytics/remoteEntry.js +1 -1
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
|
|
|
2
2
|
__esModule: { value: true },
|
|
3
3
|
[Symbol.toStringTag]: { value: "Module" }
|
|
4
4
|
});
|
|
5
|
-
const require_dist = require("../dist-
|
|
5
|
+
const require_dist = require("../dist-BZaBVEUL.js");
|
|
6
6
|
let node_fs = require("node:fs");
|
|
7
7
|
node_fs = require_dist.__toESM(node_fs, 1);
|
|
8
8
|
let node_path = require("node:path");
|
|
@@ -233,6 +233,434 @@ var MotionEpisodeTracker = class {
|
|
|
233
233
|
}
|
|
234
234
|
};
|
|
235
235
|
//#endregion
|
|
236
|
+
//#region src/notification-center/alarm/alarm-mode-coverage.ts
|
|
237
|
+
/**
|
|
238
|
+
* Whether the panel just BECAME armed, in the sense a person means.
|
|
239
|
+
*
|
|
240
|
+
* Two transitions reach an `armed_*` state and only one of them is an arm:
|
|
241
|
+
*
|
|
242
|
+
* - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
|
|
243
|
+
* - `triggered` → `armed_away` is the siren's duration ending and the panel
|
|
244
|
+
* re-arming itself. It is told too (D630), but as `alarm-rearmed`, never as
|
|
245
|
+
* an arm: "Away armed" in the middle of a break-in would read as somebody
|
|
246
|
+
* arming it. `alarmTransition` routes it there before asking this.
|
|
247
|
+
*
|
|
248
|
+
* `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
|
|
249
|
+
* set of devices covered just changed with it — which is the whole content of
|
|
250
|
+
* the message.
|
|
251
|
+
*
|
|
252
|
+
* A null `previous` is the FIRST publish and never announces. A hub restarting
|
|
253
|
+
* while armed re-publishes `armed_away`, and nobody armed anything — every
|
|
254
|
+
* deploy would otherwise send one. Handled here rather than by the caller
|
|
255
|
+
* happening to wire the hook late, because "it works because of the order two
|
|
256
|
+
* unrelated things run in" is how this stops working.
|
|
257
|
+
*/
|
|
258
|
+
function shouldAnnounceArm(previous, next) {
|
|
259
|
+
if (previous === null) return false;
|
|
260
|
+
if (!next.startsWith("armed_")) return false;
|
|
261
|
+
if (previous === next) return false;
|
|
262
|
+
if (previous === "triggered") return false;
|
|
263
|
+
return true;
|
|
264
|
+
}
|
|
265
|
+
/** The mode inside an `armed_<mode>` state, or null for any other state. */
|
|
266
|
+
function armModeOf(state, modes) {
|
|
267
|
+
for (const mode of modes) if (state === armedStateFor(mode)) return mode;
|
|
268
|
+
return null;
|
|
269
|
+
}
|
|
270
|
+
/** The `deviceState` word a rule must gate on for `mode` to cover it. */
|
|
271
|
+
function armedStateFor(mode) {
|
|
272
|
+
return `armed_${mode}`;
|
|
273
|
+
}
|
|
274
|
+
/**
|
|
275
|
+
* One entry per mode the panel offers, including modes nothing is gated on.
|
|
276
|
+
*
|
|
277
|
+
* Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
|
|
278
|
+
* most useful thing this can tell an operator, and a list that omits it looks
|
|
279
|
+
* identical to a list where night is covered.
|
|
280
|
+
*/
|
|
281
|
+
function alarmModeCoverage(rules, panelDeviceId, modes, skipProbe) {
|
|
282
|
+
return modes.map((mode) => coverageFor(rules, panelDeviceId, mode, skipProbe));
|
|
283
|
+
}
|
|
284
|
+
function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
285
|
+
const wanted = armedStateFor(mode);
|
|
286
|
+
const ids = /* @__PURE__ */ new Set();
|
|
287
|
+
let ruleCount = 0;
|
|
288
|
+
let allDevices = false;
|
|
289
|
+
for (const rule of rules) {
|
|
290
|
+
if (!rule.enabled) continue;
|
|
291
|
+
const gate = rule.conditions.deviceState;
|
|
292
|
+
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
293
|
+
if (!gate.states.includes(wanted)) continue;
|
|
294
|
+
ruleCount += 1;
|
|
295
|
+
const scope = rule.conditions.devices;
|
|
296
|
+
if (scope === void 0 || scope.length === 0) {
|
|
297
|
+
allDevices = true;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
for (const id of scope) ids.add(id);
|
|
301
|
+
}
|
|
302
|
+
const deviceIds = [...ids].toSorted((a, b) => a - b);
|
|
303
|
+
return {
|
|
304
|
+
mode,
|
|
305
|
+
ruleCount,
|
|
306
|
+
allDevices,
|
|
307
|
+
deviceIds,
|
|
308
|
+
skippedDevices: skippedFor(deviceIds, skipProbe)
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
function skippedFor(deviceIds, skipProbe) {
|
|
312
|
+
if (skipProbe === void 0) return [];
|
|
313
|
+
const out = [];
|
|
314
|
+
for (const deviceId of deviceIds) {
|
|
315
|
+
const reason = skipProbe(deviceId);
|
|
316
|
+
if (reason !== void 0) out.push({
|
|
317
|
+
deviceId,
|
|
318
|
+
reason
|
|
319
|
+
});
|
|
320
|
+
}
|
|
321
|
+
return out;
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Whether this mode arms anything, and if not, which way it fails.
|
|
325
|
+
*
|
|
326
|
+
* An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
|
|
327
|
+
* ids a rule wrote down, so it cannot answer for a mode that covers every
|
|
328
|
+
* camera on the hub, and reporting "all excluded" from a subset would be a
|
|
329
|
+
* warning about protection that exists.
|
|
330
|
+
*/
|
|
331
|
+
function alarmCoverageGap(coverage) {
|
|
332
|
+
if (coverage.ruleCount === 0) return "uncovered";
|
|
333
|
+
if (coverage.allDevices) return null;
|
|
334
|
+
if (coverage.deviceIds.length === 0) return null;
|
|
335
|
+
if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
|
|
336
|
+
return "all-skipped";
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* "Attivi: Cancello, Garage — Esclusi: Porta retro."
|
|
340
|
+
*
|
|
341
|
+
* A LIST, not a sentence. The mode is in the title and the body is one
|
|
342
|
+
* `Label: names` line per device group, because this is read on a lock screen
|
|
343
|
+
* and the question is always "which one" — the prose version ("3 dispositivi
|
|
344
|
+
* attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
|
|
345
|
+
* window. Each variant still owns its own catalog key, so the Texts tab can
|
|
346
|
+
* re-word "nothing is armed" without touching "these are armed".
|
|
347
|
+
*
|
|
348
|
+
* Names, not ids: this is read by a person standing at a door. An id that has
|
|
349
|
+
* no name falls back to a NAMED, translated placeholder rather than being
|
|
350
|
+
* dropped — a silently shorter list would understate what is armed, which is
|
|
351
|
+
* the one error this message must not make. (It used to hard-code the English
|
|
352
|
+
* `Device <id>` into both languages.)
|
|
353
|
+
*
|
|
354
|
+
* **Always a sentence.** It used to return null for a mode no rule is gated on,
|
|
355
|
+
* on the grounds that "Night armed" followed by an unwatched night is a false
|
|
356
|
+
* assurance — true, but the caller then emitted `alarm-armed` anyway with a
|
|
357
|
+
* fallback body key that existed in NEITHER locale, so the operator received a
|
|
358
|
+
* title and an empty line. Silence is not the third option here: SAYING that
|
|
359
|
+
* the mode arms nothing is.
|
|
360
|
+
*/
|
|
361
|
+
function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
362
|
+
const rules = `${coverage.ruleCount}`;
|
|
363
|
+
const mode = coverage.mode;
|
|
364
|
+
if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
|
|
365
|
+
const gap = alarmCoverageGap(coverage);
|
|
366
|
+
if (gap === "uncovered") return {
|
|
367
|
+
bodyKey: "alarm.arm.body.uncovered",
|
|
368
|
+
textParams: { mode },
|
|
369
|
+
textCount: 0
|
|
370
|
+
};
|
|
371
|
+
if (gap === "all-skipped") {
|
|
372
|
+
const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
|
|
373
|
+
return {
|
|
374
|
+
bodyKey: "alarm.arm.body.allSkipped",
|
|
375
|
+
textParams: {
|
|
376
|
+
rules,
|
|
377
|
+
mode,
|
|
378
|
+
devices: `${names.length}`,
|
|
379
|
+
names: names.join(", ")
|
|
380
|
+
},
|
|
381
|
+
textCount: names.length
|
|
382
|
+
};
|
|
383
|
+
}
|
|
384
|
+
if (coverage.allDevices) return {
|
|
385
|
+
bodyKey: "alarm.arm.body.allDevices",
|
|
386
|
+
textParams: {
|
|
387
|
+
rules,
|
|
388
|
+
mode
|
|
389
|
+
},
|
|
390
|
+
textCount: coverage.ruleCount
|
|
391
|
+
};
|
|
392
|
+
if (coverage.deviceIds.length === 0) return {
|
|
393
|
+
bodyKey: "alarm.arm.body.noDevices",
|
|
394
|
+
textParams: {
|
|
395
|
+
rules,
|
|
396
|
+
mode
|
|
397
|
+
},
|
|
398
|
+
textCount: coverage.ruleCount
|
|
399
|
+
};
|
|
400
|
+
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
401
|
+
const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
|
|
402
|
+
if (skipped.size === 0) return {
|
|
403
|
+
bodyKey: "alarm.arm.body.devices",
|
|
404
|
+
textParams: {
|
|
405
|
+
rules,
|
|
406
|
+
mode,
|
|
407
|
+
devices: `${names.length}`,
|
|
408
|
+
names: names.join(", ")
|
|
409
|
+
},
|
|
410
|
+
textCount: names.length
|
|
411
|
+
};
|
|
412
|
+
return {
|
|
413
|
+
bodyKey: "alarm.arm.body.devicesSkipped",
|
|
414
|
+
textParams: {
|
|
415
|
+
rules,
|
|
416
|
+
mode,
|
|
417
|
+
devices: `${names.length}`,
|
|
418
|
+
names: names.join(", "),
|
|
419
|
+
skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
|
|
420
|
+
},
|
|
421
|
+
textCount: names.length
|
|
422
|
+
};
|
|
423
|
+
}
|
|
424
|
+
/**
|
|
425
|
+
* "Attivi: 2 — Bypassati: Finestra veranda."
|
|
426
|
+
*
|
|
427
|
+
* The bypassed sensors are named and the armed ones are COUNTED, not listed:
|
|
428
|
+
* this sentence's subject is the exception, and burying it under thirteen
|
|
429
|
+
* camera names is how an operator stops reading the arm notification. The
|
|
430
|
+
* count pluralises on what IS armed, like every other arm variant.
|
|
431
|
+
*/
|
|
432
|
+
function armedWithBypass(coverage, bypassed, deviceName) {
|
|
433
|
+
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
434
|
+
const excluded = new Set(bypassed);
|
|
435
|
+
const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
|
|
436
|
+
return {
|
|
437
|
+
bodyKey: "alarm.arm.body.bypassed",
|
|
438
|
+
textParams: {
|
|
439
|
+
rules: `${coverage.ruleCount}`,
|
|
440
|
+
mode: coverage.mode,
|
|
441
|
+
devices: `${armed}`,
|
|
442
|
+
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
443
|
+
},
|
|
444
|
+
textCount: armed
|
|
445
|
+
};
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* "Aperti: Finestra veranda, Porta retro" + where the Arm anyway is.
|
|
449
|
+
*
|
|
450
|
+
* The one alarm sentence about something that did not happen, so it has to
|
|
451
|
+
* carry both halves an operator needs to act: WHICH devices are open, and —
|
|
452
|
+
* when the notification carries the button — that its Arm anyway arms without
|
|
453
|
+
* them (D630: pressing arm again is refused again; there is no second-press
|
|
454
|
+
* bypass any more, so the sentence never says "repeat within N seconds").
|
|
455
|
+
* Naming them is the whole message, so the names are the FIRST line.
|
|
456
|
+
*/
|
|
457
|
+
function buildArmRefusedAnnouncement(mode, names, withArmAnyway) {
|
|
458
|
+
return {
|
|
459
|
+
bodyKey: withArmAnyway ? "alarm.armRefused.body.hint" : "alarm.armRefused.body",
|
|
460
|
+
textParams: {
|
|
461
|
+
mode,
|
|
462
|
+
devices: `${names.length}`,
|
|
463
|
+
names: names.join(", ")
|
|
464
|
+
},
|
|
465
|
+
textCount: names.length
|
|
466
|
+
};
|
|
467
|
+
}
|
|
468
|
+
/**
|
|
469
|
+
* The EXCEPTIONS to the same coverage, said 30 seconds earlier.
|
|
470
|
+
*
|
|
471
|
+
* The exit-delay sentence is deliberately SHORTER than the arm one — it is read
|
|
472
|
+
* on the way out of a door — and it carries exactly one thing the arm sentence
|
|
473
|
+
* cannot: the warning arrives while the operator can still disarm and fix it.
|
|
474
|
+
* A mode that arms nothing is worth interrupting somebody for at second 0 and
|
|
475
|
+
* merely worth recording at second 30.
|
|
476
|
+
*
|
|
477
|
+
* **It never repeats the device list.** The countdown lives in the TITLE
|
|
478
|
+
* (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
|
|
479
|
+
* list arrives thirty seconds later in the arm announcement — printing it
|
|
480
|
+
* twice is the verbosity that made the operator stop reading both. So the body
|
|
481
|
+
* is what is WRONG: what was bypassed, what is excluded, or that nothing is.
|
|
482
|
+
*
|
|
483
|
+
* `seconds` stays in the params because the title asks for it, and `textCount`
|
|
484
|
+
* stays with it: a row enqueued before this deploy still pluralises on the
|
|
485
|
+
* countdown it was written for.
|
|
486
|
+
*/
|
|
487
|
+
function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
488
|
+
const textParams = {
|
|
489
|
+
mode: coverage.mode,
|
|
490
|
+
seconds: `${seconds}`
|
|
491
|
+
};
|
|
492
|
+
if (bypassed.length > 0) return {
|
|
493
|
+
bodyKey: "alarm.arming.body.bypassed",
|
|
494
|
+
textParams: {
|
|
495
|
+
...textParams,
|
|
496
|
+
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
497
|
+
},
|
|
498
|
+
textCount: seconds
|
|
499
|
+
};
|
|
500
|
+
const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
|
|
501
|
+
const gap = alarmCoverageGap(coverage);
|
|
502
|
+
if (gap === "uncovered") return {
|
|
503
|
+
bodyKey: "alarm.arming.body.uncovered",
|
|
504
|
+
textParams,
|
|
505
|
+
textCount: seconds
|
|
506
|
+
};
|
|
507
|
+
if (gap === "all-skipped") return {
|
|
508
|
+
bodyKey: "alarm.arming.body.allSkipped",
|
|
509
|
+
textParams: {
|
|
510
|
+
...textParams,
|
|
511
|
+
names: skippedNames
|
|
512
|
+
},
|
|
513
|
+
textCount: seconds
|
|
514
|
+
};
|
|
515
|
+
if (coverage.skippedDevices.length > 0) return {
|
|
516
|
+
bodyKey: "alarm.arming.body.skipped",
|
|
517
|
+
textParams: {
|
|
518
|
+
...textParams,
|
|
519
|
+
names: skippedNames
|
|
520
|
+
},
|
|
521
|
+
textCount: coverage.skippedDevices.length
|
|
522
|
+
};
|
|
523
|
+
return {
|
|
524
|
+
bodyKey: "alarm.arming.body",
|
|
525
|
+
textParams,
|
|
526
|
+
textCount: seconds
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
//#endregion
|
|
530
|
+
//#region src/notification-center/alarm/alarm-arm-guard.ts
|
|
531
|
+
/**
|
|
532
|
+
* How long an Arm anyway stays pressable — the panel's refusal and the
|
|
533
|
+
* button's grant TTL (`action-token.ts`) use this one constant. It lives here,
|
|
534
|
+
* with the arm decision, so the token module does not import the device.
|
|
535
|
+
*/
|
|
536
|
+
var ARM_ANYWAY_TTL_MS = 5 * 6e4;
|
|
537
|
+
/**
|
|
538
|
+
* One covered device that reads OPEN, with the name a person will read and the
|
|
539
|
+
* word its cap reported (`open`, `stopped`, `unlocked`, …) — not only contacts:
|
|
540
|
+
* a cover or a lock that is not secure is an opening too (D630).
|
|
541
|
+
*/
|
|
542
|
+
var NcAlarmOpenDeviceSchema = require_dist.object({
|
|
543
|
+
deviceId: require_dist.number().int(),
|
|
544
|
+
name: require_dist.string(),
|
|
545
|
+
state: require_dist.string()
|
|
546
|
+
}).readonly();
|
|
547
|
+
/** Why an Arm anyway was not honoured — see `NcAlarmPanelDevice.armAnyway`. */
|
|
548
|
+
var NcAlarmArmAnywayRejectionSchema = require_dist._enum([
|
|
549
|
+
"unknown-refusal",
|
|
550
|
+
"stale",
|
|
551
|
+
"expired",
|
|
552
|
+
"used"
|
|
553
|
+
]);
|
|
554
|
+
/**
|
|
555
|
+
* What one arm attempt — plain or Arm anyway — came to.
|
|
556
|
+
*
|
|
557
|
+
* A schema, and it lives HERE, in the addon, because it is the response of the
|
|
558
|
+
* `nc.armAlarm` / `nc.armAlarmAnyway` BRIDGE actions: the bridge validates
|
|
559
|
+
* against the addon's own copy, so a refusal can travel as a result (its id and
|
|
560
|
+
* the names of what is open) and the viewer can offer the Arm anyway bound to
|
|
561
|
+
* it. The cap cannot: `alarm-panel.arm` answers `z.void()` on the HOST's router,
|
|
562
|
+
* which is why the cap throws {@link NcAlarmArmRefusedError} instead.
|
|
563
|
+
*/
|
|
564
|
+
var NcAlarmArmOutcomeSchema = require_dist.discriminatedUnion("outcome", [
|
|
565
|
+
require_dist.object({
|
|
566
|
+
outcome: require_dist._enum(["armed", "arming"]),
|
|
567
|
+
excluded: require_dist.array(NcAlarmOpenDeviceSchema).readonly()
|
|
568
|
+
}).readonly(),
|
|
569
|
+
require_dist.object({
|
|
570
|
+
outcome: require_dist.literal("refused"),
|
|
571
|
+
refusalId: require_dist.string(),
|
|
572
|
+
blocking: require_dist.array(NcAlarmOpenDeviceSchema).readonly()
|
|
573
|
+
}).readonly(),
|
|
574
|
+
require_dist.object({
|
|
575
|
+
outcome: require_dist.literal("rejected"),
|
|
576
|
+
reason: NcAlarmArmAnywayRejectionSchema
|
|
577
|
+
}).readonly()
|
|
578
|
+
]);
|
|
579
|
+
/**
|
|
580
|
+
* The devices a mode covers, ascending and de-duplicated.
|
|
581
|
+
*
|
|
582
|
+
* A rule qualifies when it is enabled and gated on THIS panel in THIS mode;
|
|
583
|
+
* every device it names is covered, whatever its kind. A camera it names is
|
|
584
|
+
* covered too, and is simply never an opening (`alarmOpeningFrom` answers
|
|
585
|
+
* `not-opening` for it, and the arm check skips it without a word).
|
|
586
|
+
*
|
|
587
|
+
* A rule with NO device scope contributes nothing: it covers every device on
|
|
588
|
+
* the hub, and blocking on a set the operator never wrote down produces a
|
|
589
|
+
* refusal naming devices they never associated with the alarm. The caller says
|
|
590
|
+
* so in a log line rather than inventing the list.
|
|
591
|
+
*/
|
|
592
|
+
function alarmCoveredDevices(rules, panelDeviceId, mode) {
|
|
593
|
+
const wanted = armedStateFor(mode);
|
|
594
|
+
const ids = /* @__PURE__ */ new Set();
|
|
595
|
+
for (const rule of rules) {
|
|
596
|
+
if (!rule.enabled) continue;
|
|
597
|
+
const gate = rule.conditions.deviceState;
|
|
598
|
+
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
599
|
+
if (!gate.states.includes(wanted)) continue;
|
|
600
|
+
const scope = rule.conditions.devices;
|
|
601
|
+
if (scope === void 0 || scope.length === 0) continue;
|
|
602
|
+
for (const id of scope) ids.add(id);
|
|
603
|
+
}
|
|
604
|
+
return [...ids].toSorted((a, b) => a - b);
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* Refuse or arm — the whole decision, as one partition of the open devices.
|
|
608
|
+
*
|
|
609
|
+
* A device may be open at arm time for exactly two reasons: the mode TOLERATES
|
|
610
|
+
* it (its non-blocking list), or the operator was shown it in a refusal and
|
|
611
|
+
* pressed that refusal's Arm anyway (`acknowledged`). Anything else open
|
|
612
|
+
* refuses — including a device that opened AFTER the refusal the operator is
|
|
613
|
+
* confirming: they were never shown it, and excluding it would arm the alarm
|
|
614
|
+
* around an opening nobody agreed to.
|
|
615
|
+
*
|
|
616
|
+
* There is no memory here. A plain arm pressed twice is the same question
|
|
617
|
+
* twice and gets the same answer; the only way past a refusal is the explicit
|
|
618
|
+
* Arm anyway, whose lifecycle (one-shot, stale, expired) the panel owns.
|
|
619
|
+
*
|
|
620
|
+
* Excluded is EVERY open device — tolerated and acknowledged alike — and the
|
|
621
|
+
* exclusion is two-phase (`alarm-exclusions.ts`): each one is watched again
|
|
622
|
+
* once it has closed and opened.
|
|
623
|
+
*/
|
|
624
|
+
function decideArm(input) {
|
|
625
|
+
const tolerated = new Set([...input.nonBlocking, ...input.acknowledged ?? []]);
|
|
626
|
+
const blocking = input.open.filter((d) => !tolerated.has(d.deviceId));
|
|
627
|
+
if (blocking.length > 0) return {
|
|
628
|
+
kind: "refused",
|
|
629
|
+
blocking
|
|
630
|
+
};
|
|
631
|
+
return {
|
|
632
|
+
kind: "arm",
|
|
633
|
+
excluded: input.open
|
|
634
|
+
};
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* The error `alarmPanel.arm` throws when it refuses.
|
|
638
|
+
*
|
|
639
|
+
* A THROW and not a return value, and the reason is a train: the hub's
|
|
640
|
+
* generated router validates this method's response against the HOST's copy of
|
|
641
|
+
* the cap, which says `z.void()`. An addon that answered a structured refusal
|
|
642
|
+
* would have it rejected by the router in front of it — so the refusal reaches
|
|
643
|
+
* the caller as the one channel that already works across that boundary, and
|
|
644
|
+
* the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
|
|
645
|
+
* ordinary outbox, with its own Arm anyway) rather than a UI error being the
|
|
646
|
+
* only trace.
|
|
647
|
+
*
|
|
648
|
+
* The message is deliberately actionable and English (an exception message is
|
|
649
|
+
* a developer/API surface; the notification is the operator's).
|
|
650
|
+
*/
|
|
651
|
+
var NcAlarmArmRefusedError = class extends Error {
|
|
652
|
+
code = "alarm-arm-refused";
|
|
653
|
+
mode;
|
|
654
|
+
blocking;
|
|
655
|
+
constructor(input) {
|
|
656
|
+
const names = input.blocking.map((d) => d.name).join(", ");
|
|
657
|
+
super(`arm refused — open: ${names}. Use "Arm anyway" to arm with them excluded.`);
|
|
658
|
+
this.name = "NcAlarmArmRefusedError";
|
|
659
|
+
this.mode = input.mode;
|
|
660
|
+
this.blocking = input.blocking;
|
|
661
|
+
}
|
|
662
|
+
};
|
|
663
|
+
//#endregion
|
|
236
664
|
//#region src/notification-center/action-token.ts
|
|
237
665
|
/**
|
|
238
666
|
* The authority behind a notification button.
|
|
@@ -263,6 +691,18 @@ var MotionEpisodeTracker = class {
|
|
|
263
691
|
* accepted: a deploy kills the buttons on notifications already delivered,
|
|
264
692
|
* which with a short TTL were nearly dead anyway.
|
|
265
693
|
*/
|
|
694
|
+
/**
|
|
695
|
+
* A granted action that ran and was REFUSED for a reason the person holding
|
|
696
|
+
* the phone can act on — "the alarm changed since this notification". Its
|
|
697
|
+
* message is shown on the page the tap lands on; any other throw stays the
|
|
698
|
+
* generic "it did not work".
|
|
699
|
+
*/
|
|
700
|
+
var NcActionRefusedError = class extends Error {
|
|
701
|
+
constructor(message) {
|
|
702
|
+
super(message);
|
|
703
|
+
this.name = "NcActionRefusedError";
|
|
704
|
+
}
|
|
705
|
+
};
|
|
266
706
|
/** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
|
|
267
707
|
* the artifact plane's, deliberately: one signing convention in this addon. */
|
|
268
708
|
function signActionToken(secret, id, expMs) {
|
|
@@ -328,7 +768,9 @@ var ALARM_DISARM_TTL_MS = 5 * 6e4;
|
|
|
328
768
|
function actionTtlMs(subject) {
|
|
329
769
|
switch (subject.kind) {
|
|
330
770
|
case "snooze": return SNOOZE_ACTION_TTL_MS;
|
|
331
|
-
case "alarm":
|
|
771
|
+
case "alarm":
|
|
772
|
+
if (subject.action === "arm-anyway") return ARM_ANYWAY_TTL_MS;
|
|
773
|
+
return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
|
|
332
774
|
case "sequence": return DEFAULT_ACTION_TTL_MS;
|
|
333
775
|
case "addon-update": return DEFAULT_ACTION_TTL_MS;
|
|
334
776
|
case "server-update": return DEFAULT_ACTION_TTL_MS;
|
|
@@ -410,7 +852,9 @@ function describeGrant(grant) {
|
|
|
410
852
|
switch (grant.kind) {
|
|
411
853
|
case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
|
|
412
854
|
case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
|
|
413
|
-
case "alarm":
|
|
855
|
+
case "alarm":
|
|
856
|
+
if (grant.action === "arm-anyway") return "Alarm armed, around the open devices";
|
|
857
|
+
return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
|
|
414
858
|
case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
|
|
415
859
|
case "server-update": return `Server update on ${grant.nodeId}`;
|
|
416
860
|
}
|
|
@@ -437,7 +881,12 @@ function grantMeta(grant) {
|
|
|
437
881
|
minutes: grant.minutes,
|
|
438
882
|
classes: grant.classes ?? null
|
|
439
883
|
};
|
|
440
|
-
case "alarm": return {
|
|
884
|
+
case "alarm": return grant.action === "arm-anyway" ? {
|
|
885
|
+
...common,
|
|
886
|
+
alarm: grant.action,
|
|
887
|
+
refusalId: grant.refusalId,
|
|
888
|
+
panelDeviceId: grant.panelDeviceId
|
|
889
|
+
} : {
|
|
441
890
|
...common,
|
|
442
891
|
alarm: grant.action,
|
|
443
892
|
mode: grant.mode ?? null,
|
|
@@ -507,6 +956,18 @@ var NcActionPlane = class {
|
|
|
507
956
|
});
|
|
508
957
|
this.reply(res, background, 200, "Done", describeGrant(grant));
|
|
509
958
|
} catch (err) {
|
|
959
|
+
if (err instanceof NcActionRefusedError) {
|
|
960
|
+
this.deps.logger.info("nc action refused after the token was spent", {
|
|
961
|
+
tags: { deviceId: grant.deviceId },
|
|
962
|
+
meta: {
|
|
963
|
+
kind: grant.kind,
|
|
964
|
+
...grantMeta(grant),
|
|
965
|
+
reason: err.message
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
this.reply(res, background, 200, "Nothing was done", err.message);
|
|
969
|
+
return;
|
|
970
|
+
}
|
|
510
971
|
this.deps.logger.warn("nc action failed after the token was spent", {
|
|
511
972
|
tags: { deviceId: grant.deviceId },
|
|
512
973
|
meta: {
|
|
@@ -536,6 +997,334 @@ var NcActionPlane = class {
|
|
|
536
997
|
}
|
|
537
998
|
};
|
|
538
999
|
//#endregion
|
|
1000
|
+
//#region src/notification-center/alarm/alarm-openings.ts
|
|
1001
|
+
/**
|
|
1002
|
+
* What "open" means to the alarm — D630's table, in one place.
|
|
1003
|
+
*
|
|
1004
|
+
* Read from the RAW runtime slices, not from the collapsed `deviceState`
|
|
1005
|
+
* word: the collapse is first-match over every cap, so it cannot say whether a
|
|
1006
|
+
* device is an opening at all, and a camera and an unreadable window would both
|
|
1007
|
+
* come back `undefined`.
|
|
1008
|
+
*
|
|
1009
|
+
* Three answers, never two (D49 — an unreadable state is unknown, never closed
|
|
1010
|
+
* and never open):
|
|
1011
|
+
*
|
|
1012
|
+
* - `not-opening` — no opening slice at all: a camera named by an intrusion
|
|
1013
|
+
* rule, a switch. It takes no part in the arm decision.
|
|
1014
|
+
* - `unknown` — an opening cap's slice IS there, but its field is missing or
|
|
1015
|
+
* is not a word that cap defines, or the slice is a provider's PLACEHOLDER
|
|
1016
|
+
* (`lastChangedAt === 0`: written by a constructor before anybody read the
|
|
1017
|
+
* device — an HA lock's `unlocked`, an HA cover's `closed`, a binary
|
|
1018
|
+
* sensor's `entryOpen: false`). It carries the cap and the raw value (for a
|
|
1019
|
+
* placeholder, the whole slice) so the caller's log line can say what it
|
|
1020
|
+
* saw. It never blocks and is never armed as secure on a guess — and a
|
|
1021
|
+
* placeholder is never taken for a state, so neither the reconcile tick,
|
|
1022
|
+
* the arm check nor an exclusion lift acts on one.
|
|
1023
|
+
* - `reading` — open or secure, with the state word a person will read.
|
|
1024
|
+
* ANY non-zero `lastChangedAt` is a reading, however old — including `1`,
|
|
1025
|
+
* the stamp a provider writes on a value it REPLAYED after a respawn with
|
|
1026
|
+
* no upstream change time (Homematic `HM_SEEDED_CHANGED_AT`): the value is
|
|
1027
|
+
* known, only its change time is not. The transition gate
|
|
1028
|
+
* (`sensor-transition-gate.ts`, "A placeholder slice is not a state") reads
|
|
1029
|
+
* the same stamp as "not a fresh event"; `0` alone means "not read yet".
|
|
1030
|
+
*
|
|
1031
|
+
* A read that FAILED never reaches {@link alarmOpeningFrom}: the caller leaves
|
|
1032
|
+
* that id out of its answer (see {@link readAlarmOpenings}).
|
|
1033
|
+
*
|
|
1034
|
+
* Anything short of fully secure is open: a cover stopped half-way, a lock that
|
|
1035
|
+
* jammed, a cover still closing when the arm is decided.
|
|
1036
|
+
*
|
|
1037
|
+
* PRECEDENCE, when one device carries more than one opening slice: `contact`
|
|
1038
|
+
* first, then `cover`, then `lock-control`. The contact is the physical truth
|
|
1039
|
+
* about the opening — a reed switch on the frame says the window is open even
|
|
1040
|
+
* when the motor's last reported word says `closed`. A slice that cannot be
|
|
1041
|
+
* read falls through to the next; only when none of them reads is the device
|
|
1042
|
+
* `unknown`, naming the first unreadable cap.
|
|
1043
|
+
*
|
|
1044
|
+
* The vocabulary of each word is its cap's own schema (`CoverStateSchema`,
|
|
1045
|
+
* `LockStateSchema`) — a word the cap does not define is not guessed at.
|
|
1046
|
+
*/
|
|
1047
|
+
/** The one state word, per cap, that counts as secure. Everything else is open. */
|
|
1048
|
+
var COVER_SECURE = "closed";
|
|
1049
|
+
var LOCK_SECURE = "locked";
|
|
1050
|
+
var NOT_OPENING = { kind: "not-opening" };
|
|
1051
|
+
function sliceOf(runtime, cap) {
|
|
1052
|
+
const slice = runtime[cap];
|
|
1053
|
+
if (slice === void 0) return void 0;
|
|
1054
|
+
return slice !== null && typeof slice === "object" ? slice : null;
|
|
1055
|
+
}
|
|
1056
|
+
function fieldOf(slice, field) {
|
|
1057
|
+
if (slice === null) return void 0;
|
|
1058
|
+
return Object.entries(slice).find(([key]) => key === field)?.[1];
|
|
1059
|
+
}
|
|
1060
|
+
/** A provider's cold placeholder: nobody has read the device yet (D49 ⇒ unknown). */
|
|
1061
|
+
function isPlaceholder(slice) {
|
|
1062
|
+
return fieldOf(slice, "lastChangedAt") === 0;
|
|
1063
|
+
}
|
|
1064
|
+
function reading(word, secure) {
|
|
1065
|
+
return {
|
|
1066
|
+
kind: "reading",
|
|
1067
|
+
reading: word === secure ? "secure" : "open",
|
|
1068
|
+
state: word
|
|
1069
|
+
};
|
|
1070
|
+
}
|
|
1071
|
+
function readContact(runtime) {
|
|
1072
|
+
const slice = sliceOf(runtime, "contact");
|
|
1073
|
+
if (slice === void 0) return { kind: "absent" };
|
|
1074
|
+
if (isPlaceholder(slice)) return {
|
|
1075
|
+
kind: "unknown",
|
|
1076
|
+
cap: "contact",
|
|
1077
|
+
raw: slice
|
|
1078
|
+
};
|
|
1079
|
+
const raw = fieldOf(slice, "entryOpen");
|
|
1080
|
+
if (typeof raw !== "boolean") return {
|
|
1081
|
+
kind: "unknown",
|
|
1082
|
+
cap: "contact",
|
|
1083
|
+
raw
|
|
1084
|
+
};
|
|
1085
|
+
return raw ? {
|
|
1086
|
+
kind: "reading",
|
|
1087
|
+
reading: "open",
|
|
1088
|
+
state: "open"
|
|
1089
|
+
} : {
|
|
1090
|
+
kind: "reading",
|
|
1091
|
+
reading: "secure",
|
|
1092
|
+
state: "closed"
|
|
1093
|
+
};
|
|
1094
|
+
}
|
|
1095
|
+
function readCover(runtime) {
|
|
1096
|
+
const slice = sliceOf(runtime, "cover");
|
|
1097
|
+
if (slice === void 0) return { kind: "absent" };
|
|
1098
|
+
if (isPlaceholder(slice)) return {
|
|
1099
|
+
kind: "unknown",
|
|
1100
|
+
cap: "cover",
|
|
1101
|
+
raw: slice
|
|
1102
|
+
};
|
|
1103
|
+
const raw = fieldOf(slice, "state");
|
|
1104
|
+
const parsed = require_dist.CoverStateSchema.safeParse(raw);
|
|
1105
|
+
return parsed.success ? reading(parsed.data, COVER_SECURE) : {
|
|
1106
|
+
kind: "unknown",
|
|
1107
|
+
cap: "cover",
|
|
1108
|
+
raw
|
|
1109
|
+
};
|
|
1110
|
+
}
|
|
1111
|
+
function readLock(runtime) {
|
|
1112
|
+
const slice = sliceOf(runtime, "lock-control");
|
|
1113
|
+
if (slice === void 0) return { kind: "absent" };
|
|
1114
|
+
if (isPlaceholder(slice)) return {
|
|
1115
|
+
kind: "unknown",
|
|
1116
|
+
cap: "lock-control",
|
|
1117
|
+
raw: slice
|
|
1118
|
+
};
|
|
1119
|
+
const raw = fieldOf(slice, "state");
|
|
1120
|
+
const parsed = require_dist.LockStateSchema.safeParse(raw);
|
|
1121
|
+
return parsed.success ? reading(parsed.data, LOCK_SECURE) : {
|
|
1122
|
+
kind: "unknown",
|
|
1123
|
+
cap: "lock-control",
|
|
1124
|
+
raw
|
|
1125
|
+
};
|
|
1126
|
+
}
|
|
1127
|
+
/** D630's table over one device's runtime slices — see the header for the three answers. */
|
|
1128
|
+
function alarmOpeningFrom(runtime) {
|
|
1129
|
+
let firstUnknown = null;
|
|
1130
|
+
for (const read of [
|
|
1131
|
+
readContact(runtime),
|
|
1132
|
+
readCover(runtime),
|
|
1133
|
+
readLock(runtime)
|
|
1134
|
+
]) {
|
|
1135
|
+
if (read.kind === "reading") return read;
|
|
1136
|
+
if (read.kind === "unknown" && firstUnknown === null) firstUnknown = read;
|
|
1137
|
+
}
|
|
1138
|
+
return firstUnknown ?? NOT_OPENING;
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* The sensor-event kind (`CAP_TO_KIND` in `@camstack/types`) → the cap whose
|
|
1142
|
+
* slice the row's `value` is. Only the opening kinds; every other kind is not
|
|
1143
|
+
* an opening.
|
|
1144
|
+
*/
|
|
1145
|
+
var OPENING_CAP_BY_EVENT_KIND = {
|
|
1146
|
+
contact: "contact",
|
|
1147
|
+
cover: "cover",
|
|
1148
|
+
lock: "lock-control"
|
|
1149
|
+
};
|
|
1150
|
+
/**
|
|
1151
|
+
* The same table, read off one sensor-event row: its `value` IS the slice of
|
|
1152
|
+
* the cap its `kind` names, so the event path and the reconcile read answer in
|
|
1153
|
+
* one vocabulary — a cover is secure only when `closed`, a lock only when
|
|
1154
|
+
* `locked`, whichever path saw it.
|
|
1155
|
+
*/
|
|
1156
|
+
function alarmOpeningFromSensorEvent(kind, value) {
|
|
1157
|
+
const cap = OPENING_CAP_BY_EVENT_KIND[kind];
|
|
1158
|
+
if (cap === void 0) return NOT_OPENING;
|
|
1159
|
+
return alarmOpeningFrom({ [cap]: value });
|
|
1160
|
+
}
|
|
1161
|
+
function classifyCoveredOpening(opening) {
|
|
1162
|
+
if (opening === void 0) return {
|
|
1163
|
+
kind: "unknown",
|
|
1164
|
+
why: "read-failed"
|
|
1165
|
+
};
|
|
1166
|
+
if (opening.kind === "unknown") return {
|
|
1167
|
+
kind: "unknown",
|
|
1168
|
+
why: "unknown-word",
|
|
1169
|
+
cap: opening.cap,
|
|
1170
|
+
raw: opening.raw
|
|
1171
|
+
};
|
|
1172
|
+
if (opening.kind === "not-opening" || opening.reading !== "open") return { kind: "skip" };
|
|
1173
|
+
return {
|
|
1174
|
+
kind: "open",
|
|
1175
|
+
state: opening.state
|
|
1176
|
+
};
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* An `unknown` opening's `raw`, for a log line: a placeholder carries the whole
|
|
1180
|
+
* slice, which `String()` would flatten to `[object Object]`.
|
|
1181
|
+
*/
|
|
1182
|
+
function rawForLog(raw) {
|
|
1183
|
+
return raw !== null && typeof raw === "object" ? JSON.stringify(raw) : String(raw);
|
|
1184
|
+
}
|
|
1185
|
+
/** How many runtime reads `readAlarmOpenings` keeps in flight at once. */
|
|
1186
|
+
var OPENINGS_READ_CONCURRENCY = 8;
|
|
1187
|
+
/**
|
|
1188
|
+
* The Notification Center's `readDeviceOpenings` dep, given the one runtime
|
|
1189
|
+
* read it needs. Every id that could be read is present with its
|
|
1190
|
+
* {@link NcAlarmOpening}; an id whose read THREW is ABSENT (unknown, D49) and
|
|
1191
|
+
* leaves one debug line tagged with its `deviceId`.
|
|
1192
|
+
*/
|
|
1193
|
+
async function readAlarmOpenings(ids, loadRuntime, logger) {
|
|
1194
|
+
const out = /* @__PURE__ */ new Map();
|
|
1195
|
+
const readOne = async (id) => {
|
|
1196
|
+
try {
|
|
1197
|
+
out.set(id, alarmOpeningFrom(await loadRuntime(id)));
|
|
1198
|
+
} catch (err) {
|
|
1199
|
+
logger.debug("device-opening read failed", {
|
|
1200
|
+
tags: { deviceId: id },
|
|
1201
|
+
meta: { error: String(err) }
|
|
1202
|
+
});
|
|
1203
|
+
}
|
|
1204
|
+
};
|
|
1205
|
+
for (let i = 0; i < ids.length; i += OPENINGS_READ_CONCURRENCY) await Promise.all(ids.slice(i, i + OPENINGS_READ_CONCURRENCY).map(readOne));
|
|
1206
|
+
return out;
|
|
1207
|
+
}
|
|
1208
|
+
//#endregion
|
|
1209
|
+
//#region src/notification-center/alarm/alarm-exclusions.ts
|
|
1210
|
+
function isPhase(value) {
|
|
1211
|
+
return value === "open" || value === "closed";
|
|
1212
|
+
}
|
|
1213
|
+
var NcAlarmExclusions = class {
|
|
1214
|
+
entries = [];
|
|
1215
|
+
/** Replace the whole set — one arm, one set of exclusions. */
|
|
1216
|
+
exclude(deviceIds, at) {
|
|
1217
|
+
this.entries = [...new Set(deviceIds)].map((deviceId) => ({
|
|
1218
|
+
deviceId,
|
|
1219
|
+
phase: "open",
|
|
1220
|
+
at,
|
|
1221
|
+
announced: false
|
|
1222
|
+
}));
|
|
1223
|
+
}
|
|
1224
|
+
/** Every excluded sensor, in the order it was excluded. */
|
|
1225
|
+
list() {
|
|
1226
|
+
return this.entries.map((e) => e.deviceId);
|
|
1227
|
+
}
|
|
1228
|
+
/** True while this sensor's events must not reach the panel. */
|
|
1229
|
+
blocks(deviceId) {
|
|
1230
|
+
return this.entries.some((e) => e.deviceId === deviceId);
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Feed one opening's reading, and say what it CHANGED.
|
|
1234
|
+
*
|
|
1235
|
+
* Three answers, not two, because the caller has to persist on both of the
|
|
1236
|
+
* changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
|
|
1237
|
+
* only the re-arm, so a hub redeployed between the close and the re-open came
|
|
1238
|
+
* back with the sensor still in phase `open` — and the exclusion then needed
|
|
1239
|
+
* a SECOND close to lift. A phase that lives only in RAM is half a durable
|
|
1240
|
+
* exclusion.
|
|
1241
|
+
*
|
|
1242
|
+
* An unreadable state changes nothing (D49): a failed read must never lift an
|
|
1243
|
+
* exclusion the operator asked for, and must never swallow the close that
|
|
1244
|
+
* would have.
|
|
1245
|
+
*/
|
|
1246
|
+
observe(deviceId, reading, _at) {
|
|
1247
|
+
if (reading === void 0) return "unchanged";
|
|
1248
|
+
const current = this.entries.find((e) => e.deviceId === deviceId);
|
|
1249
|
+
if (current === void 0) return "unchanged";
|
|
1250
|
+
if (reading === "secure") {
|
|
1251
|
+
if (current.phase === "closed") return "unchanged";
|
|
1252
|
+
this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
|
|
1253
|
+
...e,
|
|
1254
|
+
phase: "closed"
|
|
1255
|
+
} : e);
|
|
1256
|
+
return "closed";
|
|
1257
|
+
}
|
|
1258
|
+
if (current.phase === "open") return "unchanged";
|
|
1259
|
+
this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
|
|
1260
|
+
return "rearmed";
|
|
1261
|
+
}
|
|
1262
|
+
/**
|
|
1263
|
+
* Mark this sensor's ignored trigger as announced — true only the FIRST time
|
|
1264
|
+
* in this arm (the caller then announces and persists), false for a repeat
|
|
1265
|
+
* or for a sensor that is not excluded.
|
|
1266
|
+
*/
|
|
1267
|
+
markAnnounced(deviceId) {
|
|
1268
|
+
const current = this.entries.find((e) => e.deviceId === deviceId);
|
|
1269
|
+
if (current === void 0 || current.announced) return false;
|
|
1270
|
+
this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
|
|
1271
|
+
...e,
|
|
1272
|
+
announced: true
|
|
1273
|
+
} : e);
|
|
1274
|
+
return true;
|
|
1275
|
+
}
|
|
1276
|
+
/** The arm this belongs to is over. */
|
|
1277
|
+
clear() {
|
|
1278
|
+
this.entries = [];
|
|
1279
|
+
}
|
|
1280
|
+
/** What to persist. Plain data — it rides the panel's config blob. */
|
|
1281
|
+
snapshot() {
|
|
1282
|
+
return this.entries.map((e) => ({
|
|
1283
|
+
deviceId: e.deviceId,
|
|
1284
|
+
phase: e.phase,
|
|
1285
|
+
at: e.at,
|
|
1286
|
+
announced: e.announced
|
|
1287
|
+
}));
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* Rebuild from the persisted blob — a GUARD, never a cast.
|
|
1291
|
+
*
|
|
1292
|
+
* Anything unrecognised restores NOTHING, which is the failure that shows
|
|
1293
|
+
* itself (a bypassed sensor that fires) rather than the one that hides (a
|
|
1294
|
+
* sensor excluded forever by a blob nobody can read). A bare id with no phase
|
|
1295
|
+
* restores as still-OPEN: it needs a close before it can re-arm, which is the
|
|
1296
|
+
* half that cannot cause a false alarm.
|
|
1297
|
+
*/
|
|
1298
|
+
restore(raw) {
|
|
1299
|
+
if (!Array.isArray(raw)) return;
|
|
1300
|
+
const out = [];
|
|
1301
|
+
for (const item of raw) {
|
|
1302
|
+
if (typeof item === "number" && Number.isInteger(item)) {
|
|
1303
|
+
out.push({
|
|
1304
|
+
deviceId: item,
|
|
1305
|
+
phase: "open",
|
|
1306
|
+
at: 0,
|
|
1307
|
+
announced: false
|
|
1308
|
+
});
|
|
1309
|
+
continue;
|
|
1310
|
+
}
|
|
1311
|
+
if (item === null || typeof item !== "object") continue;
|
|
1312
|
+
const rec = { ...item };
|
|
1313
|
+
const deviceId = rec["deviceId"];
|
|
1314
|
+
if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
|
|
1315
|
+
const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
|
|
1316
|
+
const at = typeof rec["at"] === "number" ? rec["at"] : 0;
|
|
1317
|
+
out.push({
|
|
1318
|
+
deviceId,
|
|
1319
|
+
phase,
|
|
1320
|
+
at,
|
|
1321
|
+
announced: rec["announced"] === true
|
|
1322
|
+
});
|
|
1323
|
+
}
|
|
1324
|
+
this.entries = out;
|
|
1325
|
+
}
|
|
1326
|
+
};
|
|
1327
|
+
//#endregion
|
|
539
1328
|
//#region src/notification-center/alarm/alarm-state-machine.ts
|
|
540
1329
|
/**
|
|
541
1330
|
* Defaults an operator can change per panel. `home` has no exit delay on
|
|
@@ -591,6 +1380,19 @@ function exitDelayAt(m, now) {
|
|
|
591
1380
|
armedAt: m.armedAt
|
|
592
1381
|
};
|
|
593
1382
|
}
|
|
1383
|
+
/**
|
|
1384
|
+
* The entry delay running at `now`, or `undefined` when none is. Gated on
|
|
1385
|
+
* `stateAt`, like {@link exitDelayAt}: `triggerAt` survives on the machine
|
|
1386
|
+
* after the siren sounds, until a settle drops it.
|
|
1387
|
+
*/
|
|
1388
|
+
function pendingAt(m, now) {
|
|
1389
|
+
if (m.triggerAt === void 0) return void 0;
|
|
1390
|
+
if (stateAt(m, now) !== "pending") return void 0;
|
|
1391
|
+
return {
|
|
1392
|
+
target: m.target,
|
|
1393
|
+
triggerAt: m.triggerAt
|
|
1394
|
+
};
|
|
1395
|
+
}
|
|
594
1396
|
/** Whether the panel is armed enough for a trigger to matter. */
|
|
595
1397
|
function isArmed(m, now) {
|
|
596
1398
|
const s = stateAt(m, now);
|
|
@@ -683,512 +1485,6 @@ function settle(m, now) {
|
|
|
683
1485
|
};
|
|
684
1486
|
return m;
|
|
685
1487
|
}
|
|
686
|
-
//#endregion
|
|
687
|
-
//#region src/notification-center/alarm/alarm-mode-coverage.ts
|
|
688
|
-
/**
|
|
689
|
-
* Whether the panel just BECAME armed, in the sense a person means.
|
|
690
|
-
*
|
|
691
|
-
* Two transitions reach an `armed_*` state and only one of them is an arm:
|
|
692
|
-
*
|
|
693
|
-
* - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
|
|
694
|
-
* - `triggered` → `armed_away` is the siren's duration ending and the panel
|
|
695
|
-
* re-arming itself. Announcing it would send "Away armed" in the middle of
|
|
696
|
-
* a break-in, seconds after the alarm notification, saying nothing new.
|
|
697
|
-
*
|
|
698
|
-
* `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
|
|
699
|
-
* set of devices covered just changed with it — which is the whole content of
|
|
700
|
-
* the message.
|
|
701
|
-
*
|
|
702
|
-
* A null `previous` is the FIRST publish and never announces. A hub restarting
|
|
703
|
-
* while armed re-publishes `armed_away`, and nobody armed anything — every
|
|
704
|
-
* deploy would otherwise send one. Handled here rather than by the caller
|
|
705
|
-
* happening to wire the hook late, because "it works because of the order two
|
|
706
|
-
* unrelated things run in" is how this stops working.
|
|
707
|
-
*/
|
|
708
|
-
function shouldAnnounceArm(previous, next) {
|
|
709
|
-
if (previous === null) return false;
|
|
710
|
-
if (!next.startsWith("armed_")) return false;
|
|
711
|
-
if (previous === next) return false;
|
|
712
|
-
if (previous === "triggered") return false;
|
|
713
|
-
return true;
|
|
714
|
-
}
|
|
715
|
-
/** The mode inside an `armed_<mode>` state, or null for any other state. */
|
|
716
|
-
function armModeOf(state, modes) {
|
|
717
|
-
for (const mode of modes) if (state === armedStateFor(mode)) return mode;
|
|
718
|
-
return null;
|
|
719
|
-
}
|
|
720
|
-
/** The `deviceState` word a rule must gate on for `mode` to cover it. */
|
|
721
|
-
function armedStateFor(mode) {
|
|
722
|
-
return `armed_${mode}`;
|
|
723
|
-
}
|
|
724
|
-
/**
|
|
725
|
-
* One entry per mode the panel offers, including modes nothing is gated on.
|
|
726
|
-
*
|
|
727
|
-
* Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
|
|
728
|
-
* most useful thing this can tell an operator, and a list that omits it looks
|
|
729
|
-
* identical to a list where night is covered.
|
|
730
|
-
*/
|
|
731
|
-
function alarmModeCoverage(rules, panelDeviceId, modes, skipProbe) {
|
|
732
|
-
return modes.map((mode) => coverageFor(rules, panelDeviceId, mode, skipProbe));
|
|
733
|
-
}
|
|
734
|
-
function coverageFor(rules, panelDeviceId, mode, skipProbe) {
|
|
735
|
-
const wanted = armedStateFor(mode);
|
|
736
|
-
const ids = /* @__PURE__ */ new Set();
|
|
737
|
-
let ruleCount = 0;
|
|
738
|
-
let allDevices = false;
|
|
739
|
-
for (const rule of rules) {
|
|
740
|
-
if (!rule.enabled) continue;
|
|
741
|
-
const gate = rule.conditions.deviceState;
|
|
742
|
-
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
743
|
-
if (!gate.states.includes(wanted)) continue;
|
|
744
|
-
ruleCount += 1;
|
|
745
|
-
const scope = rule.conditions.devices;
|
|
746
|
-
if (scope === void 0 || scope.length === 0) {
|
|
747
|
-
allDevices = true;
|
|
748
|
-
continue;
|
|
749
|
-
}
|
|
750
|
-
for (const id of scope) ids.add(id);
|
|
751
|
-
}
|
|
752
|
-
const deviceIds = [...ids].toSorted((a, b) => a - b);
|
|
753
|
-
return {
|
|
754
|
-
mode,
|
|
755
|
-
ruleCount,
|
|
756
|
-
allDevices,
|
|
757
|
-
deviceIds,
|
|
758
|
-
skippedDevices: skippedFor(deviceIds, skipProbe)
|
|
759
|
-
};
|
|
760
|
-
}
|
|
761
|
-
function skippedFor(deviceIds, skipProbe) {
|
|
762
|
-
if (skipProbe === void 0) return [];
|
|
763
|
-
const out = [];
|
|
764
|
-
for (const deviceId of deviceIds) {
|
|
765
|
-
const reason = skipProbe(deviceId);
|
|
766
|
-
if (reason !== void 0) out.push({
|
|
767
|
-
deviceId,
|
|
768
|
-
reason
|
|
769
|
-
});
|
|
770
|
-
}
|
|
771
|
-
return out;
|
|
772
|
-
}
|
|
773
|
-
/**
|
|
774
|
-
* Whether this mode arms anything, and if not, which way it fails.
|
|
775
|
-
*
|
|
776
|
-
* An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
|
|
777
|
-
* ids a rule wrote down, so it cannot answer for a mode that covers every
|
|
778
|
-
* camera on the hub, and reporting "all excluded" from a subset would be a
|
|
779
|
-
* warning about protection that exists.
|
|
780
|
-
*/
|
|
781
|
-
function alarmCoverageGap(coverage) {
|
|
782
|
-
if (coverage.ruleCount === 0) return "uncovered";
|
|
783
|
-
if (coverage.allDevices) return null;
|
|
784
|
-
if (coverage.deviceIds.length === 0) return null;
|
|
785
|
-
if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
|
|
786
|
-
return "all-skipped";
|
|
787
|
-
}
|
|
788
|
-
/**
|
|
789
|
-
* "Attivi: Cancello, Garage — Esclusi: Porta retro."
|
|
790
|
-
*
|
|
791
|
-
* A LIST, not a sentence. The mode is in the title and the body is one
|
|
792
|
-
* `Label: names` line per device group, because this is read on a lock screen
|
|
793
|
-
* and the question is always "which one" — the prose version ("3 dispositivi
|
|
794
|
-
* attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
|
|
795
|
-
* window. Each variant still owns its own catalog key, so the Texts tab can
|
|
796
|
-
* re-word "nothing is armed" without touching "these are armed".
|
|
797
|
-
*
|
|
798
|
-
* Names, not ids: this is read by a person standing at a door. An id that has
|
|
799
|
-
* no name falls back to a NAMED, translated placeholder rather than being
|
|
800
|
-
* dropped — a silently shorter list would understate what is armed, which is
|
|
801
|
-
* the one error this message must not make. (It used to hard-code the English
|
|
802
|
-
* `Device <id>` into both languages.)
|
|
803
|
-
*
|
|
804
|
-
* **Always a sentence.** It used to return null for a mode no rule is gated on,
|
|
805
|
-
* on the grounds that "Night armed" followed by an unwatched night is a false
|
|
806
|
-
* assurance — true, but the caller then emitted `alarm-armed` anyway with a
|
|
807
|
-
* fallback body key that existed in NEITHER locale, so the operator received a
|
|
808
|
-
* title and an empty line. Silence is not the third option here: SAYING that
|
|
809
|
-
* the mode arms nothing is.
|
|
810
|
-
*/
|
|
811
|
-
function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
|
|
812
|
-
const rules = `${coverage.ruleCount}`;
|
|
813
|
-
const mode = coverage.mode;
|
|
814
|
-
if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
|
|
815
|
-
const gap = alarmCoverageGap(coverage);
|
|
816
|
-
if (gap === "uncovered") return {
|
|
817
|
-
bodyKey: "alarm.arm.body.uncovered",
|
|
818
|
-
textParams: { mode },
|
|
819
|
-
textCount: 0
|
|
820
|
-
};
|
|
821
|
-
if (gap === "all-skipped") {
|
|
822
|
-
const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
|
|
823
|
-
return {
|
|
824
|
-
bodyKey: "alarm.arm.body.allSkipped",
|
|
825
|
-
textParams: {
|
|
826
|
-
rules,
|
|
827
|
-
mode,
|
|
828
|
-
devices: `${names.length}`,
|
|
829
|
-
names: names.join(", ")
|
|
830
|
-
},
|
|
831
|
-
textCount: names.length
|
|
832
|
-
};
|
|
833
|
-
}
|
|
834
|
-
if (coverage.allDevices) return {
|
|
835
|
-
bodyKey: "alarm.arm.body.allDevices",
|
|
836
|
-
textParams: {
|
|
837
|
-
rules,
|
|
838
|
-
mode
|
|
839
|
-
},
|
|
840
|
-
textCount: coverage.ruleCount
|
|
841
|
-
};
|
|
842
|
-
if (coverage.deviceIds.length === 0) return {
|
|
843
|
-
bodyKey: "alarm.arm.body.noDevices",
|
|
844
|
-
textParams: {
|
|
845
|
-
rules,
|
|
846
|
-
mode
|
|
847
|
-
},
|
|
848
|
-
textCount: coverage.ruleCount
|
|
849
|
-
};
|
|
850
|
-
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
851
|
-
const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
|
|
852
|
-
if (skipped.size === 0) return {
|
|
853
|
-
bodyKey: "alarm.arm.body.devices",
|
|
854
|
-
textParams: {
|
|
855
|
-
rules,
|
|
856
|
-
mode,
|
|
857
|
-
devices: `${names.length}`,
|
|
858
|
-
names: names.join(", ")
|
|
859
|
-
},
|
|
860
|
-
textCount: names.length
|
|
861
|
-
};
|
|
862
|
-
return {
|
|
863
|
-
bodyKey: "alarm.arm.body.devicesSkipped",
|
|
864
|
-
textParams: {
|
|
865
|
-
rules,
|
|
866
|
-
mode,
|
|
867
|
-
devices: `${names.length}`,
|
|
868
|
-
names: names.join(", "),
|
|
869
|
-
skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
|
|
870
|
-
},
|
|
871
|
-
textCount: names.length
|
|
872
|
-
};
|
|
873
|
-
}
|
|
874
|
-
/**
|
|
875
|
-
* "Attivi: 2 — Bypassati: Finestra veranda."
|
|
876
|
-
*
|
|
877
|
-
* The bypassed sensors are named and the armed ones are COUNTED, not listed:
|
|
878
|
-
* this sentence's subject is the exception, and burying it under thirteen
|
|
879
|
-
* camera names is how an operator stops reading the arm notification. The
|
|
880
|
-
* count pluralises on what IS armed, like every other arm variant.
|
|
881
|
-
*/
|
|
882
|
-
function armedWithBypass(coverage, bypassed, deviceName) {
|
|
883
|
-
const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
|
|
884
|
-
const excluded = new Set(bypassed);
|
|
885
|
-
const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
|
|
886
|
-
return {
|
|
887
|
-
bodyKey: "alarm.arm.body.bypassed",
|
|
888
|
-
textParams: {
|
|
889
|
-
rules: `${coverage.ruleCount}`,
|
|
890
|
-
mode: coverage.mode,
|
|
891
|
-
devices: `${armed}`,
|
|
892
|
-
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
893
|
-
},
|
|
894
|
-
textCount: armed
|
|
895
|
-
};
|
|
896
|
-
}
|
|
897
|
-
/**
|
|
898
|
-
* "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
|
|
899
|
-
*
|
|
900
|
-
* The one alarm sentence about something that did not happen, so it has to
|
|
901
|
-
* carry both halves an operator needs to act: WHICH sensors are open, and that
|
|
902
|
-
* pressing arm again arms without them. Naming them is the whole message — a
|
|
903
|
-
* refusal that says only "cannot arm" is a panel the operator will conclude is
|
|
904
|
-
* broken — so the names are the FIRST line and the retry hint is the second.
|
|
905
|
-
*/
|
|
906
|
-
function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
|
|
907
|
-
return {
|
|
908
|
-
bodyKey: "alarm.armRefused.body",
|
|
909
|
-
textParams: {
|
|
910
|
-
mode,
|
|
911
|
-
devices: `${names.length}`,
|
|
912
|
-
names: names.join(", "),
|
|
913
|
-
seconds: `${retrySeconds}`
|
|
914
|
-
},
|
|
915
|
-
textCount: names.length
|
|
916
|
-
};
|
|
917
|
-
}
|
|
918
|
-
/**
|
|
919
|
-
* The EXCEPTIONS to the same coverage, said 30 seconds earlier.
|
|
920
|
-
*
|
|
921
|
-
* The exit-delay sentence is deliberately SHORTER than the arm one — it is read
|
|
922
|
-
* on the way out of a door — and it carries exactly one thing the arm sentence
|
|
923
|
-
* cannot: the warning arrives while the operator can still disarm and fix it.
|
|
924
|
-
* A mode that arms nothing is worth interrupting somebody for at second 0 and
|
|
925
|
-
* merely worth recording at second 30.
|
|
926
|
-
*
|
|
927
|
-
* **It never repeats the device list.** The countdown lives in the TITLE
|
|
928
|
-
* (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
|
|
929
|
-
* list arrives thirty seconds later in the arm announcement — printing it
|
|
930
|
-
* twice is the verbosity that made the operator stop reading both. So the body
|
|
931
|
-
* is what is WRONG: what was bypassed, what is excluded, or that nothing is.
|
|
932
|
-
*
|
|
933
|
-
* `seconds` stays in the params because the title asks for it, and `textCount`
|
|
934
|
-
* stays with it: a row enqueued before this deploy still pluralises on the
|
|
935
|
-
* countdown it was written for.
|
|
936
|
-
*/
|
|
937
|
-
function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
|
|
938
|
-
const textParams = {
|
|
939
|
-
mode: coverage.mode,
|
|
940
|
-
seconds: `${seconds}`
|
|
941
|
-
};
|
|
942
|
-
if (bypassed.length > 0) return {
|
|
943
|
-
bodyKey: "alarm.arming.body.bypassed",
|
|
944
|
-
textParams: {
|
|
945
|
-
...textParams,
|
|
946
|
-
bypassedNames: bypassed.map(deviceName).join(", ")
|
|
947
|
-
},
|
|
948
|
-
textCount: seconds
|
|
949
|
-
};
|
|
950
|
-
const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
|
|
951
|
-
const gap = alarmCoverageGap(coverage);
|
|
952
|
-
if (gap === "uncovered") return {
|
|
953
|
-
bodyKey: "alarm.arming.body.uncovered",
|
|
954
|
-
textParams,
|
|
955
|
-
textCount: seconds
|
|
956
|
-
};
|
|
957
|
-
if (gap === "all-skipped") return {
|
|
958
|
-
bodyKey: "alarm.arming.body.allSkipped",
|
|
959
|
-
textParams: {
|
|
960
|
-
...textParams,
|
|
961
|
-
names: skippedNames
|
|
962
|
-
},
|
|
963
|
-
textCount: seconds
|
|
964
|
-
};
|
|
965
|
-
if (coverage.skippedDevices.length > 0) return {
|
|
966
|
-
bodyKey: "alarm.arming.body.skipped",
|
|
967
|
-
textParams: {
|
|
968
|
-
...textParams,
|
|
969
|
-
names: skippedNames
|
|
970
|
-
},
|
|
971
|
-
textCount: coverage.skippedDevices.length
|
|
972
|
-
};
|
|
973
|
-
return {
|
|
974
|
-
bodyKey: "alarm.arming.body",
|
|
975
|
-
textParams,
|
|
976
|
-
textCount: seconds
|
|
977
|
-
};
|
|
978
|
-
}
|
|
979
|
-
//#endregion
|
|
980
|
-
//#region src/notification-center/alarm/alarm-arm-guard.ts
|
|
981
|
-
/**
|
|
982
|
-
* How long a refusal stays confirmable.
|
|
983
|
-
*
|
|
984
|
-
* Long enough to read the notification and press again (the operator is
|
|
985
|
-
* standing at the door, phone in hand); short enough that it cannot be the
|
|
986
|
-
* state the panel is left in. Sixty seconds is also under the shortest exit
|
|
987
|
-
* delay this panel ships with, so a bypass can never outlive the arm it
|
|
988
|
-
* belongs to.
|
|
989
|
-
*/
|
|
990
|
-
var ARM_BYPASS_WINDOW_MS = 6e4;
|
|
991
|
-
/** The taxonomy kind a door/window contact persists its events under. */
|
|
992
|
-
var CONTACT_SENSOR_KIND = "contact";
|
|
993
|
-
/**
|
|
994
|
-
* The contact sensors a mode would arm, ascending and de-duplicated.
|
|
995
|
-
*
|
|
996
|
-
* A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
|
|
997
|
-
* and about contacts (`sensorKinds` naming `contact`). Two exclusions are
|
|
998
|
-
* deliberate and both are about refusing an arm the operator cannot fix:
|
|
999
|
-
*
|
|
1000
|
-
* - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
|
|
1001
|
-
* not a contact rule, and a camera is never open;
|
|
1002
|
-
* - a contact rule with NO device scope covers every contact on the hub, and
|
|
1003
|
-
* blocking on a set the operator never wrote down produces a refusal naming
|
|
1004
|
-
* devices they never associated with the alarm. The caller says so in a log
|
|
1005
|
-
* line rather than inventing the list.
|
|
1006
|
-
*/
|
|
1007
|
-
function alarmBlockingCandidates(rules, panelDeviceId, mode) {
|
|
1008
|
-
const wanted = armedStateFor(mode);
|
|
1009
|
-
const ids = /* @__PURE__ */ new Set();
|
|
1010
|
-
for (const rule of rules) {
|
|
1011
|
-
if (!rule.enabled) continue;
|
|
1012
|
-
const gate = rule.conditions.deviceState;
|
|
1013
|
-
if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
|
|
1014
|
-
if (!gate.states.includes(wanted)) continue;
|
|
1015
|
-
const kinds = rule.conditions.sensorKinds;
|
|
1016
|
-
if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
|
|
1017
|
-
const scope = rule.conditions.devices;
|
|
1018
|
-
if (scope === void 0 || scope.length === 0) continue;
|
|
1019
|
-
for (const id of scope) ids.add(id);
|
|
1020
|
-
}
|
|
1021
|
-
return [...ids].toSorted((a, b) => a - b);
|
|
1022
|
-
}
|
|
1023
|
-
/**
|
|
1024
|
-
* Every contact any mode of this panel could block on — the set whose state is
|
|
1025
|
-
* worth keeping mirrored.
|
|
1026
|
-
*
|
|
1027
|
-
* Read on the rule-reload tick, so the arm path has a recent answer even when
|
|
1028
|
-
* the targeted read at arm time fails, and so an excluded sensor's close can be
|
|
1029
|
-
* noticed on a hub where the sensor is linked to no camera (and therefore emits
|
|
1030
|
-
* no device event at all — the live installation on 2026-08-13).
|
|
1031
|
-
*/
|
|
1032
|
-
function alarmContactWatchList(rules, panelDeviceId, modes) {
|
|
1033
|
-
const ids = /* @__PURE__ */ new Set();
|
|
1034
|
-
for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
|
|
1035
|
-
return [...ids].toSorted((a, b) => a - b);
|
|
1036
|
-
}
|
|
1037
|
-
/**
|
|
1038
|
-
* Refuse, bypass or proceed — the whole decision, as one comparison.
|
|
1039
|
-
*
|
|
1040
|
-
* The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
|
|
1041
|
-
* is a different intent about a different set of sensors, and letting one
|
|
1042
|
-
* confirm the other would arm a mode around a window nobody was told about.
|
|
1043
|
-
*
|
|
1044
|
-
* The bypassed set is what is open NOW, not what was open at the refusal: a
|
|
1045
|
-
* window shut between the two presses is armed with everything else, which is
|
|
1046
|
-
* the entire point of having refused.
|
|
1047
|
-
*/
|
|
1048
|
-
function decideArm(input) {
|
|
1049
|
-
const window = input.windowMs ?? 6e4;
|
|
1050
|
-
if (input.openContacts.length === 0) return { kind: "clear" };
|
|
1051
|
-
const previous = input.previous;
|
|
1052
|
-
if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
|
|
1053
|
-
kind: "bypassed",
|
|
1054
|
-
bypassed: input.openContacts
|
|
1055
|
-
};
|
|
1056
|
-
return {
|
|
1057
|
-
kind: "refused",
|
|
1058
|
-
blocking: input.openContacts,
|
|
1059
|
-
retryUntil: input.at + window
|
|
1060
|
-
};
|
|
1061
|
-
}
|
|
1062
|
-
/**
|
|
1063
|
-
* The error `alarmPanel.arm` throws when it refuses.
|
|
1064
|
-
*
|
|
1065
|
-
* A THROW and not a return value, and the reason is a train: the hub's
|
|
1066
|
-
* generated router validates this method's response against the HOST's copy of
|
|
1067
|
-
* the cap, which says `z.void()`. An addon that answered a structured refusal
|
|
1068
|
-
* would have it rejected by the router in front of it — so the refusal reaches
|
|
1069
|
-
* the caller as the one channel that already works across that boundary, and
|
|
1070
|
-
* the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
|
|
1071
|
-
* ordinary outbox) rather than a UI error being the only trace.
|
|
1072
|
-
*
|
|
1073
|
-
* The message is deliberately actionable and English (an exception message is
|
|
1074
|
-
* a developer/API surface; the notification is the operator's).
|
|
1075
|
-
*/
|
|
1076
|
-
var NcAlarmArmRefusedError = class extends Error {
|
|
1077
|
-
code = "alarm-arm-refused";
|
|
1078
|
-
mode;
|
|
1079
|
-
blocking;
|
|
1080
|
-
retryUntil;
|
|
1081
|
-
constructor(input) {
|
|
1082
|
-
const names = input.blocking.map((c) => c.name).join(", ");
|
|
1083
|
-
const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
|
|
1084
|
-
super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
|
|
1085
|
-
this.name = "NcAlarmArmRefusedError";
|
|
1086
|
-
this.mode = input.mode;
|
|
1087
|
-
this.blocking = input.blocking;
|
|
1088
|
-
this.retryUntil = input.retryUntil;
|
|
1089
|
-
}
|
|
1090
|
-
};
|
|
1091
|
-
//#endregion
|
|
1092
|
-
//#region src/notification-center/alarm/alarm-exclusions.ts
|
|
1093
|
-
function isPhase(value) {
|
|
1094
|
-
return value === "open" || value === "closed";
|
|
1095
|
-
}
|
|
1096
|
-
var NcAlarmExclusions = class {
|
|
1097
|
-
entries = [];
|
|
1098
|
-
/** Replace the whole set — one arm, one set of exclusions. */
|
|
1099
|
-
exclude(deviceIds, at) {
|
|
1100
|
-
this.entries = [...new Set(deviceIds)].map((deviceId) => ({
|
|
1101
|
-
deviceId,
|
|
1102
|
-
phase: "open",
|
|
1103
|
-
at
|
|
1104
|
-
}));
|
|
1105
|
-
}
|
|
1106
|
-
/** Every excluded sensor, in the order it was excluded. */
|
|
1107
|
-
list() {
|
|
1108
|
-
return this.entries.map((e) => e.deviceId);
|
|
1109
|
-
}
|
|
1110
|
-
/** True while this sensor's events must not reach the panel. */
|
|
1111
|
-
blocks(deviceId) {
|
|
1112
|
-
return this.entries.some((e) => e.deviceId === deviceId);
|
|
1113
|
-
}
|
|
1114
|
-
/**
|
|
1115
|
-
* Feed one contact reading, and say what it CHANGED.
|
|
1116
|
-
*
|
|
1117
|
-
* Three answers, not two, because the caller has to persist on both of the
|
|
1118
|
-
* changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
|
|
1119
|
-
* only the re-arm, so a hub redeployed between the close and the re-open came
|
|
1120
|
-
* back with the sensor still in phase `open` — and the exclusion then needed
|
|
1121
|
-
* a SECOND close to lift. A phase that lives only in RAM is half a durable
|
|
1122
|
-
* exclusion.
|
|
1123
|
-
*
|
|
1124
|
-
* An unreadable state changes nothing (D49): a failed read must never lift an
|
|
1125
|
-
* exclusion the operator asked for, and must never swallow the close that
|
|
1126
|
-
* would have.
|
|
1127
|
-
*/
|
|
1128
|
-
observe(deviceId, reading, _at) {
|
|
1129
|
-
if (reading === void 0) return "unchanged";
|
|
1130
|
-
const current = this.entries.find((e) => e.deviceId === deviceId);
|
|
1131
|
-
if (current === void 0) return "unchanged";
|
|
1132
|
-
if (reading === "closed") {
|
|
1133
|
-
if (current.phase === "closed") return "unchanged";
|
|
1134
|
-
this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
|
|
1135
|
-
...e,
|
|
1136
|
-
phase: "closed"
|
|
1137
|
-
} : e);
|
|
1138
|
-
return "closed";
|
|
1139
|
-
}
|
|
1140
|
-
if (current.phase === "open") return "unchanged";
|
|
1141
|
-
this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
|
|
1142
|
-
return "rearmed";
|
|
1143
|
-
}
|
|
1144
|
-
/** The arm this belongs to is over. */
|
|
1145
|
-
clear() {
|
|
1146
|
-
this.entries = [];
|
|
1147
|
-
}
|
|
1148
|
-
/** What to persist. Plain data — it rides the panel's config blob. */
|
|
1149
|
-
snapshot() {
|
|
1150
|
-
return this.entries.map((e) => ({
|
|
1151
|
-
deviceId: e.deviceId,
|
|
1152
|
-
phase: e.phase,
|
|
1153
|
-
at: e.at
|
|
1154
|
-
}));
|
|
1155
|
-
}
|
|
1156
|
-
/**
|
|
1157
|
-
* Rebuild from the persisted blob — a GUARD, never a cast.
|
|
1158
|
-
*
|
|
1159
|
-
* Anything unrecognised restores NOTHING, which is the failure that shows
|
|
1160
|
-
* itself (a bypassed sensor that fires) rather than the one that hides (a
|
|
1161
|
-
* sensor excluded forever by a blob nobody can read). A bare id with no phase
|
|
1162
|
-
* restores as still-OPEN: it needs a close before it can re-arm, which is the
|
|
1163
|
-
* half that cannot cause a false alarm.
|
|
1164
|
-
*/
|
|
1165
|
-
restore(raw) {
|
|
1166
|
-
if (!Array.isArray(raw)) return;
|
|
1167
|
-
const out = [];
|
|
1168
|
-
for (const item of raw) {
|
|
1169
|
-
if (typeof item === "number" && Number.isInteger(item)) {
|
|
1170
|
-
out.push({
|
|
1171
|
-
deviceId: item,
|
|
1172
|
-
phase: "open",
|
|
1173
|
-
at: 0
|
|
1174
|
-
});
|
|
1175
|
-
continue;
|
|
1176
|
-
}
|
|
1177
|
-
if (item === null || typeof item !== "object") continue;
|
|
1178
|
-
const rec = { ...item };
|
|
1179
|
-
const deviceId = rec["deviceId"];
|
|
1180
|
-
if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
|
|
1181
|
-
const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
|
|
1182
|
-
const at = typeof rec["at"] === "number" ? rec["at"] : 0;
|
|
1183
|
-
out.push({
|
|
1184
|
-
deviceId,
|
|
1185
|
-
phase,
|
|
1186
|
-
at
|
|
1187
|
-
});
|
|
1188
|
-
}
|
|
1189
|
-
this.entries = out;
|
|
1190
|
-
}
|
|
1191
|
-
};
|
|
1192
1488
|
/**
|
|
1193
1489
|
* WHO the panel's current countdown belongs to.
|
|
1194
1490
|
*
|
|
@@ -1287,6 +1583,18 @@ function alarmTransition(input) {
|
|
|
1287
1583
|
...input.source !== void 0 ? { source: input.source } : {}
|
|
1288
1584
|
};
|
|
1289
1585
|
if (next === "arming") return armingTransition(previous, input);
|
|
1586
|
+
if (next === "pending") return pendingTransition(previous, input);
|
|
1587
|
+
if (previous === "triggered" && next.startsWith("armed_")) {
|
|
1588
|
+
const mode = armModeOf(next, input.modes);
|
|
1589
|
+
if (mode === null) return null;
|
|
1590
|
+
return {
|
|
1591
|
+
kind: "alarm-rearmed",
|
|
1592
|
+
state: next,
|
|
1593
|
+
previous,
|
|
1594
|
+
mode,
|
|
1595
|
+
at
|
|
1596
|
+
};
|
|
1597
|
+
}
|
|
1290
1598
|
if (shouldAnnounceArm(previous, next)) {
|
|
1291
1599
|
const mode = armModeOf(next, input.modes);
|
|
1292
1600
|
if (mode === null) return null;
|
|
@@ -1329,6 +1637,27 @@ function armingTransition(previous, input) {
|
|
|
1329
1637
|
at: input.at
|
|
1330
1638
|
};
|
|
1331
1639
|
}
|
|
1640
|
+
/**
|
|
1641
|
+
* The entry delay, as one notification at its START — the mirror of
|
|
1642
|
+
* {@link armingTransition}. Null when the panel did not say which mode the
|
|
1643
|
+
* countdown belongs to and when it ends: "Intrusion — s to disarm" is not a
|
|
1644
|
+
* sentence to send.
|
|
1645
|
+
*/
|
|
1646
|
+
function pendingTransition(previous, input) {
|
|
1647
|
+
const pending = input.pending;
|
|
1648
|
+
if (pending === void 0) return null;
|
|
1649
|
+
const mode = armModeOf(pending.target, input.modes);
|
|
1650
|
+
if (mode === null) return null;
|
|
1651
|
+
return {
|
|
1652
|
+
kind: "alarm-pending",
|
|
1653
|
+
state: "pending",
|
|
1654
|
+
previous,
|
|
1655
|
+
mode,
|
|
1656
|
+
seconds: Math.max(0, Math.ceil((pending.triggerAt - input.at) / 1e3)),
|
|
1657
|
+
at: input.at,
|
|
1658
|
+
...input.source !== void 0 ? { source: input.source } : {}
|
|
1659
|
+
};
|
|
1660
|
+
}
|
|
1332
1661
|
//#endregion
|
|
1333
1662
|
//#region src/notification-center/alarm/alarm-panel-device.ts
|
|
1334
1663
|
/**
|
|
@@ -1371,22 +1700,70 @@ var ncAlarmPanelSchema = require_dist.object({
|
|
|
1371
1700
|
exitDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
|
|
1372
1701
|
entryDelaySec: require_dist.number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
|
|
1373
1702
|
triggeredDurationSec: require_dist.number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
|
|
1374
|
-
/**
|
|
1703
|
+
/**
|
|
1704
|
+
* VESTIGIAL (D630): nothing reads it to decide anything any more — every
|
|
1705
|
+
* transition is announced to `announceTargets`. Kept so a persisted blob
|
|
1706
|
+
* and the settings cap still round-trip it, and read once at boot to tell
|
|
1707
|
+
* an operator who had it OFF that their targets now hear every transition.
|
|
1708
|
+
*/
|
|
1375
1709
|
announceArm: require_dist.boolean().default(false),
|
|
1376
|
-
/** Target ids
|
|
1710
|
+
/** Target ids EVERY alarm announcement goes to — see `NcAlarmSettingsSchema`. */
|
|
1377
1711
|
announceTargets: require_dist.array(require_dist.string()).default([]),
|
|
1378
1712
|
machine: require_dist.record(require_dist.string(), require_dist.unknown()).optional(),
|
|
1379
1713
|
/**
|
|
1380
|
-
*
|
|
1381
|
-
*
|
|
1714
|
+
* Devices the CURRENT arm is ignoring because they were open when it armed
|
|
1715
|
+
* (tolerated by the mode's non-blocking list, or confirmed with Arm anyway),
|
|
1716
|
+
* and how far each is through its two-phase lift.
|
|
1382
1717
|
*
|
|
1383
1718
|
* Persisted for the same reason the machine is: a deploy in the middle of an
|
|
1384
1719
|
* armed night would otherwise silently re-arm every sensor the operator
|
|
1385
1720
|
* agreed to leave out, and they would learn about it from the siren. Read
|
|
1386
1721
|
* back through a guard, never a cast — see `NcAlarmExclusions.restore`.
|
|
1387
1722
|
*/
|
|
1388
|
-
exclusions: require_dist.array(require_dist.unknown()).default([])
|
|
1723
|
+
exclusions: require_dist.array(require_dist.unknown()).default([]),
|
|
1724
|
+
/**
|
|
1725
|
+
* Per arm mode, the covered devices whose being open does not refuse the arm
|
|
1726
|
+
* (D630). Loose on purpose — `mode` is a string here — and read back through
|
|
1727
|
+
* {@link nonBlockingFromPersisted}, never a cast: a blob written by a later
|
|
1728
|
+
* release with a mode this one does not know must drop that entry, not the
|
|
1729
|
+
* whole config.
|
|
1730
|
+
*/
|
|
1731
|
+
nonBlocking: require_dist.array(require_dist.object({
|
|
1732
|
+
mode: require_dist.string(),
|
|
1733
|
+
deviceIds: require_dist.array(require_dist.number().int())
|
|
1734
|
+
})).default([])
|
|
1389
1735
|
});
|
|
1736
|
+
/**
|
|
1737
|
+
* The persisted non-blocking lists, narrowed to modes that exist, first entry
|
|
1738
|
+
* per mode kept — the same "at most one list per mode" the settings schema
|
|
1739
|
+
* enforces at the boundary. A type guard (`safeParse`), never a cast.
|
|
1740
|
+
*/
|
|
1741
|
+
function nonBlockingFromPersisted(raw) {
|
|
1742
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1743
|
+
const lists = [];
|
|
1744
|
+
for (const entry of raw) {
|
|
1745
|
+
const mode = require_dist.AlarmArmModeSchema.safeParse(entry.mode);
|
|
1746
|
+
if (!mode.success || seen.has(mode.data)) continue;
|
|
1747
|
+
seen.add(mode.data);
|
|
1748
|
+
lists.push({
|
|
1749
|
+
mode: mode.data,
|
|
1750
|
+
deviceIds: [...entry.deviceIds]
|
|
1751
|
+
});
|
|
1752
|
+
}
|
|
1753
|
+
return lists;
|
|
1754
|
+
}
|
|
1755
|
+
/**
|
|
1756
|
+
* The log line for a trigger the state machine ignored, per state. `trigger`
|
|
1757
|
+
* ignores four: disarmed, arming (the exit delay), and pending / triggered
|
|
1758
|
+
* (already counting down or going off — a second sensor must not restart it).
|
|
1759
|
+
*/
|
|
1760
|
+
function ignoredTriggerMessage(state) {
|
|
1761
|
+
if (state === "arming") return "alarm trigger ignored — the panel is still arming (exit delay)";
|
|
1762
|
+
if (state === "pending") return "alarm trigger ignored — the entry delay is already running";
|
|
1763
|
+
if (state === "triggered") return "alarm trigger ignored — the alarm is already going off";
|
|
1764
|
+
if (state === "disarmed") return "alarm trigger ignored — the panel is disarmed";
|
|
1765
|
+
return "alarm trigger ignored — the panel cannot be triggered in this state";
|
|
1766
|
+
}
|
|
1390
1767
|
/** `armed_home` and friends — the only targets a machine may settle into. */
|
|
1391
1768
|
function isArmTarget(value) {
|
|
1392
1769
|
return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
|
|
@@ -1440,20 +1817,26 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1440
1817
|
/** Who answers "is anything open" — null until the centre wires itself in. */
|
|
1441
1818
|
armGuard = null;
|
|
1442
1819
|
/**
|
|
1443
|
-
* The
|
|
1820
|
+
* The LATEST refusal — the only one whose Arm anyway can still arm.
|
|
1444
1821
|
*
|
|
1445
1822
|
* In RAM and only in RAM, deliberately: it is a confirmation of something the
|
|
1446
|
-
* operator read
|
|
1823
|
+
* operator read minutes ago, and a confirmation that survived a restart would
|
|
1447
1824
|
* let a process respawn arm the alarm around an open window nobody re-read.
|
|
1448
|
-
* Losing it costs one extra
|
|
1449
|
-
|
|
1450
|
-
|
|
1825
|
+
* Losing it costs one extra tap. Nothing else clears it: a disarm or an arm
|
|
1826
|
+
* moves `machine.changedAt`, and `armAnyway` then finds it stale.
|
|
1827
|
+
*/
|
|
1828
|
+
refusal = null;
|
|
1829
|
+
/** The refusal whose Arm anyway was pressed last — a second press is `used`. */
|
|
1830
|
+
spentRefusalId = null;
|
|
1831
|
+
/** Makes refusal ids unique inside one millisecond. */
|
|
1832
|
+
refusalSeq = 0;
|
|
1451
1833
|
constructor(ctx) {
|
|
1452
1834
|
super(ctx, ncAlarmPanelSchema, { type: require_dist.DeviceType.AlarmPanel });
|
|
1453
1835
|
this.delays = this.readDelays();
|
|
1454
1836
|
this.restore();
|
|
1455
1837
|
this.registerAlarmCap();
|
|
1456
1838
|
this.publish();
|
|
1839
|
+
if (this.config.get("announceArm") === false && this.config.get("announceTargets").length > 0) this.ctx.logger.info("alarm announcements now cover every transition (D630) — the announceArm switch is gone", { tags: { deviceId: this.id } });
|
|
1457
1840
|
}
|
|
1458
1841
|
/** The operator-visible configuration, as the cap serves it. */
|
|
1459
1842
|
settings() {
|
|
@@ -1461,7 +1844,7 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1461
1844
|
...this.readDelays(),
|
|
1462
1845
|
announceArm: this.config.get("announceArm"),
|
|
1463
1846
|
announceTargets: [...this.config.get("announceTargets")],
|
|
1464
|
-
nonBlocking:
|
|
1847
|
+
nonBlocking: nonBlockingFromPersisted(this.config.get("nonBlocking"))
|
|
1465
1848
|
};
|
|
1466
1849
|
}
|
|
1467
1850
|
/**
|
|
@@ -1478,6 +1861,10 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1478
1861
|
if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
|
|
1479
1862
|
if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
|
|
1480
1863
|
if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
|
|
1864
|
+
if (patch.nonBlocking !== void 0) await this.config.set("nonBlocking", patch.nonBlocking.map((l) => ({
|
|
1865
|
+
mode: l.mode,
|
|
1866
|
+
deviceIds: [...l.deviceIds]
|
|
1867
|
+
})));
|
|
1481
1868
|
this.delays = this.readDelays();
|
|
1482
1869
|
return this.settings();
|
|
1483
1870
|
}
|
|
@@ -1506,11 +1893,106 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1506
1893
|
currentState(now = Date.now()) {
|
|
1507
1894
|
return stateAt(this.machine, now);
|
|
1508
1895
|
}
|
|
1509
|
-
/**
|
|
1896
|
+
/** The mode the machine belongs to — see {@link NcAlarmPublishReport.target}. */
|
|
1897
|
+
currentTarget() {
|
|
1898
|
+
return this.machine.target;
|
|
1899
|
+
}
|
|
1900
|
+
/** Wire the openings check and the refusal / check-failed announcements. */
|
|
1510
1901
|
setArmGuard(hook) {
|
|
1511
1902
|
this.armGuard = hook;
|
|
1512
1903
|
}
|
|
1513
1904
|
/**
|
|
1905
|
+
* A plain arm. Refused on any open covered device the mode does not tolerate
|
|
1906
|
+
* — pressed once or ten times, the answer is the same; the only way past a
|
|
1907
|
+
* refusal is {@link armAnyway} with that refusal's id.
|
|
1908
|
+
*
|
|
1909
|
+
* When the openings could not be read at all it arms (fail-open, announced
|
|
1910
|
+
* as a check failure) and EXCLUDES NOTHING NEW: the previous exclusions are
|
|
1911
|
+
* left as they were, since there is no reading to replace them with. An
|
|
1912
|
+
* `unknown` device neither blocks nor is excluded — nobody was shown it.
|
|
1913
|
+
*/
|
|
1914
|
+
async armWith(mode) {
|
|
1915
|
+
const now = Date.now();
|
|
1916
|
+
const read = await this.applyArmGuard(mode);
|
|
1917
|
+
if (read.kind === "unguarded") return this.settleArm(mode, null, now);
|
|
1918
|
+
if (read.kind === "check-failed") {
|
|
1919
|
+
const outcome = this.settleArm(mode, null, now);
|
|
1920
|
+
this.announceUncheckedArm(mode, read.error, now);
|
|
1921
|
+
return outcome;
|
|
1922
|
+
}
|
|
1923
|
+
if (read.verdict.kind === "refused") return this.refuse(mode, read.verdict.blocking, now);
|
|
1924
|
+
return this.settleArm(mode, read.verdict.excluded.map((device) => ({
|
|
1925
|
+
device,
|
|
1926
|
+
why: "non-blocking"
|
|
1927
|
+
})), now);
|
|
1928
|
+
}
|
|
1929
|
+
/**
|
|
1930
|
+
* The Arm anyway on ONE refusal's notification — the only path that arms
|
|
1931
|
+
* around a device that blocks.
|
|
1932
|
+
*
|
|
1933
|
+
* 1. no held refusal, or a different one (a later refusal replaced it) ⇒
|
|
1934
|
+
* `unknown-refusal`; the id of the refusal spent last ⇒ `used`;
|
|
1935
|
+
* 2. the panel changed state since ⇒ `stale`, and the refusal is dropped;
|
|
1936
|
+
* 3. older than {@link ARM_ANYWAY_TTL_MS} ⇒ `expired`, and it is dropped;
|
|
1937
|
+
* 4. otherwise the openings are READ AGAIN. A device that opened since is a
|
|
1938
|
+
* NEW refusal (new id, new notification) listing EVERY device that
|
|
1939
|
+
* blocks now — the still-open confirmed ones too, so its own Arm anyway
|
|
1940
|
+
* can arm around all of them, and the confirmed ones the re-read reads
|
|
1941
|
+
* UNKNOWN (so that Arm anyway still excludes them). Otherwise the arm excludes what is open
|
|
1942
|
+
* now; a refused device that closed meanwhile is armed normally. A
|
|
1943
|
+
* confirmed device the re-read cannot vouch for — the read failed, or
|
|
1944
|
+
* it reads unknown — stays EXCLUDED (D49: unknown is never secure).
|
|
1945
|
+
* 5. the refusal is spent on every outcome of step 4.
|
|
1946
|
+
*/
|
|
1947
|
+
async armAnyway(refusalId) {
|
|
1948
|
+
const now = Date.now();
|
|
1949
|
+
const held = this.refusal;
|
|
1950
|
+
if (held === null || held.id !== refusalId) {
|
|
1951
|
+
const reason = refusalId === this.spentRefusalId ? "used" : "unknown-refusal";
|
|
1952
|
+
return this.rejectArmAnyway(refusalId, reason);
|
|
1953
|
+
}
|
|
1954
|
+
if (this.machine.changedAt !== held.panelChangedAt) {
|
|
1955
|
+
this.refusal = null;
|
|
1956
|
+
return this.rejectArmAnyway(refusalId, "stale");
|
|
1957
|
+
}
|
|
1958
|
+
if (now - held.at > 3e5) {
|
|
1959
|
+
this.refusal = null;
|
|
1960
|
+
return this.rejectArmAnyway(refusalId, "expired");
|
|
1961
|
+
}
|
|
1962
|
+
this.refusal = null;
|
|
1963
|
+
this.spentRefusalId = held.id;
|
|
1964
|
+
const read = await this.applyArmGuard(held.mode, held.blockingIds);
|
|
1965
|
+
if (this.machine.changedAt !== held.panelChangedAt) return this.rejectArmAnyway(refusalId, "stale");
|
|
1966
|
+
if (read.kind !== "read") {
|
|
1967
|
+
const outcome = this.settleArm(held.mode, held.blocking.map((device) => ({
|
|
1968
|
+
device,
|
|
1969
|
+
why: "arm-anyway-unverified"
|
|
1970
|
+
})), now);
|
|
1971
|
+
if (read.kind === "check-failed") this.announceUncheckedArm(held.mode, read.error, now);
|
|
1972
|
+
return outcome;
|
|
1973
|
+
}
|
|
1974
|
+
const tolerated = this.nonBlockingFor(held.mode);
|
|
1975
|
+
const unknown = new Set(read.openings.unknown);
|
|
1976
|
+
if (read.verdict.kind === "refused") {
|
|
1977
|
+
const plain = decideArm({
|
|
1978
|
+
mode: held.mode,
|
|
1979
|
+
open: read.openings.open,
|
|
1980
|
+
nonBlocking: tolerated
|
|
1981
|
+
});
|
|
1982
|
+
const blockingNow = plain.kind === "refused" ? plain.blocking : read.verdict.blocking;
|
|
1983
|
+
const shownIds = new Set(blockingNow.map((d) => d.deviceId));
|
|
1984
|
+
const blocking = [...blockingNow, ...held.blocking.filter((d) => unknown.has(d.deviceId) && !shownIds.has(d.deviceId))];
|
|
1985
|
+
return this.refuse(held.mode, blocking, now);
|
|
1986
|
+
}
|
|
1987
|
+
return this.settleArm(held.mode, [...read.verdict.excluded.map((device) => ({
|
|
1988
|
+
device,
|
|
1989
|
+
why: tolerated.includes(device.deviceId) ? "non-blocking" : "arm-anyway"
|
|
1990
|
+
})), ...held.blocking.filter((device) => unknown.has(device.deviceId)).map((device) => ({
|
|
1991
|
+
device,
|
|
1992
|
+
why: "arm-anyway-unknown"
|
|
1993
|
+
}))], now);
|
|
1994
|
+
}
|
|
1995
|
+
/**
|
|
1514
1996
|
* Sensors the current arm is ignoring — what the armed/arming sentence names
|
|
1515
1997
|
* as excluded, and the only place that list is published.
|
|
1516
1998
|
*/
|
|
@@ -1518,26 +2000,54 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1518
2000
|
return this.exclusions.list();
|
|
1519
2001
|
}
|
|
1520
2002
|
/**
|
|
1521
|
-
*
|
|
1522
|
-
*
|
|
2003
|
+
* The same exclusions, each with its phase — `open` while the device is
|
|
2004
|
+
* still open as it was when armed, `closed` once it has been secured and
|
|
2005
|
+
* its next open re-arms it. What the live view shows; a read, never a copy.
|
|
2006
|
+
*/
|
|
2007
|
+
exclusionPhases() {
|
|
2008
|
+
return this.exclusions.snapshot().map((e) => ({
|
|
2009
|
+
deviceId: e.deviceId,
|
|
2010
|
+
phase: e.phase
|
|
2011
|
+
}));
|
|
2012
|
+
}
|
|
2013
|
+
/**
|
|
2014
|
+
* Disarm — the ONE path, for the cap and the `nc.disarmAlarm` bridge alike.
|
|
2015
|
+
* The exclusions belong to the ARM, and this one is over: keeping them would
|
|
2016
|
+
* carry an open window's exemption into tonight's arm, which nobody
|
|
2017
|
+
* confirmed.
|
|
2018
|
+
*/
|
|
2019
|
+
async disarm() {
|
|
2020
|
+
this.machine = disarm(Date.now());
|
|
2021
|
+
this.attribution.clear();
|
|
2022
|
+
this.exclusions.clear();
|
|
2023
|
+
this.publish();
|
|
2024
|
+
}
|
|
2025
|
+
/**
|
|
2026
|
+
* One opening's reading (`open` / `secure`, D630's vocabulary — a cover is
|
|
2027
|
+
* secure only when `closed`, a lock only when `locked`), from whoever saw
|
|
2028
|
+
* it: the sensor's own event, or the centre's reconcile tick.
|
|
1523
2029
|
*
|
|
1524
2030
|
* The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
|
|
1525
2031
|
* the same reading repeatedly, and an unreadable state changes nothing. Fed
|
|
1526
2032
|
* BEFORE the event that carried it is evaluated, so the open that re-arms a
|
|
1527
2033
|
* sensor is already lifted when the rule it fires reaches the panel.
|
|
1528
2034
|
*/
|
|
1529
|
-
|
|
2035
|
+
observeOpening(deviceId, reading, now = Date.now()) {
|
|
1530
2036
|
const change = this.exclusions.observe(deviceId, reading, now);
|
|
1531
2037
|
if (change === "unchanged") return;
|
|
1532
2038
|
this.persist();
|
|
1533
2039
|
if (change === "closed") return;
|
|
1534
|
-
this.ctx.logger.info("alarm exclusion lifted — the
|
|
2040
|
+
this.ctx.logger.info("alarm exclusion lifted — the device was secured and opened again", {
|
|
1535
2041
|
tags: { deviceId },
|
|
1536
2042
|
meta: {
|
|
1537
2043
|
panelDeviceId: this.id,
|
|
1538
2044
|
remaining: this.exclusions.list().length
|
|
1539
2045
|
}
|
|
1540
2046
|
});
|
|
2047
|
+
this.announceBypass({
|
|
2048
|
+
deviceId,
|
|
2049
|
+
event: "lifted"
|
|
2050
|
+
}, now);
|
|
1541
2051
|
}
|
|
1542
2052
|
/** Stop the countdown when the device goes away. The state itself is
|
|
1543
2053
|
* persisted, so nothing is lost — only the announcements stop. */
|
|
@@ -1545,108 +2055,166 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1545
2055
|
this.stopTicking();
|
|
1546
2056
|
}
|
|
1547
2057
|
/**
|
|
1548
|
-
*
|
|
1549
|
-
*
|
|
2058
|
+
* Ask the centre what is open and decide — or say why there is nothing to
|
|
2059
|
+
* decide with.
|
|
1550
2060
|
*
|
|
1551
2061
|
* Fail-OPEN by construction, in three places, and each one is a decision:
|
|
1552
2062
|
*
|
|
1553
2063
|
* - no guard wired (an agent node, a boot pass before the centre exists) ⇒
|
|
1554
|
-
* arm. A panel that cannot ask must not refuse.
|
|
1555
|
-
* - the
|
|
1556
|
-
*
|
|
1557
|
-
*
|
|
1558
|
-
*
|
|
1559
|
-
* -
|
|
1560
|
-
*
|
|
1561
|
-
*
|
|
1562
|
-
*
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
async applyArmGuard(mode, now) {
|
|
2064
|
+
* `unguarded`, arm. A panel that cannot ask must not refuse.
|
|
2065
|
+
* - the read threw (nothing could be read) ⇒ `check-failed`, arm, and SAY
|
|
2066
|
+
* so — a WARN here and, once the arm has LANDED, `announceCheckFailed` on
|
|
2067
|
+
* the operator's phone. Refusing on a failed read would send the operator
|
|
2068
|
+
* to shut a window that is already shut.
|
|
2069
|
+
* - a covered device whose state is UNKNOWN ⇒ never blocks and is never
|
|
2070
|
+
* counted secure (D49); the guard logs one line each, under its own id.
|
|
2071
|
+
*
|
|
2072
|
+
* `acknowledged` is what an Arm anyway confirms — the ids its refusal SHOWED.
|
|
2073
|
+
*/
|
|
2074
|
+
async applyArmGuard(mode, acknowledged) {
|
|
1566
2075
|
const guard = this.armGuard;
|
|
1567
|
-
if (guard === null) return;
|
|
1568
|
-
let
|
|
2076
|
+
if (guard === null) return { kind: "unguarded" };
|
|
2077
|
+
let openings;
|
|
1569
2078
|
try {
|
|
1570
|
-
|
|
2079
|
+
openings = await guard.openings(mode);
|
|
1571
2080
|
} catch (err) {
|
|
1572
|
-
this.ctx.logger.warn("alarm arm guard could not read the
|
|
2081
|
+
this.ctx.logger.warn("alarm arm guard could not read the openings — arming without the check", {
|
|
1573
2082
|
tags: { deviceId: this.id },
|
|
1574
2083
|
meta: {
|
|
1575
2084
|
mode,
|
|
1576
2085
|
error: String(err)
|
|
1577
2086
|
}
|
|
1578
2087
|
});
|
|
1579
|
-
return
|
|
2088
|
+
return {
|
|
2089
|
+
kind: "check-failed",
|
|
2090
|
+
error: String(err)
|
|
2091
|
+
};
|
|
1580
2092
|
}
|
|
1581
2093
|
const verdict = decideArm({
|
|
1582
2094
|
mode,
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
2095
|
+
open: openings.open,
|
|
2096
|
+
nonBlocking: this.nonBlockingFor(mode),
|
|
2097
|
+
...acknowledged !== void 0 ? { acknowledged } : {}
|
|
1586
2098
|
});
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
|
|
1593
|
-
|
|
1594
|
-
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2099
|
+
return {
|
|
2100
|
+
kind: "read",
|
|
2101
|
+
openings,
|
|
2102
|
+
verdict
|
|
2103
|
+
};
|
|
2104
|
+
}
|
|
2105
|
+
/**
|
|
2106
|
+
* The operator's copy of "armed WITHOUT the openings check" — called only
|
|
2107
|
+
* after {@link settleArm}, with the state the panel is really in now. Never
|
|
2108
|
+
* on a path that armed nothing: a stale Arm anyway whose re-read also failed
|
|
2109
|
+
* used to push "Armed … without checking" for an arm that never happened.
|
|
2110
|
+
*/
|
|
2111
|
+
announceUncheckedArm(mode, error, now) {
|
|
2112
|
+
this.armGuard?.announceCheckFailed({
|
|
2113
|
+
mode,
|
|
2114
|
+
state: stateAt(this.machine, now),
|
|
2115
|
+
at: now,
|
|
2116
|
+
error
|
|
2117
|
+
});
|
|
2118
|
+
}
|
|
2119
|
+
/** This mode's non-blocking ids, from the persisted lists. */
|
|
2120
|
+
nonBlockingFor(mode) {
|
|
2121
|
+
return nonBlockingFromPersisted(this.config.get("nonBlocking")).find((l) => l.mode === mode)?.deviceIds ?? [];
|
|
2122
|
+
}
|
|
2123
|
+
/**
|
|
2124
|
+
* Arm, with these exclusions.
|
|
2125
|
+
*
|
|
2126
|
+
* `null` exclusions (no guard, or nothing could be read on a plain arm) arm
|
|
2127
|
+
* and leave the exclusions as they were — there is no reading to replace them with.
|
|
2128
|
+
*/
|
|
2129
|
+
settleArm(mode, exclusions, now) {
|
|
2130
|
+
const excluded = (exclusions ?? []).map((e) => e.device);
|
|
2131
|
+
if (exclusions !== null) {
|
|
2132
|
+
if (excluded.length > 0) this.exclusions.exclude(excluded.map((d) => d.deviceId), now);
|
|
2133
|
+
else this.exclusions.clear();
|
|
1598
2134
|
this.persist();
|
|
1599
|
-
for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
|
|
1600
|
-
tags: { deviceId: contact.deviceId },
|
|
1601
|
-
meta: {
|
|
1602
|
-
panelDeviceId: this.id,
|
|
1603
|
-
mode,
|
|
1604
|
-
name: contact.name
|
|
1605
|
-
}
|
|
1606
|
-
});
|
|
1607
|
-
return;
|
|
1608
2135
|
}
|
|
1609
|
-
this.
|
|
2136
|
+
this.machine = arm(mode, this.delays, now);
|
|
2137
|
+
this.attribution.clear();
|
|
2138
|
+
this.publish(now);
|
|
2139
|
+
for (const { device, why } of exclusions ?? []) this.ctx.logger.info("alarm armed with an open device excluded", {
|
|
2140
|
+
tags: { deviceId: device.deviceId },
|
|
2141
|
+
meta: {
|
|
2142
|
+
panelDeviceId: this.id,
|
|
2143
|
+
mode,
|
|
2144
|
+
name: device.name,
|
|
2145
|
+
state: device.state,
|
|
2146
|
+
why
|
|
2147
|
+
}
|
|
2148
|
+
});
|
|
2149
|
+
return {
|
|
2150
|
+
outcome: stateAt(this.machine, now) === "arming" ? "arming" : "armed",
|
|
2151
|
+
excluded
|
|
2152
|
+
};
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Record the refusal, say it per device, and hand it to the operator's phone.
|
|
2156
|
+
*
|
|
2157
|
+
* The record REPLACES any earlier one — only the latest refusal's Arm anyway
|
|
2158
|
+
* can arm (an older notification's button is superseded).
|
|
2159
|
+
*/
|
|
2160
|
+
refuse(mode, blocking, now) {
|
|
2161
|
+
this.refusalSeq += 1;
|
|
2162
|
+
const refusal = {
|
|
2163
|
+
id: `refusal:${String(this.id)}:${mode}:${String(now)}:${String(this.refusalSeq)}`,
|
|
1610
2164
|
mode,
|
|
1611
|
-
|
|
2165
|
+
blockingIds: blocking.map((d) => d.deviceId),
|
|
2166
|
+
blocking,
|
|
2167
|
+
at: now,
|
|
2168
|
+
panelChangedAt: this.machine.changedAt
|
|
1612
2169
|
};
|
|
1613
|
-
|
|
1614
|
-
|
|
2170
|
+
this.refusal = refusal;
|
|
2171
|
+
for (const d of blocking) this.ctx.logger.warn("alarm arm REFUSED — this device is open", {
|
|
2172
|
+
tags: { deviceId: d.deviceId },
|
|
1615
2173
|
meta: {
|
|
1616
2174
|
panelDeviceId: this.id,
|
|
1617
2175
|
mode,
|
|
1618
|
-
name:
|
|
2176
|
+
name: d.name,
|
|
2177
|
+
state: d.state,
|
|
2178
|
+
refusalId: refusal.id
|
|
1619
2179
|
}
|
|
1620
2180
|
});
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
blocking
|
|
1624
|
-
|
|
2181
|
+
this.armGuard?.announceRefusal({
|
|
2182
|
+
refusal,
|
|
2183
|
+
blocking,
|
|
2184
|
+
state: stateAt(this.machine, now),
|
|
1625
2185
|
at: now
|
|
1626
2186
|
});
|
|
1627
|
-
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
2187
|
+
return {
|
|
2188
|
+
outcome: "refused",
|
|
2189
|
+
refusalId: refusal.id,
|
|
2190
|
+
blocking
|
|
2191
|
+
};
|
|
2192
|
+
}
|
|
2193
|
+
/** A pressed Arm anyway that did nothing — refused work, so it says so. */
|
|
2194
|
+
rejectArmAnyway(refusalId, reason) {
|
|
2195
|
+
this.ctx.logger.info("alarm Arm anyway rejected — nothing was armed", {
|
|
2196
|
+
tags: { deviceId: this.id },
|
|
2197
|
+
meta: {
|
|
2198
|
+
refusalId,
|
|
2199
|
+
reason
|
|
2200
|
+
}
|
|
1631
2201
|
});
|
|
2202
|
+
return {
|
|
2203
|
+
outcome: "rejected",
|
|
2204
|
+
reason
|
|
2205
|
+
};
|
|
1632
2206
|
}
|
|
1633
2207
|
registerAlarmCap() {
|
|
1634
2208
|
this.ctx.registerNativeCap(require_dist.alarmPanelCapability, {
|
|
1635
2209
|
getStatus: async () => this.status(),
|
|
1636
2210
|
arm: async ({ mode }) => {
|
|
1637
|
-
const
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
},
|
|
1643
|
-
disarm: async () => {
|
|
1644
|
-
this.machine = disarm(Date.now());
|
|
1645
|
-
this.attribution.clear();
|
|
1646
|
-
this.exclusions.clear();
|
|
1647
|
-
this.lastRefusal = null;
|
|
1648
|
-
this.publish();
|
|
2211
|
+
const outcome = await this.armWith(mode);
|
|
2212
|
+
if (outcome.outcome === "refused") throw new NcAlarmArmRefusedError({
|
|
2213
|
+
mode,
|
|
2214
|
+
blocking: outcome.blocking
|
|
2215
|
+
});
|
|
1649
2216
|
},
|
|
2217
|
+
disarm: async () => this.disarm(),
|
|
1650
2218
|
trigger: async () => {
|
|
1651
2219
|
const now = Date.now();
|
|
1652
2220
|
const pending = this.attribution.pending(now);
|
|
@@ -1662,13 +2230,15 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1662
2230
|
state: stateAt(this.machine, now)
|
|
1663
2231
|
}
|
|
1664
2232
|
});
|
|
2233
|
+
this.announceIgnoredTrigger(source, now);
|
|
1665
2234
|
return;
|
|
1666
2235
|
}
|
|
1667
2236
|
const next = trigger(this.machine, this.delays, now);
|
|
1668
2237
|
if (next === this.machine) {
|
|
1669
|
-
|
|
2238
|
+
const state = stateAt(this.machine, now);
|
|
2239
|
+
this.ctx.logger.info(ignoredTriggerMessage(state), {
|
|
1670
2240
|
tags: { deviceId: this.id },
|
|
1671
|
-
meta: { state
|
|
2241
|
+
meta: { state }
|
|
1672
2242
|
});
|
|
1673
2243
|
return;
|
|
1674
2244
|
}
|
|
@@ -1724,18 +2294,21 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1724
2294
|
if (hook === null) return;
|
|
1725
2295
|
const source = this.attribution.current();
|
|
1726
2296
|
const arming = exitDelayAt(this.machine, now);
|
|
2297
|
+
const pending = pendingAt(this.machine, now);
|
|
1727
2298
|
const transition = alarmTransition({
|
|
1728
2299
|
previous,
|
|
1729
2300
|
next: state,
|
|
1730
2301
|
modes: NC_ALARM_MODES,
|
|
1731
2302
|
at: now,
|
|
1732
2303
|
...source !== null ? { source } : {},
|
|
1733
|
-
...arming !== void 0 ? { arming } : {}
|
|
2304
|
+
...arming !== void 0 ? { arming } : {},
|
|
2305
|
+
...pending !== void 0 ? { pending } : {}
|
|
1734
2306
|
});
|
|
1735
2307
|
if (transition?.kind === "alarm-triggered") this.attribution.clear();
|
|
1736
2308
|
try {
|
|
1737
2309
|
hook({
|
|
1738
2310
|
state,
|
|
2311
|
+
target: this.machine.target,
|
|
1739
2312
|
transition
|
|
1740
2313
|
});
|
|
1741
2314
|
} catch (err) {
|
|
@@ -1749,6 +2322,70 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
|
|
|
1749
2322
|
});
|
|
1750
2323
|
}
|
|
1751
2324
|
}
|
|
2325
|
+
/**
|
|
2326
|
+
* A dropped trigger is TOLD, not only logged — an intrusion the alarm
|
|
2327
|
+
* deliberately did not act on is what the operator most needs to hear — but
|
|
2328
|
+
* ONCE per sensor per arm (the mark rides the persisted exclusion), and only
|
|
2329
|
+
* while the panel is armed: during the exit delay `trigger()` would have
|
|
2330
|
+
* dropped it anyway, so the exclusion is not what ignored it.
|
|
2331
|
+
*/
|
|
2332
|
+
announceIgnoredTrigger(deviceId, now) {
|
|
2333
|
+
if (!isArmed(this.machine, now)) {
|
|
2334
|
+
this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
|
|
2335
|
+
tags: { deviceId },
|
|
2336
|
+
meta: {
|
|
2337
|
+
panelDeviceId: this.id,
|
|
2338
|
+
state: stateAt(this.machine, now)
|
|
2339
|
+
}
|
|
2340
|
+
});
|
|
2341
|
+
return;
|
|
2342
|
+
}
|
|
2343
|
+
if (!this.exclusions.markAnnounced(deviceId)) {
|
|
2344
|
+
this.ctx.logger.debug("alarm bypass already announced in this arm — repeat not sent", {
|
|
2345
|
+
tags: { deviceId },
|
|
2346
|
+
meta: { panelDeviceId: this.id }
|
|
2347
|
+
});
|
|
2348
|
+
return;
|
|
2349
|
+
}
|
|
2350
|
+
this.persist();
|
|
2351
|
+
this.announceBypass({
|
|
2352
|
+
deviceId,
|
|
2353
|
+
event: "ignored"
|
|
2354
|
+
}, now);
|
|
2355
|
+
}
|
|
2356
|
+
/**
|
|
2357
|
+
* Report a sensor the current arm ignored, or stopped ignoring, through the
|
|
2358
|
+
* publish hook — a report whose state is UNCHANGED, so `lastPublished` is
|
|
2359
|
+
* not touched and the next real transition is still computed from it.
|
|
2360
|
+
*/
|
|
2361
|
+
announceBypass(sensor, now) {
|
|
2362
|
+
const hook = this.onPublish;
|
|
2363
|
+
if (hook === null) return;
|
|
2364
|
+
const state = stateAt(this.machine, now);
|
|
2365
|
+
const transition = {
|
|
2366
|
+
kind: "alarm-sensor-bypass",
|
|
2367
|
+
state,
|
|
2368
|
+
previous: state,
|
|
2369
|
+
at: now,
|
|
2370
|
+
sensor
|
|
2371
|
+
};
|
|
2372
|
+
try {
|
|
2373
|
+
hook({
|
|
2374
|
+
state,
|
|
2375
|
+
target: this.machine.target,
|
|
2376
|
+
transition
|
|
2377
|
+
});
|
|
2378
|
+
} catch (err) {
|
|
2379
|
+
this.ctx.logger.warn("alarm publish hook failed", {
|
|
2380
|
+
tags: { deviceId: this.id },
|
|
2381
|
+
meta: {
|
|
2382
|
+
state,
|
|
2383
|
+
transition: transition.kind,
|
|
2384
|
+
error: String(err)
|
|
2385
|
+
}
|
|
2386
|
+
});
|
|
2387
|
+
}
|
|
2388
|
+
}
|
|
1752
2389
|
/** The three durations, read from the persisted config. */
|
|
1753
2390
|
readDelays() {
|
|
1754
2391
|
return {
|
|
@@ -1811,6 +2448,25 @@ var NC_ALARM_DECLARATION = {
|
|
|
1811
2448
|
DeviceClass: NcAlarmPanelDevice
|
|
1812
2449
|
};
|
|
1813
2450
|
//#endregion
|
|
2451
|
+
//#region src/notification-center/alarm/alarm-panel-port.ts
|
|
2452
|
+
function ncAlarmPanelPort(panel) {
|
|
2453
|
+
return {
|
|
2454
|
+
deviceId: panel.id,
|
|
2455
|
+
settings: () => panel.settings(),
|
|
2456
|
+
applySettings: (patch) => panel.applySettings(patch),
|
|
2457
|
+
availableModes: () => NC_ALARM_MODES,
|
|
2458
|
+
noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
|
|
2459
|
+
excludedDevices: () => panel.excludedDevices(),
|
|
2460
|
+
exclusionPhases: () => panel.exclusionPhases(),
|
|
2461
|
+
currentState: () => panel.currentState(),
|
|
2462
|
+
currentTarget: () => panel.currentTarget(),
|
|
2463
|
+
armWith: (mode) => panel.armWith(mode),
|
|
2464
|
+
armAnyway: (refusalId) => panel.armAnyway(refusalId),
|
|
2465
|
+
disarm: () => panel.disarm(),
|
|
2466
|
+
observeOpening: (deviceId, reading, now) => panel.observeOpening(deviceId, reading, now)
|
|
2467
|
+
};
|
|
2468
|
+
}
|
|
2469
|
+
//#endregion
|
|
1814
2470
|
//#region src/notification-center/artifact-index.ts
|
|
1815
2471
|
/**
|
|
1816
2472
|
* @durable class=ledger owner=notification-center
|
|
@@ -7111,10 +7767,10 @@ function replaceConditionReferences(monitor, condition, reference) {
|
|
|
7111
7767
|
//#endregion
|
|
7112
7768
|
//#region src/notification-center/snooze-exempt.ts
|
|
7113
7769
|
/** The cap whose presence in a sequence makes the rule the alarm's trigger. */
|
|
7114
|
-
var ALARM_PANEL_CAP$
|
|
7770
|
+
var ALARM_PANEL_CAP$2 = "alarm-panel";
|
|
7115
7771
|
var ALARM_KINDS = new Set(require_dist.NC_ALARM_SYSTEM_EVENT_KINDS);
|
|
7116
7772
|
function actuatesAlarmPanel(sequences) {
|
|
7117
|
-
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$
|
|
7773
|
+
for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
|
|
7118
7774
|
return false;
|
|
7119
7775
|
}
|
|
7120
7776
|
/**
|
|
@@ -7265,10 +7921,11 @@ var MAX_ARM_BUTTONS = 3;
|
|
|
7265
7921
|
*
|
|
7266
7922
|
* | notification | buttons |
|
|
7267
7923
|
* | --- | --- |
|
|
7268
|
-
* | `alarm-arming` | **Disarma**, destructive |
|
|
7269
|
-
* | `alarm-triggered` | **Disarma**, destructive |
|
|
7270
|
-
* | `alarm-
|
|
7271
|
-
* | `alarm-
|
|
7924
|
+
* | `alarm-arming`, `alarm-armed`, `alarm-pending` | **Disarma**, destructive |
|
|
7925
|
+
* | `alarm-triggered`, `alarm-rearmed`, `alarm-check-failed` | **Disarma**, destructive |
|
|
7926
|
+
* | `alarm-disarmed` | **Arma …** per declared (enabled) mode, max three |
|
|
7927
|
+
* | `alarm-arm-refused` | **Arma comunque**, bound to the row's refusal |
|
|
7928
|
+
* | `alarm-sensor-bypass` | nothing — it reports, there is nothing to undo |
|
|
7272
7929
|
*
|
|
7273
7930
|
* The `alarm-arming` row is the one that earns its keep: that notification
|
|
7274
7931
|
* arrives at the START of the exit delay, so its Disarm is the only alarm
|
|
@@ -7283,10 +7940,15 @@ var MAX_ARM_BUTTONS = 3;
|
|
|
7283
7940
|
* browser. The disarm is `navigate`: it removes a protection, and the operator
|
|
7284
7941
|
* doing it deserves to SEE that it happened rather than trust a POST nobody
|
|
7285
7942
|
* confirmed.
|
|
7943
|
+
*
|
|
7944
|
+
* `panel.availableModes` ARE the enabled modes: CamStack has no per-mode
|
|
7945
|
+
* disable setting, so the Arm buttons offer exactly what the panel declares.
|
|
7286
7946
|
*/
|
|
7287
7947
|
function buildAlarmButtons(input) {
|
|
7288
7948
|
const panel = input.panel;
|
|
7289
7949
|
if (panel === null) return [];
|
|
7950
|
+
if (input.kind === "alarm-sensor-bypass") return [];
|
|
7951
|
+
if (input.kind === "alarm-arm-refused") return armAnywayButton(input.refusalId, input);
|
|
7290
7952
|
if (input.kind === "alarm-disarmed") return panel.availableModes.slice(0, MAX_ARM_BUTTONS).map((mode) => {
|
|
7291
7953
|
const actionId = `alarm-arm-${mode}`;
|
|
7292
7954
|
return {
|
|
@@ -7314,6 +7976,28 @@ function buildAlarmButtons(input) {
|
|
|
7314
7976
|
mode: "navigate"
|
|
7315
7977
|
}];
|
|
7316
7978
|
}
|
|
7979
|
+
/**
|
|
7980
|
+
* The one Arm anyway a refusal notification carries — or none, when the row
|
|
7981
|
+
* names no refusal. `navigate` and destructive, like the disarm: it arms
|
|
7982
|
+
* AROUND an open door, and the person doing it should see it happen. Its TTL
|
|
7983
|
+
* is the refusal's own (`ARM_ANYWAY_TTL_MS`, priced by the minter).
|
|
7984
|
+
*/
|
|
7985
|
+
function armAnywayButton(refusalId, input) {
|
|
7986
|
+
if (refusalId === void 0) return [];
|
|
7987
|
+
const actionId = "alarm-arm-anyway";
|
|
7988
|
+
return [{
|
|
7989
|
+
id: actionId,
|
|
7990
|
+
label: input.labels.armAnyway,
|
|
7991
|
+
url: input.mintUrl({
|
|
7992
|
+
actionId,
|
|
7993
|
+
action: "arm-anyway",
|
|
7994
|
+
refusalId
|
|
7995
|
+
}),
|
|
7996
|
+
icon: "arm",
|
|
7997
|
+
destructive: true,
|
|
7998
|
+
mode: "navigate"
|
|
7999
|
+
}];
|
|
8000
|
+
}
|
|
7317
8001
|
//#endregion
|
|
7318
8002
|
//#region src/notification-center/addon-update-apply.ts
|
|
7319
8003
|
/**
|
|
@@ -7376,6 +8060,80 @@ function buildAddonUpdateButtons(input) {
|
|
|
7376
8060
|
}];
|
|
7377
8061
|
}
|
|
7378
8062
|
//#endregion
|
|
8063
|
+
//#region src/notification-center/alarm/alarm-gate-words.ts
|
|
8064
|
+
/** The states that belong to an armed mode without being it. */
|
|
8065
|
+
var INCIDENT_STATES = new Set(["pending", "triggered"]);
|
|
8066
|
+
/** `[state]`, plus `target` while `state` is `pending`/`triggered` of an armed mode. */
|
|
8067
|
+
function alarmGateWords(state, target) {
|
|
8068
|
+
if (INCIDENT_STATES.has(state) && target.startsWith("armed_")) return [state, target];
|
|
8069
|
+
return [state];
|
|
8070
|
+
}
|
|
8071
|
+
/**
|
|
8072
|
+
* The engine's lookup: every device answers its one mirrored word, and the
|
|
8073
|
+
* panel answers its gate words — but only while the mirror AGREES on the state.
|
|
8074
|
+
*
|
|
8075
|
+
* The mirror keeps the freshness policy (an expired or cold entry is
|
|
8076
|
+
* `undefined`, which fails the gate closed — D49); the alias only widens an
|
|
8077
|
+
* answer the mirror already gives. A target paired with a state it was not
|
|
8078
|
+
* published with would be a guess, so a disagreement answers the mirror alone.
|
|
8079
|
+
*/
|
|
8080
|
+
function panelAwareLookup(read, panel) {
|
|
8081
|
+
return (deviceId) => {
|
|
8082
|
+
const state = read(deviceId);
|
|
8083
|
+
if (state === void 0) return void 0;
|
|
8084
|
+
if (panel !== null && panel.deviceId === deviceId && sameWord(panel.words[0], state)) return panel.words;
|
|
8085
|
+
return [state];
|
|
8086
|
+
};
|
|
8087
|
+
}
|
|
8088
|
+
function sameWord(a, b) {
|
|
8089
|
+
return a !== void 0 && a.trim().toLowerCase() === b.trim().toLowerCase();
|
|
8090
|
+
}
|
|
8091
|
+
//#endregion
|
|
8092
|
+
//#region src/notification-center/alarm/alarm-live.ts
|
|
8093
|
+
/** Every covered id across all modes, ascending, once — what the one read asks for. */
|
|
8094
|
+
function alarmLiveReadIds(covered) {
|
|
8095
|
+
const ids = /* @__PURE__ */ new Set();
|
|
8096
|
+
for (const list of covered.values()) for (const id of list) ids.add(id);
|
|
8097
|
+
return [...ids].toSorted((a, b) => a - b);
|
|
8098
|
+
}
|
|
8099
|
+
function modeOpenings(input, mode) {
|
|
8100
|
+
const tolerated = new Set(input.nonBlocking.find((l) => l.mode === mode)?.deviceIds ?? []);
|
|
8101
|
+
const devices = [];
|
|
8102
|
+
const unknown = [];
|
|
8103
|
+
for (const deviceId of input.covered.get(mode) ?? []) {
|
|
8104
|
+
const verdict = classifyCoveredOpening(input.openings.get(deviceId));
|
|
8105
|
+
if (verdict.kind === "unknown") {
|
|
8106
|
+
unknown.push(deviceId);
|
|
8107
|
+
continue;
|
|
8108
|
+
}
|
|
8109
|
+
if (verdict.kind === "skip") continue;
|
|
8110
|
+
devices.push({
|
|
8111
|
+
deviceId,
|
|
8112
|
+
name: input.name(deviceId),
|
|
8113
|
+
state: verdict.state,
|
|
8114
|
+
blocking: !tolerated.has(deviceId)
|
|
8115
|
+
});
|
|
8116
|
+
}
|
|
8117
|
+
return {
|
|
8118
|
+
mode,
|
|
8119
|
+
devices,
|
|
8120
|
+
unknown
|
|
8121
|
+
};
|
|
8122
|
+
}
|
|
8123
|
+
function buildAlarmLive(input) {
|
|
8124
|
+
return {
|
|
8125
|
+
state: input.state,
|
|
8126
|
+
targetMode: armModeOf(input.target, input.modes),
|
|
8127
|
+
availableModes: [...input.modes],
|
|
8128
|
+
openings: input.modes.map((mode) => modeOpenings(input, mode)),
|
|
8129
|
+
exclusions: input.exclusions.map((e) => ({
|
|
8130
|
+
deviceId: e.deviceId,
|
|
8131
|
+
name: input.name(e.deviceId),
|
|
8132
|
+
phase: e.phase
|
|
8133
|
+
}))
|
|
8134
|
+
};
|
|
8135
|
+
}
|
|
8136
|
+
//#endregion
|
|
7379
8137
|
//#region src/notification-center/alerts/alert-store.ts
|
|
7380
8138
|
/**
|
|
7381
8139
|
* @durable class=ledger owner=notification-center
|
|
@@ -8070,6 +8828,52 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
|
|
|
8070
8828
|
return insidePolygon / totalMaskPixels;
|
|
8071
8829
|
}
|
|
8072
8830
|
//#endregion
|
|
8831
|
+
//#region src/notification-center/alarm/alarm-opening-edge.ts
|
|
8832
|
+
/**
|
|
8833
|
+
* Which way an OPENING moved, read off the slice one sensor event carries
|
|
8834
|
+
* (D630 "Alarm rules fire on the opening edge").
|
|
8835
|
+
*
|
|
8836
|
+
* A rule that belongs to the alarm fires when an opening OPENS — a contact
|
|
8837
|
+
* opens, a cover leaves closed, a lock unlocks — and never when it closes.
|
|
8838
|
+
*
|
|
8839
|
+
* Stateless on purpose: one state change is N SensorEvent rows (one per linked
|
|
8840
|
+
* camera), and a "previous reading" tracker would call the first row an edge
|
|
8841
|
+
* and the other N−1 steady — camera 2's alarm rule would lose the event. The
|
|
8842
|
+
* slice already says which way the device went, and the transition gate
|
|
8843
|
+
* upstream (`sensor-transition-gate.ts`) only lets a row through when the level
|
|
8844
|
+
* (the boolean, or a cover's state WORD) actually changed.
|
|
8845
|
+
*
|
|
8846
|
+
* What is open and what is secure is NOT decided here: it is
|
|
8847
|
+
* {@link alarmOpeningFromSensorEvent}'s table, the same one the arm check
|
|
8848
|
+
* reads. This file adds only the DIRECTION of a word that table calls open:
|
|
8849
|
+
* a word travelling towards secure (`closing`, `locking`) is a closing edge,
|
|
8850
|
+
* a halt or a fault (`stopped`, `jammed`) is `neither` — not an entry, so an
|
|
8851
|
+
* alarm rule does not fire on it, though the arm check still counts it as open
|
|
8852
|
+
* (a different question: "not secure" vs "just opened").
|
|
8853
|
+
*
|
|
8854
|
+
* `undefined` = this kind has no opening meaning, or the slice is unreadable
|
|
8855
|
+
* (D49 — never guessed). The caller then does not filter at all.
|
|
8856
|
+
*/
|
|
8857
|
+
/** Open words (per the openings table) that are travelling towards secure. */
|
|
8858
|
+
var TOWARD_SECURE = new Set(["closing", "locking"]);
|
|
8859
|
+
/** Open words that are a halt or a fault: neither an entry nor a close. */
|
|
8860
|
+
var HALTED = new Set(["stopped", "jammed"]);
|
|
8861
|
+
/** Open words that ARE the opening: leaving closed / leaving locked, or a contact apart. */
|
|
8862
|
+
var OPENING = new Set([
|
|
8863
|
+
"open",
|
|
8864
|
+
"opening",
|
|
8865
|
+
"unlocked",
|
|
8866
|
+
"unlocking"
|
|
8867
|
+
]);
|
|
8868
|
+
function sensorEdgeOf(kind, value) {
|
|
8869
|
+
const opening = alarmOpeningFromSensorEvent(kind, value);
|
|
8870
|
+
if (opening.kind !== "reading") return void 0;
|
|
8871
|
+
if (opening.reading === "secure") return "closing";
|
|
8872
|
+
if (TOWARD_SECURE.has(opening.state)) return "closing";
|
|
8873
|
+
if (HALTED.has(opening.state)) return "neither";
|
|
8874
|
+
if (OPENING.has(opening.state)) return "opening";
|
|
8875
|
+
}
|
|
8876
|
+
//#endregion
|
|
8073
8877
|
//#region src/notification-center/audio-rule-matcher.ts
|
|
8074
8878
|
/**
|
|
8075
8879
|
* AudioWatcher — the Notification Center's matcher for audio rules
|
|
@@ -8545,6 +9349,7 @@ function readSensorEventType(value) {
|
|
|
8545
9349
|
*/
|
|
8546
9350
|
function subjectFromSensorEvent(ev, markerTrackId) {
|
|
8547
9351
|
const eventType = readSensorEventType(ev.value);
|
|
9352
|
+
const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
|
|
8548
9353
|
return {
|
|
8549
9354
|
kind: "device-event",
|
|
8550
9355
|
recordId: String(ev.id),
|
|
@@ -8556,7 +9361,20 @@ function subjectFromSensorEvent(ev, markerTrackId) {
|
|
|
8556
9361
|
zones: [],
|
|
8557
9362
|
source: "sensor",
|
|
8558
9363
|
sensorKind: ev.kind,
|
|
8559
|
-
...eventType !== void 0 ? { eventType } : {}
|
|
9364
|
+
...eventType !== void 0 ? { eventType } : {},
|
|
9365
|
+
...sensorEdge !== void 0 ? { sensorEdge } : {}
|
|
9366
|
+
};
|
|
9367
|
+
}
|
|
9368
|
+
/**
|
|
9369
|
+
* Build the subject for a sensor no camera takes a row for (D630 §6): the
|
|
9370
|
+
* sensor is its own device (`deviceId === sourceDeviceId`, so no
|
|
9371
|
+
* `sourceDeviceId` field) and there is no marker track — nothing owns a frame
|
|
9372
|
+
* of it. `linkedCameraIds` carries every camera linking it (see the field).
|
|
9373
|
+
*/
|
|
9374
|
+
function subjectFromUnownedSensorEvent(ev, linkedCameraIds) {
|
|
9375
|
+
return {
|
|
9376
|
+
...subjectFromSensorEvent(ev),
|
|
9377
|
+
linkedCameraIds: [...linkedCameraIds]
|
|
8560
9378
|
};
|
|
8561
9379
|
}
|
|
8562
9380
|
/**
|
|
@@ -8902,6 +9720,37 @@ function matchesIdentityScope(subject, scope) {
|
|
|
8902
9720
|
function ruleAwaitsRecognition(rule) {
|
|
8903
9721
|
return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
|
|
8904
9722
|
}
|
|
9723
|
+
var ARMED_WORD_PREFIX = "armed_";
|
|
9724
|
+
var ALARM_PANEL_CAP$1 = "alarm-panel";
|
|
9725
|
+
var ALARM_PANEL_TRIGGER_METHOD = "trigger";
|
|
9726
|
+
/**
|
|
9727
|
+
* A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
|
|
9728
|
+
* `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
|
|
9729
|
+
* alarm panel — or when an ENABLED `onTrigger` sequence pulls the panel's
|
|
9730
|
+
* trigger itself. Such a rule fires on an opening's OPENING edge only: closing
|
|
9731
|
+
* a window while armed is not an intrusion (D630).
|
|
9732
|
+
*
|
|
9733
|
+
* This is NOT the arm check's coverage predicate (`alarm-mode-coverage.ts`):
|
|
9734
|
+
* it ignores which device the gate names, which mode, whether the rule is
|
|
9735
|
+
* enabled and what it is scoped to. It answers only "should this rule refuse
|
|
9736
|
+
* a closing edge", and erring wide there costs a closing notification nobody
|
|
9737
|
+
* gated on an armed word would have wanted anyway.
|
|
9738
|
+
*
|
|
9739
|
+
* Derived, never a stored flag: both rule editors prune condition keys to the
|
|
9740
|
+
* catalog on save, so a new key would be dropped by the first edit.
|
|
9741
|
+
*/
|
|
9742
|
+
function ruleBelongsToAlarm(rule) {
|
|
9743
|
+
if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
|
|
9744
|
+
return (rule.actions?.onTrigger ?? []).some((sequence) => sequence.enabled && sequence.actions.some((step) => step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1 && step.method === ALARM_PANEL_TRIGGER_METHOD));
|
|
9745
|
+
}
|
|
9746
|
+
/**
|
|
9747
|
+
* The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
|
|
9748
|
+
* after it already opened would otherwise fire twice; after a close it would
|
|
9749
|
+
* fire as if it had opened; a jammed lock is a fault, not an entry).
|
|
9750
|
+
*/
|
|
9751
|
+
function isRefusedAlarmEdge(edge) {
|
|
9752
|
+
return edge === "closing" || edge === "neither";
|
|
9753
|
+
}
|
|
8905
9754
|
/**
|
|
8906
9755
|
* Does this rule stop firing on the object event and fire at TRACK CLOSE?
|
|
8907
9756
|
*
|
|
@@ -9052,10 +9901,12 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
|
|
|
9052
9901
|
}
|
|
9053
9902
|
const c = rule.conditions;
|
|
9054
9903
|
if (c.deviceState !== void 0) {
|
|
9055
|
-
const
|
|
9056
|
-
if (
|
|
9057
|
-
|
|
9904
|
+
const words = deviceState?.(c.deviceState.deviceId);
|
|
9905
|
+
if (words === void 0 || words.length === 0) return fail("deviceState");
|
|
9906
|
+
const wanted = toLowerSet(c.deviceState.states);
|
|
9907
|
+
if (!words.some((w) => wanted.has(w.trim().toLowerCase()))) return fail("deviceState");
|
|
9058
9908
|
}
|
|
9909
|
+
if (subject.kind === "device-event" && isRefusedAlarmEdge(subject.sensorEdge) && ruleBelongsToAlarm(rule)) return fail("sensorEdge");
|
|
9059
9910
|
if (c.scene !== void 0) {
|
|
9060
9911
|
const observation = sceneStates?.(c.scene.sceneId);
|
|
9061
9912
|
if (observation === void 0) return fail("scene");
|
|
@@ -9480,6 +10331,26 @@ function incomingFromSensorEvent(event, markerTrackId) {
|
|
|
9480
10331
|
};
|
|
9481
10332
|
}
|
|
9482
10333
|
/**
|
|
10334
|
+
* A sensor no camera takes a timeline row for (D630 §6) — evaluated, never
|
|
10335
|
+
* persisted. The sensor is its own device and there is no marker: a
|
|
10336
|
+
* text-only notification is the design here, not a degradation.
|
|
10337
|
+
*/
|
|
10338
|
+
function incomingFromUnownedSensorEvent(event, linkedCameraIds) {
|
|
10339
|
+
return {
|
|
10340
|
+
subject: subjectFromUnownedSensorEvent(event, linkedCameraIds),
|
|
10341
|
+
kind: "device-event",
|
|
10342
|
+
origin: "sensor",
|
|
10343
|
+
log: () => ({
|
|
10344
|
+
tags: { deviceId: event.deviceId },
|
|
10345
|
+
meta: {
|
|
10346
|
+
sensorEventId: event.id,
|
|
10347
|
+
kind: event.kind,
|
|
10348
|
+
linkedCameraIds: [...linkedCameraIds]
|
|
10349
|
+
}
|
|
10350
|
+
})
|
|
10351
|
+
};
|
|
10352
|
+
}
|
|
10353
|
+
/**
|
|
9483
10354
|
* Audio-event persist (classified episode).
|
|
9484
10355
|
*
|
|
9485
10356
|
* `stillOwnerId` is the audio still shelf's owner id for the photograph taken
|
|
@@ -10393,7 +11264,8 @@ function unique(values) {
|
|
|
10393
11264
|
* therefore keeps the alarm's own sentence, which is also what every row
|
|
10394
11265
|
* enqueued before this deploy carries.
|
|
10395
11266
|
*/
|
|
10396
|
-
function combinedAlarmText(source) {
|
|
11267
|
+
function combinedAlarmText(kind, source) {
|
|
11268
|
+
if (kind !== "alarm-triggered") return null;
|
|
10397
11269
|
if (source?.className === void 0) return null;
|
|
10398
11270
|
return {
|
|
10399
11271
|
titleKey: "alarm.combined.title",
|
|
@@ -10401,6 +11273,43 @@ function combinedAlarmText(source) {
|
|
|
10401
11273
|
};
|
|
10402
11274
|
}
|
|
10403
11275
|
/**
|
|
11276
|
+
* The title and body keys an alarm row carries — ONE function, so the
|
|
11277
|
+
* catalog spec (`alarm-texts.spec.ts`) enumerates exactly what the intake can
|
|
11278
|
+
* select.
|
|
11279
|
+
*
|
|
11280
|
+
* VARIANT SELECTION AT INTAKE, like every other system sentence: the mode is
|
|
11281
|
+
* part of the words ("Away armed"), not a fragment substituted into them,
|
|
11282
|
+
* because the catalog renders in ONE pass and a `{{mode}}` filled with `away`
|
|
11283
|
+
* would deliver the enum value to a person.
|
|
11284
|
+
*/
|
|
11285
|
+
function alarmTextKeys(transition, body) {
|
|
11286
|
+
const { kind, source } = transition;
|
|
11287
|
+
const combined = combinedAlarmText(kind, source);
|
|
11288
|
+
if (combined !== null) return combined;
|
|
11289
|
+
const titleKey = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
|
|
11290
|
+
if (body !== void 0) return {
|
|
11291
|
+
titleKey,
|
|
11292
|
+
bodyKey: body.bodyKey
|
|
11293
|
+
};
|
|
11294
|
+
if (kind === "alarm-sensor-bypass" && transition.sensor !== void 0) return {
|
|
11295
|
+
titleKey,
|
|
11296
|
+
bodyKey: `system.${kind}.body.${transition.sensor.event}`
|
|
11297
|
+
};
|
|
11298
|
+
if (kind === "alarm-disarmed" && (transition.previous === "triggered" || transition.previous === "pending")) return {
|
|
11299
|
+
titleKey,
|
|
11300
|
+
bodyKey: `system.${kind}.body.defused`
|
|
11301
|
+
};
|
|
11302
|
+
return {
|
|
11303
|
+
titleKey,
|
|
11304
|
+
bodyKey: source !== void 0 && (kind === "alarm-triggered" || kind === "alarm-pending") ? `system.${kind}.body.rule` : `system.${kind}.body`
|
|
11305
|
+
};
|
|
11306
|
+
}
|
|
11307
|
+
/** Whose event the row is — see {@link incomingFromAlarmTransition}. */
|
|
11308
|
+
function alarmSubjectDevice(transition, panelDeviceId) {
|
|
11309
|
+
if (transition.kind === "alarm-sensor-bypass" && transition.sensor !== void 0) return transition.sensor.deviceId;
|
|
11310
|
+
return transition.source?.deviceId ?? panelDeviceId;
|
|
11311
|
+
}
|
|
11312
|
+
/**
|
|
10404
11313
|
* The panel's own transition, as a system-event subject.
|
|
10405
11314
|
*
|
|
10406
11315
|
* IN-PROCESS, not off the bus. The alarm and the Notification Center live in
|
|
@@ -10416,42 +11325,53 @@ function combinedAlarmText(source) {
|
|
|
10416
11325
|
* With one, it is about the CAMERA that set it off: that is the media owner,
|
|
10417
11326
|
* the name the body reads out ("ALLARME — Ingresso"), and the device a rule
|
|
10418
11327
|
* scoping `deviceIds` means. One sentence, and both halves of it are testable.
|
|
11328
|
+
* A SENSOR BYPASS is about the sensor — its name is the title.
|
|
10419
11329
|
*/
|
|
10420
11330
|
function incomingFromAlarmTransition(input) {
|
|
10421
11331
|
const { transition, panelDeviceId } = input;
|
|
10422
11332
|
const source = transition.source;
|
|
10423
|
-
const deviceId =
|
|
11333
|
+
const deviceId = alarmSubjectDevice(transition, panelDeviceId);
|
|
10424
11334
|
const identity = input.lookup(deviceId);
|
|
10425
11335
|
const kind = transition.kind;
|
|
10426
|
-
const
|
|
11336
|
+
const keys = alarmTextKeys(transition, input.body);
|
|
11337
|
+
const sensor = transition.sensor;
|
|
11338
|
+
const alarmSource = kind === "alarm-triggered" ? source : void 0;
|
|
11339
|
+
const facts = {
|
|
11340
|
+
eventId: `alarm:${kind}:${String(transition.at)}${sensor !== void 0 ? `:${String(sensor.deviceId)}` : ""}${transition.refusalId !== void 0 ? `:${transition.refusalId}` : ""}`,
|
|
11341
|
+
timestamp: transition.at,
|
|
11342
|
+
category: `alarm.${kind.slice(6)}`,
|
|
11343
|
+
data: {
|
|
11344
|
+
panelDeviceId,
|
|
11345
|
+
state: transition.state,
|
|
11346
|
+
previous: transition.previous,
|
|
11347
|
+
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
11348
|
+
...source !== void 0 ? { sourceRuleId: source.ruleId } : {},
|
|
11349
|
+
...transition.refusalId !== void 0 ? { refusalId: transition.refusalId } : {},
|
|
11350
|
+
...sensor !== void 0 ? {
|
|
11351
|
+
sensorDeviceId: sensor.deviceId,
|
|
11352
|
+
event: sensor.event
|
|
11353
|
+
} : {}
|
|
11354
|
+
}
|
|
11355
|
+
};
|
|
11356
|
+
const deviceParams = deviceTextParams(deviceId, identity);
|
|
10427
11357
|
return systemSubject({
|
|
10428
|
-
facts
|
|
10429
|
-
eventId: `alarm:${kind}:${String(transition.at)}`,
|
|
10430
|
-
timestamp: transition.at,
|
|
10431
|
-
category: `alarm.${kind.slice(6)}`,
|
|
10432
|
-
data: {
|
|
10433
|
-
panelDeviceId,
|
|
10434
|
-
state: transition.state,
|
|
10435
|
-
previous: transition.previous,
|
|
10436
|
-
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
10437
|
-
...source !== void 0 ? { sourceRuleId: source.ruleId } : {}
|
|
10438
|
-
}
|
|
10439
|
-
},
|
|
11358
|
+
facts,
|
|
10440
11359
|
kind,
|
|
10441
11360
|
subject: `alarm:${String(panelDeviceId)}`,
|
|
10442
|
-
titleKey:
|
|
10443
|
-
bodyKey:
|
|
11361
|
+
titleKey: keys.titleKey,
|
|
11362
|
+
bodyKey: keys.bodyKey,
|
|
10444
11363
|
textParams: {
|
|
10445
|
-
...
|
|
11364
|
+
...deviceParams,
|
|
11365
|
+
...sensor?.name !== void 0 && identity === void 0 ? { deviceName: sensor.name } : {},
|
|
10446
11366
|
...transition.mode !== void 0 ? { mode: transition.mode } : {},
|
|
10447
11367
|
...source !== void 0 ? { rule: source.ruleName } : {},
|
|
10448
|
-
...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
|
|
11368
|
+
...kind === "alarm-arming" || kind === "alarm-pending" ? { seconds: `${transition.seconds ?? 0}` } : {},
|
|
10449
11369
|
...input.body?.textParams
|
|
10450
11370
|
},
|
|
10451
11371
|
deviceId,
|
|
10452
11372
|
...identity !== void 0 ? { deviceType: identity.type } : {},
|
|
10453
11373
|
...input.body !== void 0 ? { textCount: input.body.textCount } : {},
|
|
10454
|
-
...
|
|
11374
|
+
...alarmSource !== void 0 ? { alarmSource } : {}
|
|
10455
11375
|
});
|
|
10456
11376
|
}
|
|
10457
11377
|
/** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
|
|
@@ -11409,7 +12329,8 @@ var en_default = {
|
|
|
11409
12329
|
"alarm.arming.body.skipped": "Excluded: {{names}}",
|
|
11410
12330
|
"alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
|
|
11411
12331
|
"alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
|
|
11412
|
-
"alarm.armRefused.body": "Open: {{names}}
|
|
12332
|
+
"alarm.armRefused.body": "Open: {{names}}",
|
|
12333
|
+
"alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
|
|
11413
12334
|
"alarm.device.unnamed": "Device {{id}}",
|
|
11414
12335
|
"alarm.combined.title": "Alarm: {{deviceName}}",
|
|
11415
12336
|
"system.alarm-arm-refused.title.home": "Arming home refused",
|
|
@@ -11426,15 +12347,33 @@ var en_default = {
|
|
|
11426
12347
|
"system.alarm-armed.body": "Alarm armed",
|
|
11427
12348
|
"system.alarm-disarmed.title": "Disarmed",
|
|
11428
12349
|
"system.alarm-disarmed.body": "Alarm disarmed",
|
|
12350
|
+
"system.alarm-disarmed.body.defused": "Alarm disarmed during an alarm",
|
|
11429
12351
|
"system.alarm-triggered.title": "ALARM",
|
|
11430
12352
|
"system.alarm-triggered.body": "Alarm triggered",
|
|
11431
12353
|
"system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
|
|
12354
|
+
"system.alarm-pending.title.home": "Intrusion — home, {{seconds}}s to disarm",
|
|
12355
|
+
"system.alarm-pending.title.away": "Intrusion — away, {{seconds}}s to disarm",
|
|
12356
|
+
"system.alarm-pending.title.night": "Intrusion — night, {{seconds}}s to disarm",
|
|
12357
|
+
"system.alarm-pending.body": "Entry delay started",
|
|
12358
|
+
"system.alarm-pending.body.rule": "Started by {{rule}} on {{deviceName}}",
|
|
12359
|
+
"system.alarm-rearmed.title.home": "Re-armed: home",
|
|
12360
|
+
"system.alarm-rearmed.title.away": "Re-armed: away",
|
|
12361
|
+
"system.alarm-rearmed.title.night": "Re-armed: night",
|
|
12362
|
+
"system.alarm-rearmed.body": "The siren ended and the alarm is armed again",
|
|
12363
|
+
"system.alarm-sensor-bypass.title": "{{deviceName}}",
|
|
12364
|
+
"system.alarm-sensor-bypass.body.ignored": "Ignored: it was open when the alarm was armed",
|
|
12365
|
+
"system.alarm-sensor-bypass.body.lifted": "Monitored again: it closed and opened",
|
|
12366
|
+
"system.alarm-check-failed.title.home": "Armed home without checking openings",
|
|
12367
|
+
"system.alarm-check-failed.title.away": "Armed away without checking openings",
|
|
12368
|
+
"system.alarm-check-failed.title.night": "Armed night without checking openings",
|
|
12369
|
+
"system.alarm-check-failed.body": "The device states could not be read",
|
|
11432
12370
|
"button.snooze.minutes": "Snooze {{minutes}} min",
|
|
11433
12371
|
"button.snooze.hours": {
|
|
11434
12372
|
"one": "Snooze {{hours}} hour",
|
|
11435
12373
|
"other": "Snooze {{hours}} hours"
|
|
11436
12374
|
},
|
|
11437
12375
|
"button.alarm.disarm": "Disarm",
|
|
12376
|
+
"button.alarm.armAnyway": "Arm anyway",
|
|
11438
12377
|
"button.alarm.arm.home": "Arm home",
|
|
11439
12378
|
"button.alarm.arm.away": "Arm away",
|
|
11440
12379
|
"button.alarm.arm.night": "Arm night",
|
|
@@ -11678,7 +12617,8 @@ var it_default = {
|
|
|
11678
12617
|
"alarm.arming.body.skipped": "Esclusi: {{names}}",
|
|
11679
12618
|
"alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
|
|
11680
12619
|
"alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
|
|
11681
|
-
"alarm.armRefused.body": "Aperti: {{names}}
|
|
12620
|
+
"alarm.armRefused.body": "Aperti: {{names}}",
|
|
12621
|
+
"alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
|
|
11682
12622
|
"alarm.device.unnamed": "Dispositivo {{id}}",
|
|
11683
12623
|
"alarm.combined.title": "Allarme: {{deviceName}}",
|
|
11684
12624
|
"system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
|
|
@@ -11695,15 +12635,33 @@ var it_default = {
|
|
|
11695
12635
|
"system.alarm-armed.body": "Allarme inserito",
|
|
11696
12636
|
"system.alarm-disarmed.title": "Disinserito",
|
|
11697
12637
|
"system.alarm-disarmed.body": "Allarme disinserito",
|
|
12638
|
+
"system.alarm-disarmed.body.defused": "Allarme disinserito durante un allarme",
|
|
11698
12639
|
"system.alarm-triggered.title": "ALLARME",
|
|
11699
12640
|
"system.alarm-triggered.body": "Allarme scattato",
|
|
11700
12641
|
"system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
|
|
12642
|
+
"system.alarm-pending.title.home": "Intrusione — Casa, {{seconds}}s per disinserire",
|
|
12643
|
+
"system.alarm-pending.title.away": "Intrusione — Fuori, {{seconds}}s per disinserire",
|
|
12644
|
+
"system.alarm-pending.title.night": "Intrusione — Notte, {{seconds}}s per disinserire",
|
|
12645
|
+
"system.alarm-pending.body": "Ritardo di ingresso avviato",
|
|
12646
|
+
"system.alarm-pending.body.rule": "Avviato da {{rule}} su {{deviceName}}",
|
|
12647
|
+
"system.alarm-rearmed.title.home": "Reinserito: Casa",
|
|
12648
|
+
"system.alarm-rearmed.title.away": "Reinserito: Fuori",
|
|
12649
|
+
"system.alarm-rearmed.title.night": "Reinserito: Notte",
|
|
12650
|
+
"system.alarm-rearmed.body": "La sirena è terminata e l'allarme è di nuovo inserito",
|
|
12651
|
+
"system.alarm-sensor-bypass.title": "{{deviceName}}",
|
|
12652
|
+
"system.alarm-sensor-bypass.body.ignored": "Ignorato: era aperto all'inserimento",
|
|
12653
|
+
"system.alarm-sensor-bypass.body.lifted": "Di nuovo sorvegliato: si è chiuso e riaperto",
|
|
12654
|
+
"system.alarm-check-failed.title.home": "Inserito Casa senza controllare le aperture",
|
|
12655
|
+
"system.alarm-check-failed.title.away": "Inserito Fuori senza controllare le aperture",
|
|
12656
|
+
"system.alarm-check-failed.title.night": "Inserito Notte senza controllare le aperture",
|
|
12657
|
+
"system.alarm-check-failed.body": "Non è stato possibile leggere lo stato dei dispositivi",
|
|
11701
12658
|
"button.snooze.minutes": "Silenzia {{minutes}} min",
|
|
11702
12659
|
"button.snooze.hours": {
|
|
11703
12660
|
"one": "Silenzia {{hours}} ora",
|
|
11704
12661
|
"other": "Silenzia {{hours}} ore"
|
|
11705
12662
|
},
|
|
11706
12663
|
"button.alarm.disarm": "Disarma",
|
|
12664
|
+
"button.alarm.armAnyway": "Arma comunque",
|
|
11707
12665
|
"button.alarm.arm.home": "Arma casa",
|
|
11708
12666
|
"button.alarm.arm.away": "Arma fuori",
|
|
11709
12667
|
"button.alarm.arm.night": "Arma notte",
|
|
@@ -16328,6 +17286,8 @@ var OUTBOX_RECORD_KINDS = new Set([
|
|
|
16328
17286
|
function isOutboxRecordKind(x) {
|
|
16329
17287
|
return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
|
|
16330
17288
|
}
|
|
17289
|
+
/** A stored row back to an entry — a guard, never a cast. Exported for the
|
|
17290
|
+
* specs that read the store a centre wrote to. */
|
|
16331
17291
|
function rowToEntry$1(id, data) {
|
|
16332
17292
|
const ruleId = data["ruleId"];
|
|
16333
17293
|
const targetId = data["targetId"];
|
|
@@ -23191,6 +24151,26 @@ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
|
|
|
23191
24151
|
*/
|
|
23192
24152
|
var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
|
|
23193
24153
|
/**
|
|
24154
|
+
* A rule's media policy, as the fields a row freezes — ONE derivation, used by
|
|
24155
|
+
* every row a rule addresses AND by the alarm's own announcement row when it is
|
|
24156
|
+
* the COMBINED siren (the triggering rule's pictures on the alarm's words).
|
|
24157
|
+
*/
|
|
24158
|
+
function mediaPolicyFields(mediaRule, cropZoneIds) {
|
|
24159
|
+
const media = mediaRule.media;
|
|
24160
|
+
return {
|
|
24161
|
+
media: media.attach,
|
|
24162
|
+
...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
24163
|
+
...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
24164
|
+
...media.frame !== void 0 ? { mediaFrame: media.frame } : {},
|
|
24165
|
+
...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
|
|
24166
|
+
...media.gif === true ? { mediaGif: true } : {},
|
|
24167
|
+
...media.clip === true ? { mediaClip: true } : {},
|
|
24168
|
+
...media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: media.clipPreRollSec } : {},
|
|
24169
|
+
...media.clipSpeed !== void 0 ? { mediaClipSpeed: media.clipSpeed } : {},
|
|
24170
|
+
...media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: media.clipPostRollSec } : {}
|
|
24171
|
+
};
|
|
24172
|
+
}
|
|
24173
|
+
/**
|
|
23194
24174
|
* Synthetic rule id for an export's on-screen picker.
|
|
23195
24175
|
*
|
|
23196
24176
|
* The operator named these targets on the export screen, not in a rule. The
|
|
@@ -23208,10 +24188,35 @@ var NC_EXPORT_NOTIFY_RULE_ID = "nc-export-notify";
|
|
|
23208
24188
|
* notification that says the alarm was disarmed is worse than offering nothing.
|
|
23209
24189
|
*/
|
|
23210
24190
|
function alarmButtonKind(kind) {
|
|
23211
|
-
|
|
23212
|
-
|
|
24191
|
+
switch (kind) {
|
|
24192
|
+
case "alarm-triggered":
|
|
24193
|
+
case "alarm-armed":
|
|
24194
|
+
case "alarm-arming":
|
|
24195
|
+
case "alarm-disarmed":
|
|
24196
|
+
case "alarm-pending":
|
|
24197
|
+
case "alarm-rearmed":
|
|
24198
|
+
case "alarm-check-failed":
|
|
24199
|
+
case "alarm-arm-refused":
|
|
24200
|
+
case "alarm-sensor-bypass": return kind;
|
|
24201
|
+
default: return null;
|
|
24202
|
+
}
|
|
24203
|
+
}
|
|
24204
|
+
/** The refusal id an `alarm-arm-refused` row froze (`incomingFromAlarmTransition`). */
|
|
24205
|
+
function rowRefusalId(systemEvent) {
|
|
24206
|
+
const value = systemEvent?.data["refusalId"];
|
|
24207
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
23213
24208
|
}
|
|
23214
24209
|
/**
|
|
24210
|
+
* What a pressed Arm anyway that did nothing tells the person holding the
|
|
24211
|
+
* phone — one sentence per reason the panel refused it.
|
|
24212
|
+
*/
|
|
24213
|
+
var ARM_ANYWAY_REJECTION_COPY = {
|
|
24214
|
+
stale: "The alarm changed since this notification — nothing was armed",
|
|
24215
|
+
expired: "This button expired — arm again from the app",
|
|
24216
|
+
used: "Already used",
|
|
24217
|
+
"unknown-refusal": "This refusal is no longer current — arm again"
|
|
24218
|
+
};
|
|
24219
|
+
/**
|
|
23215
24220
|
* What the sustained-state alerts run on when nothing wired the settings in.
|
|
23216
24221
|
*
|
|
23217
24222
|
* The SHIPPED DEFAULTS, not "off". A wiring that silently disabled the feature
|
|
@@ -23315,7 +24320,6 @@ function consumablesSliceOf(event) {
|
|
|
23315
24320
|
*/
|
|
23316
24321
|
var NC_ALARM_SETTINGS_FALLBACK = {
|
|
23317
24322
|
...DEFAULT_DELAYS,
|
|
23318
|
-
announceArm: false,
|
|
23319
24323
|
announceTargets: [],
|
|
23320
24324
|
nonBlocking: []
|
|
23321
24325
|
};
|
|
@@ -23425,47 +24429,6 @@ function historyRecordKind(kind) {
|
|
|
23425
24429
|
return kind;
|
|
23426
24430
|
}
|
|
23427
24431
|
/**
|
|
23428
|
-
* The system-event half of a history row, in the CAP's shape.
|
|
23429
|
-
*
|
|
23430
|
-
* The cap contract (`NcHistoryEntry`, frozen in `@camstack/types`) wants a
|
|
23431
|
-
* title and a body, and a row written since the text catalog carries keys
|
|
23432
|
-
* instead. So history resolves them through the SAME catalog the delivery used
|
|
23433
|
-
* — history must read as what the operator was actually sent, and a page that
|
|
23434
|
-
* showed a key while the phone showed a sentence would be a second vocabulary
|
|
23435
|
-
* to keep in step. A pre-catalog row falls back to the pair frozen on it.
|
|
23436
|
-
*/
|
|
23437
|
-
/**
|
|
23438
|
-
* Cap-facing downgrade of a system-event kind — the SAME device as
|
|
23439
|
-
* {@link historyRecordKind}, and it exists for the same reason.
|
|
23440
|
-
*
|
|
23441
|
-
* This addon bundles its own `@camstack/types` (self-contained externals), so
|
|
23442
|
-
* it can emit a kind the enum in the RUNNING SERVER does not have. The history
|
|
23443
|
-
* router does not: it validates its output against the host's
|
|
23444
|
-
* `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
|
|
23445
|
-
* the host has never heard of therefore does not degrade — it fails the whole
|
|
23446
|
-
* `getHistory` call, for every row, exactly as the enum's own legacy-tail note
|
|
23447
|
-
* warns.
|
|
23448
|
-
*
|
|
23449
|
-
* `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
|
|
23450
|
-
* panel, same lifecycle, adjacent instant. Nothing a person reads changes —
|
|
23451
|
-
* the row's title and body are resolved from the arming keys ("Inserimento
|
|
23452
|
-
* Fuori — attivo tra 30 secondi") — only the machine-facing word does.
|
|
23453
|
-
*
|
|
23454
|
-
* DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
|
|
23455
|
-
* `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
|
|
23456
|
-
* then it is what lets the exit-delay notification ship without a train.
|
|
23457
|
-
*/
|
|
23458
|
-
/**
|
|
23459
|
-
* The mirrored device-state word, narrowed to a contact reading.
|
|
23460
|
-
*
|
|
23461
|
-
* Anything that is not one of the two contact words is `undefined` — a device
|
|
23462
|
-
* whose slice the state table read as `on` / `armed_home` is not a contact, and
|
|
23463
|
-
* an exclusion must never be lifted by a word that belongs to another cap.
|
|
23464
|
-
*/
|
|
23465
|
-
function contactReadingOf(state) {
|
|
23466
|
-
if (state === "open" || state === "closed") return state;
|
|
23467
|
-
}
|
|
23468
|
-
/**
|
|
23469
24432
|
* The subject kind a persisted record kind maps to.
|
|
23470
24433
|
*
|
|
23471
24434
|
* `NcOutboxRecordKind` carries two kinds a rule subject never has —
|
|
@@ -23503,11 +24466,16 @@ function cooldownSubjectFromOutbox(entry) {
|
|
|
23503
24466
|
...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
|
|
23504
24467
|
};
|
|
23505
24468
|
}
|
|
23506
|
-
|
|
23507
|
-
|
|
23508
|
-
|
|
23509
|
-
|
|
23510
|
-
|
|
24469
|
+
/**
|
|
24470
|
+
* The system-event half of a history row, in the CAP's shape.
|
|
24471
|
+
*
|
|
24472
|
+
* The cap contract (`NcHistoryEntry`, frozen in `@camstack/types`) wants a
|
|
24473
|
+
* title and a body, and a row written since the text catalog carries keys
|
|
24474
|
+
* instead. So history resolves them through the SAME catalog the delivery used
|
|
24475
|
+
* — history must read as what the operator was actually sent, and a page that
|
|
24476
|
+
* showed a key while the phone showed a sentence would be a second vocabulary
|
|
24477
|
+
* to keep in step. A pre-catalog row falls back to the pair frozen on it.
|
|
24478
|
+
*/
|
|
23511
24479
|
function historySystemEvent(texts, systemEvent) {
|
|
23512
24480
|
const vars = systemEvent.textParams ?? {};
|
|
23513
24481
|
const count = systemEvent.textCount;
|
|
@@ -23520,7 +24488,7 @@ function historySystemEvent(texts, systemEvent) {
|
|
|
23520
24488
|
return frozen ?? "";
|
|
23521
24489
|
};
|
|
23522
24490
|
return {
|
|
23523
|
-
kind:
|
|
24491
|
+
kind: systemEvent.kind,
|
|
23524
24492
|
subject: systemEvent.subject,
|
|
23525
24493
|
title: resolve(systemEvent.titleKey, systemEvent.title),
|
|
23526
24494
|
body: resolve(systemEvent.bodyKey, systemEvent.body),
|
|
@@ -23708,6 +24676,13 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23708
24676
|
audioEnabled = false;
|
|
23709
24677
|
/** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
|
|
23710
24678
|
alarmPanel = null;
|
|
24679
|
+
/**
|
|
24680
|
+
* The words the panel answers the `deviceState` gate with — its state, plus
|
|
24681
|
+
* the armed mode a `pending`/`triggered` belongs to (D630). Refreshed on
|
|
24682
|
+
* every publish, next to the one-word mirror; the lookup only uses them
|
|
24683
|
+
* while the mirror agrees on the state (`panelAwareLookup`).
|
|
24684
|
+
*/
|
|
24685
|
+
panelGateWords = null;
|
|
23711
24686
|
/** In-memory cooldown map — seeded from persisted outbox rows on start. */
|
|
23712
24687
|
lastFiredAt = /* @__PURE__ */ new Map();
|
|
23713
24688
|
/**
|
|
@@ -23805,11 +24780,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23805
24780
|
async mintButtons(input) {
|
|
23806
24781
|
const mint = this.deps.mintActionUrl;
|
|
23807
24782
|
if (mint === void 0) return [];
|
|
23808
|
-
const
|
|
23809
|
-
|
|
24783
|
+
const stored = this.rules.get(input.ruleId);
|
|
24784
|
+
const announcement = stored === null && input.ruleId === "nc-alarm-announce";
|
|
24785
|
+
if (stored === null && !announcement) return [];
|
|
23810
24786
|
const drafts = [];
|
|
23811
|
-
const sequenceButtons = this.actionRunner === null ? [] : buildActionButtons({
|
|
23812
|
-
actions:
|
|
24787
|
+
const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
|
|
24788
|
+
actions: stored.actions,
|
|
23813
24789
|
mintUrl: ({ sequence, actionId }) => {
|
|
23814
24790
|
const id = (0, node_crypto.randomUUID)();
|
|
23815
24791
|
drafts.push({
|
|
@@ -23830,8 +24806,8 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23830
24806
|
return id;
|
|
23831
24807
|
}
|
|
23832
24808
|
});
|
|
23833
|
-
const snoozeButtons = input.ownerUserId === void 0 ? [] : buildSnoozeButtons({
|
|
23834
|
-
rule,
|
|
24809
|
+
const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
|
|
24810
|
+
rule: stored,
|
|
23835
24811
|
label: (minutes) => this.snoozeButtonLabel(minutes),
|
|
23836
24812
|
mintUrl: ({ actionId, minutes }) => {
|
|
23837
24813
|
const id = (0, node_crypto.randomUUID)();
|
|
@@ -23858,36 +24834,63 @@ var NotificationCenter = class NotificationCenter {
|
|
|
23858
24834
|
});
|
|
23859
24835
|
const alarmKind = alarmButtonKind(input.systemEvent?.kind);
|
|
23860
24836
|
const panel = this.alarmPanel;
|
|
24837
|
+
const refusalId = rowRefusalId(input.systemEvent);
|
|
24838
|
+
if (alarmKind === "alarm-arm-refused" && refusalId === void 0) this.logger.debug("refusal notification carries no Arm anyway — the row names no refusal", {
|
|
24839
|
+
tags: { deviceId: input.deviceId },
|
|
24840
|
+
meta: {
|
|
24841
|
+
ruleId: input.ruleId,
|
|
24842
|
+
targetId: input.targetId
|
|
24843
|
+
}
|
|
24844
|
+
});
|
|
23861
24845
|
const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
|
|
23862
24846
|
kind: alarmKind,
|
|
23863
24847
|
panel: panel === null ? null : {
|
|
23864
24848
|
deviceId: panel.deviceId,
|
|
23865
24849
|
availableModes: panel.availableModes()
|
|
23866
24850
|
},
|
|
24851
|
+
...refusalId !== void 0 ? { refusalId } : {},
|
|
23867
24852
|
labels: {
|
|
23868
24853
|
disarm: this.texts.text({ key: "button.alarm.disarm" }),
|
|
23869
|
-
arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` })
|
|
24854
|
+
arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` }),
|
|
24855
|
+
armAnyway: this.texts.text({ key: "button.alarm.armAnyway" })
|
|
23870
24856
|
},
|
|
23871
|
-
mintUrl: (
|
|
24857
|
+
mintUrl: (button) => {
|
|
23872
24858
|
const id = (0, node_crypto.randomUUID)();
|
|
23873
|
-
const
|
|
24859
|
+
const common = {
|
|
24860
|
+
kind: "alarm",
|
|
24861
|
+
id,
|
|
24862
|
+
panelDeviceId: panel?.deviceId ?? 0,
|
|
24863
|
+
deviceId: input.deviceId,
|
|
24864
|
+
actionId: button.actionId,
|
|
24865
|
+
targetId: input.targetId
|
|
24866
|
+
};
|
|
23874
24867
|
drafts.push({
|
|
23875
24868
|
id,
|
|
23876
24869
|
ttlMs: actionTtlMs({
|
|
23877
24870
|
kind: "alarm",
|
|
23878
|
-
action
|
|
24871
|
+
action: button.action
|
|
23879
24872
|
}),
|
|
23880
|
-
grant: (expiresAt) =>
|
|
23881
|
-
|
|
23882
|
-
|
|
23883
|
-
|
|
23884
|
-
|
|
23885
|
-
|
|
23886
|
-
|
|
23887
|
-
|
|
23888
|
-
|
|
23889
|
-
|
|
23890
|
-
|
|
24873
|
+
grant: (expiresAt) => {
|
|
24874
|
+
switch (button.action) {
|
|
24875
|
+
case "arm-anyway": return {
|
|
24876
|
+
...common,
|
|
24877
|
+
action: "arm-anyway",
|
|
24878
|
+
refusalId: button.refusalId,
|
|
24879
|
+
expiresAt
|
|
24880
|
+
};
|
|
24881
|
+
case "arm": return {
|
|
24882
|
+
...common,
|
|
24883
|
+
action: "arm",
|
|
24884
|
+
mode: button.mode,
|
|
24885
|
+
expiresAt
|
|
24886
|
+
};
|
|
24887
|
+
case "disarm": return {
|
|
24888
|
+
...common,
|
|
24889
|
+
action: "disarm",
|
|
24890
|
+
expiresAt
|
|
24891
|
+
};
|
|
24892
|
+
}
|
|
24893
|
+
}
|
|
23891
24894
|
});
|
|
23892
24895
|
return id;
|
|
23893
24896
|
}
|
|
@@ -24069,10 +25072,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
24069
25072
|
/** Arm or disarm the panel a button granted. Throws when the panel is gone —
|
|
24070
25073
|
* the plane turns that into the page that says so. */
|
|
24071
25074
|
async applyGrantedAlarm(grant) {
|
|
24072
|
-
if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
|
|
24073
25075
|
const panel = this.alarmPanel;
|
|
24074
25076
|
if (panel === null) throw new Error("this node has no alarm panel");
|
|
24075
25077
|
if (panel.deviceId !== grant.panelDeviceId) throw new Error("this button was minted for a different alarm panel");
|
|
25078
|
+
if (grant.action === "arm-anyway") return this.applyGrantedArmAnyway(panel, grant);
|
|
25079
|
+
if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
|
|
24076
25080
|
await this.actuateWithAttribution({
|
|
24077
25081
|
deviceId: panel.deviceId,
|
|
24078
25082
|
cap: ALARM_PANEL_CAP,
|
|
@@ -24080,6 +25084,34 @@ var NotificationCenter = class NotificationCenter {
|
|
|
24080
25084
|
args: grant.action === "arm" && grant.mode !== void 0 ? { mode: grant.mode } : {}
|
|
24081
25085
|
});
|
|
24082
25086
|
}
|
|
25087
|
+
/**
|
|
25088
|
+
* The Arm anyway on a refusal notification — IN-PROCESS, `armAnyway(refusalId)`,
|
|
25089
|
+
* never the cap: `alarm-panel.arm` has no field for a refusal, and a plain
|
|
25090
|
+
* arm would simply be refused again.
|
|
25091
|
+
*
|
|
25092
|
+
* The token claim is already atomic and single-use; the panel adds its own
|
|
25093
|
+
* one-shot on the refusal, so a second phone's copy of the same button is
|
|
25094
|
+
* `used`. Every way it does nothing throws a sentence the page shows.
|
|
25095
|
+
*/
|
|
25096
|
+
async applyGrantedArmAnyway(panel, grant) {
|
|
25097
|
+
const outcome = await panel.armAnyway(grant.refusalId);
|
|
25098
|
+
switch (outcome.outcome) {
|
|
25099
|
+
case "rejected": throw new NcActionRefusedError(ARM_ANYWAY_REJECTION_COPY[outcome.reason]);
|
|
25100
|
+
case "refused": throw new NcActionRefusedError("Something else is open now — see the new notification");
|
|
25101
|
+
case "arming":
|
|
25102
|
+
case "armed":
|
|
25103
|
+
this.logger.info("alarm armed from a refusal notification (Arm anyway)", {
|
|
25104
|
+
tags: { deviceId: panel.deviceId },
|
|
25105
|
+
meta: {
|
|
25106
|
+
refusalId: grant.refusalId,
|
|
25107
|
+
outcome: outcome.outcome,
|
|
25108
|
+
excluded: outcome.excluded.length,
|
|
25109
|
+
targetId: grant.targetId ?? null
|
|
25110
|
+
}
|
|
25111
|
+
});
|
|
25112
|
+
return;
|
|
25113
|
+
}
|
|
25114
|
+
}
|
|
24083
25115
|
/** The registry the action plane claims against. */
|
|
24084
25116
|
get actionTokenRegistry() {
|
|
24085
25117
|
return this.actionTokens;
|
|
@@ -25139,17 +26171,38 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25139
26171
|
* before it.
|
|
25140
26172
|
*/
|
|
25141
26173
|
onSensorEventPersisted(event, markerTrackId) {
|
|
25142
|
-
this.
|
|
26174
|
+
this.observeOpeningEvent(event);
|
|
25143
26175
|
this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
|
|
25144
26176
|
}
|
|
25145
|
-
/**
|
|
25146
|
-
*
|
|
25147
|
-
|
|
26177
|
+
/**
|
|
26178
|
+
* Called for a sensor state change no camera takes a timeline row for
|
|
26179
|
+
* (`ingestSensorStateChange`'s `onUnowned`, D630 §6): linked to no camera
|
|
26180
|
+
* (`linkedCameraIds` = []), or linked only by cameras that did not opt it
|
|
26181
|
+
* into tracked events (`linkedCameraIds` = those cameras). Nothing was
|
|
26182
|
+
* persisted, but the event is evaluated exactly like a persisted one, so a
|
|
26183
|
+
* contact rule fires and the contact reaches the alarm panel. The sensor is
|
|
26184
|
+
* its own device, and the notification is text-only (no marker).
|
|
26185
|
+
*
|
|
26186
|
+
* A separate door from {@link onSensorEventPersisted} because the SUBJECT
|
|
26187
|
+
* differs, not the path: both feed the panel first and then the same
|
|
26188
|
+
* evaluation. Same lossy boundary: no row, so no reconcile.
|
|
26189
|
+
*/
|
|
26190
|
+
onUnownedSensorEvent(event, linkedCameraIds) {
|
|
26191
|
+
this.observeOpeningEvent(event);
|
|
26192
|
+
this.consumeEvent(incomingFromUnownedSensorEvent(event, linkedCameraIds));
|
|
26193
|
+
}
|
|
26194
|
+
/**
|
|
26195
|
+
* The opening reading a sensor row carries — a contact, a cover or a lock,
|
|
26196
|
+
* read with D630's table (`alarmOpeningFromSensorEvent`) — handed to the
|
|
26197
|
+
* panel. A row that is not an opening, or whose word is unknown, hands over
|
|
26198
|
+
* nothing: an unreadable state must never move an exclusion (D49).
|
|
26199
|
+
*/
|
|
26200
|
+
observeOpeningEvent(event) {
|
|
25148
26201
|
const panel = this.alarmPanel;
|
|
25149
|
-
if (panel === null
|
|
25150
|
-
const
|
|
25151
|
-
if (
|
|
25152
|
-
panel.
|
|
26202
|
+
if (panel === null) return;
|
|
26203
|
+
const opening = alarmOpeningFromSensorEvent(event.kind, event.value);
|
|
26204
|
+
if (opening.kind !== "reading") return;
|
|
26205
|
+
panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp);
|
|
25153
26206
|
}
|
|
25154
26207
|
/**
|
|
25155
26208
|
* Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
|
|
@@ -25501,22 +26554,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25501
26554
|
return { success: true };
|
|
25502
26555
|
},
|
|
25503
26556
|
getAlarmConfig: async () => this.alarmConfig(),
|
|
25504
|
-
setAlarmConfig: async ({ patch }) =>
|
|
25505
|
-
const panel = this.alarmPanel;
|
|
25506
|
-
if (panel === null) throw new Error("this node has no alarm panel");
|
|
25507
|
-
const settings = await panel.applySettings(patch);
|
|
25508
|
-
this.logger.info("alarm settings changed", {
|
|
25509
|
-
tags: { deviceId: panel.deviceId },
|
|
25510
|
-
meta: {
|
|
25511
|
-
exitDelaySec: settings.exitDelaySec,
|
|
25512
|
-
entryDelaySec: settings.entryDelaySec,
|
|
25513
|
-
triggeredDurationSec: settings.triggeredDurationSec,
|
|
25514
|
-
announceArm: settings.announceArm,
|
|
25515
|
-
announceTargets: settings.announceTargets.length
|
|
25516
|
-
}
|
|
25517
|
-
});
|
|
25518
|
-
return this.alarmConfig();
|
|
25519
|
-
}
|
|
26557
|
+
setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch)
|
|
25520
26558
|
};
|
|
25521
26559
|
}
|
|
25522
26560
|
/**
|
|
@@ -25526,66 +26564,96 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25526
26564
|
*/
|
|
25527
26565
|
setAlarmPanel(panel) {
|
|
25528
26566
|
this.alarmPanel = panel;
|
|
26567
|
+
if (panel === null || this.panelGateWords?.deviceId !== panel.deviceId) this.panelGateWords = null;
|
|
26568
|
+
}
|
|
26569
|
+
/**
|
|
26570
|
+
* The `deviceState` lookup the engine gets — live path and dry-run alike, so
|
|
26571
|
+
* the tester predicts what the saved rule will do. Every device answers its
|
|
26572
|
+
* one word from `read`; the panel this node owns also answers the armed mode
|
|
26573
|
+
* its `pending`/`triggered` belongs to (D630).
|
|
26574
|
+
*/
|
|
26575
|
+
deviceStateLookup(read) {
|
|
26576
|
+
return panelAwareLookup(read, this.panelGateWords);
|
|
25529
26577
|
}
|
|
25530
26578
|
/**
|
|
25531
26579
|
* What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
|
|
25532
26580
|
*
|
|
25533
26581
|
* Handed to the panel by the same wiring that hands it the publish hook: the
|
|
25534
26582
|
* panel owns the state machine, the centre owns the rules that say which
|
|
25535
|
-
*
|
|
26583
|
+
* devices a mode covers, and neither can reach the other on its own.
|
|
25536
26584
|
*/
|
|
25537
26585
|
alarmArmGuard() {
|
|
25538
26586
|
return {
|
|
25539
|
-
|
|
25540
|
-
announceRefusal: (input) => this.announceArmRefused(input)
|
|
26587
|
+
openings: (mode) => this.alarmOpenings(mode),
|
|
26588
|
+
announceRefusal: (input) => this.announceArmRefused(input),
|
|
26589
|
+
announceCheckFailed: (input) => this.announceArmCheckFailed(input)
|
|
25541
26590
|
};
|
|
25542
26591
|
}
|
|
25543
26592
|
/**
|
|
25544
|
-
* Which of the
|
|
26593
|
+
* Which of the devices this mode covers are OPEN right now, and which could
|
|
26594
|
+
* not be read (D630).
|
|
25545
26595
|
*
|
|
25546
|
-
* A TARGETED read of at most the handful of
|
|
25547
|
-
* refresh of the whole watched set: the arm is a person pressing a
|
|
25548
|
-
* the answer has to be about this second, but it must not become
|
|
25549
|
-
* over every gated device on the hub.
|
|
26596
|
+
* A TARGETED read of at most the handful of devices the mode's rules name,
|
|
26597
|
+
* not a refresh of the whole watched set: the arm is a person pressing a
|
|
26598
|
+
* button and the answer has to be about this second, but it must not become
|
|
26599
|
+
* a fan-out over every gated device on the hub. Read through
|
|
26600
|
+
* `readDeviceOpenings`, so a cover or a lock that is not secure is open too,
|
|
26601
|
+
* and a camera an intrusion rule names is `not-opening` and takes no part.
|
|
25550
26602
|
*
|
|
25551
|
-
*
|
|
25552
|
-
* ignorance is worse than arming:
|
|
26603
|
+
* Never a guess, in either direction (D49):
|
|
25553
26604
|
*
|
|
25554
|
-
* - a
|
|
25555
|
-
*
|
|
25556
|
-
*
|
|
25557
|
-
* - a mode whose
|
|
25558
|
-
*
|
|
25559
|
-
*
|
|
25560
|
-
*
|
|
26605
|
+
* - a device the read could not answer for (absent), or whose opening slice
|
|
26606
|
+
* carries a word its cap does not define (`unknown`), is UNKNOWN — it
|
|
26607
|
+
* never blocks, is never counted secure, and says so in one line each;
|
|
26608
|
+
* - a mode whose rules name no devices blocks nothing — an unscoped rule
|
|
26609
|
+
* covers every device on the hub, and a refusal naming devices the
|
|
26610
|
+
* operator never associated with the alarm is one they cannot act on;
|
|
26611
|
+
* - when NOTHING could be read (no reader, the read threw, or every id came
|
|
26612
|
+
* back absent) this THROWS, and the panel arms and announces that it
|
|
26613
|
+
* armed without the check.
|
|
25561
26614
|
*/
|
|
25562
|
-
async
|
|
26615
|
+
async alarmOpenings(mode) {
|
|
25563
26616
|
const panel = this.alarmPanel;
|
|
25564
|
-
if (panel === null) return
|
|
25565
|
-
|
|
25566
|
-
|
|
25567
|
-
|
|
26617
|
+
if (panel === null) return {
|
|
26618
|
+
open: [],
|
|
26619
|
+
unknown: []
|
|
26620
|
+
};
|
|
26621
|
+
const candidates = alarmCoveredDevices(this.rules.list(), panel.deviceId, mode);
|
|
26622
|
+
if (candidates.length === 0) return {
|
|
26623
|
+
open: [],
|
|
26624
|
+
unknown: []
|
|
26625
|
+
};
|
|
26626
|
+
const read = this.deps.readDeviceOpenings;
|
|
26627
|
+
if (read === void 0) throw new Error("no openings reader is wired on this node");
|
|
26628
|
+
const openings = await read(candidates);
|
|
26629
|
+
if (openings.size === 0) throw new Error(`none of the ${String(candidates.length)} covered devices could be read`);
|
|
25568
26630
|
const name = this.deviceNameFromDirectory();
|
|
25569
26631
|
const open = [];
|
|
25570
26632
|
const unknown = [];
|
|
25571
26633
|
for (const deviceId of candidates) {
|
|
25572
|
-
const
|
|
25573
|
-
if (
|
|
26634
|
+
const verdict = classifyCoveredOpening(openings.get(deviceId));
|
|
26635
|
+
if (verdict.kind === "unknown") {
|
|
25574
26636
|
unknown.push(deviceId);
|
|
26637
|
+
this.logger.info("alarm arm check: device state unreadable — it cannot block this arm", {
|
|
26638
|
+
tags: { deviceId },
|
|
26639
|
+
meta: {
|
|
26640
|
+
panelDeviceId: panel.deviceId,
|
|
26641
|
+
mode,
|
|
26642
|
+
why: verdict.why,
|
|
26643
|
+
...verdict.why === "unknown-word" ? {
|
|
26644
|
+
cap: verdict.cap,
|
|
26645
|
+
raw: rawForLog(verdict.raw)
|
|
26646
|
+
} : {}
|
|
26647
|
+
}
|
|
26648
|
+
});
|
|
25575
26649
|
continue;
|
|
25576
26650
|
}
|
|
25577
|
-
if (
|
|
26651
|
+
if (verdict.kind === "open") open.push({
|
|
25578
26652
|
deviceId,
|
|
25579
|
-
name: name(deviceId)
|
|
26653
|
+
name: name(deviceId),
|
|
26654
|
+
state: verdict.state
|
|
25580
26655
|
});
|
|
25581
26656
|
}
|
|
25582
|
-
for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
|
|
25583
|
-
tags: { deviceId },
|
|
25584
|
-
meta: {
|
|
25585
|
-
panelDeviceId: panel.deviceId,
|
|
25586
|
-
mode
|
|
25587
|
-
}
|
|
25588
|
-
});
|
|
25589
26657
|
this.logger.info("alarm arm check", {
|
|
25590
26658
|
tags: { deviceId: panel.deviceId },
|
|
25591
26659
|
meta: {
|
|
@@ -25593,55 +26661,198 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25593
26661
|
candidates: candidates.length,
|
|
25594
26662
|
open: open.length,
|
|
25595
26663
|
unknown: unknown.length,
|
|
25596
|
-
openIds: open.map((
|
|
26664
|
+
openIds: open.map((d) => d.deviceId).join(",")
|
|
25597
26665
|
}
|
|
25598
26666
|
});
|
|
25599
|
-
return
|
|
25600
|
-
|
|
25601
|
-
|
|
25602
|
-
|
|
25603
|
-
async readContactStates(ids) {
|
|
25604
|
-
const read = this.deps.readDeviceStates;
|
|
25605
|
-
if (read === void 0) return /* @__PURE__ */ new Map();
|
|
25606
|
-
try {
|
|
25607
|
-
return await read(ids);
|
|
25608
|
-
} catch (err) {
|
|
25609
|
-
this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
|
|
25610
|
-
devices: ids.length,
|
|
25611
|
-
error: String(err)
|
|
25612
|
-
} });
|
|
25613
|
-
return /* @__PURE__ */ new Map();
|
|
25614
|
-
}
|
|
26667
|
+
return {
|
|
26668
|
+
open,
|
|
26669
|
+
unknown
|
|
26670
|
+
};
|
|
25615
26671
|
}
|
|
25616
26672
|
/**
|
|
25617
26673
|
* The refusal, as an ordinary notification.
|
|
25618
26674
|
*
|
|
25619
|
-
* Same path
|
|
25620
|
-
* ordinary notification path), so it is durable, retried, in history and
|
|
26675
|
+
* Same path every alarm announcement takes (D126: the alarm speaks through
|
|
26676
|
+
* the ordinary notification path), so it is durable, retried, in history and
|
|
25621
26677
|
* worded in the reader's language at send time. It is the operator's ONLY
|
|
25622
26678
|
* copy: the thrown error reaches whoever called the cap, and when that caller
|
|
25623
|
-
* is a notification button on a phone, nobody ever sees it.
|
|
26679
|
+
* is a notification button on a phone, nobody ever sees it. The refusal id
|
|
26680
|
+
* rides the transition onto the ROW, where the Arm anyway button is minted
|
|
26681
|
+
* from (D630).
|
|
25624
26682
|
*/
|
|
25625
26683
|
announceArmRefused(input) {
|
|
25626
26684
|
const panel = this.alarmPanel;
|
|
25627
26685
|
if (panel === null) return;
|
|
25628
|
-
|
|
25629
|
-
const transition = {
|
|
26686
|
+
this.scheduleAlarmTransition(panel, {
|
|
25630
26687
|
kind: "alarm-arm-refused",
|
|
25631
|
-
state:
|
|
25632
|
-
previous:
|
|
26688
|
+
state: input.state,
|
|
26689
|
+
previous: input.state,
|
|
26690
|
+
mode: input.refusal.mode,
|
|
26691
|
+
at: input.at,
|
|
26692
|
+
refusalId: input.refusal.id,
|
|
26693
|
+
blocking: input.blocking.map((d) => ({
|
|
26694
|
+
deviceId: d.deviceId,
|
|
26695
|
+
name: d.name,
|
|
26696
|
+
state: d.state
|
|
26697
|
+
}))
|
|
26698
|
+
});
|
|
26699
|
+
}
|
|
26700
|
+
/**
|
|
26701
|
+
* The panel armed WITHOUT the openings check — nothing could be read. A
|
|
26702
|
+
* line on the panel AND an `alarm-check-failed` notification to its
|
|
26703
|
+
* recipients: "armed, but I could not check the windows" is exactly what the
|
|
26704
|
+
* operator has to hear before leaving.
|
|
26705
|
+
*/
|
|
26706
|
+
announceArmCheckFailed(input) {
|
|
26707
|
+
const panel = this.alarmPanel;
|
|
26708
|
+
if (panel === null) return;
|
|
26709
|
+
this.logger.warn("alarm armed WITHOUT the openings check — nothing could be read", {
|
|
26710
|
+
tags: { deviceId: panel.deviceId },
|
|
26711
|
+
meta: {
|
|
26712
|
+
mode: input.mode,
|
|
26713
|
+
at: input.at,
|
|
26714
|
+
error: input.error
|
|
26715
|
+
}
|
|
26716
|
+
});
|
|
26717
|
+
this.scheduleAlarmTransition(panel, {
|
|
26718
|
+
kind: "alarm-check-failed",
|
|
26719
|
+
state: input.state,
|
|
26720
|
+
previous: input.state,
|
|
25633
26721
|
mode: input.mode,
|
|
25634
26722
|
at: input.at
|
|
26723
|
+
});
|
|
26724
|
+
}
|
|
26725
|
+
/**
|
|
26726
|
+
* The alarm, for the `nc.*` bridge — the viewer's and the admin tab's
|
|
26727
|
+
* surface (`nc-actions.ts` decides who may call what). The same paths the cap
|
|
26728
|
+
* and the notification buttons take: `armWith`, `armAnyway`, `disarm` on the
|
|
26729
|
+
* panel, and the one config reader/writer below.
|
|
26730
|
+
*/
|
|
26731
|
+
alarmActions() {
|
|
26732
|
+
const requirePanel = () => {
|
|
26733
|
+
const panel = this.alarmPanel;
|
|
26734
|
+
if (panel === null) throw new Error("this node has no alarm panel");
|
|
26735
|
+
return panel;
|
|
25635
26736
|
};
|
|
25636
|
-
|
|
25637
|
-
|
|
25638
|
-
|
|
25639
|
-
|
|
25640
|
-
|
|
26737
|
+
return {
|
|
26738
|
+
status: async () => {
|
|
26739
|
+
const panel = this.alarmPanel;
|
|
26740
|
+
if (panel === null) return {
|
|
26741
|
+
deviceId: null,
|
|
26742
|
+
live: null
|
|
26743
|
+
};
|
|
26744
|
+
return {
|
|
26745
|
+
deviceId: panel.deviceId,
|
|
26746
|
+
live: await this.alarmLive(panel)
|
|
26747
|
+
};
|
|
26748
|
+
},
|
|
26749
|
+
arm: async (mode, userId) => {
|
|
26750
|
+
const panel = requirePanel();
|
|
26751
|
+
const outcome = await panel.armWith(mode);
|
|
26752
|
+
this.logger.info("alarm arm requested through the bridge", {
|
|
26753
|
+
tags: { deviceId: panel.deviceId },
|
|
26754
|
+
meta: {
|
|
26755
|
+
userId,
|
|
26756
|
+
mode,
|
|
26757
|
+
outcome: outcome.outcome
|
|
26758
|
+
}
|
|
26759
|
+
});
|
|
26760
|
+
return outcome;
|
|
26761
|
+
},
|
|
26762
|
+
armAnyway: async (refusalId, userId) => {
|
|
26763
|
+
const panel = requirePanel();
|
|
26764
|
+
const outcome = await panel.armAnyway(refusalId);
|
|
26765
|
+
this.logger.info("alarm Arm anyway requested through the bridge", {
|
|
26766
|
+
tags: { deviceId: panel.deviceId },
|
|
26767
|
+
meta: {
|
|
26768
|
+
userId,
|
|
26769
|
+
refusalId,
|
|
26770
|
+
outcome: outcome.outcome
|
|
26771
|
+
}
|
|
26772
|
+
});
|
|
26773
|
+
return outcome;
|
|
26774
|
+
},
|
|
26775
|
+
disarm: async (userId) => {
|
|
26776
|
+
const panel = requirePanel();
|
|
26777
|
+
await panel.disarm();
|
|
26778
|
+
this.logger.info("alarm disarm requested through the bridge", {
|
|
26779
|
+
tags: { deviceId: panel.deviceId },
|
|
26780
|
+
meta: { userId }
|
|
26781
|
+
});
|
|
26782
|
+
},
|
|
26783
|
+
getConfig: () => this.alarmConfig(),
|
|
26784
|
+
setConfig: (patch) => this.setAlarmConfig(patch)
|
|
26785
|
+
};
|
|
26786
|
+
}
|
|
26787
|
+
async setAlarmConfig(patch) {
|
|
26788
|
+
const panel = this.alarmPanel;
|
|
26789
|
+
if (panel === null) throw new Error("this node has no alarm panel");
|
|
26790
|
+
const settings = await panel.applySettings(patch);
|
|
26791
|
+
this.logger.info("alarm settings changed", {
|
|
26792
|
+
tags: { deviceId: panel.deviceId },
|
|
26793
|
+
meta: {
|
|
26794
|
+
exitDelaySec: settings.exitDelaySec,
|
|
26795
|
+
entryDelaySec: settings.entryDelaySec,
|
|
26796
|
+
triggeredDurationSec: settings.triggeredDurationSec,
|
|
26797
|
+
announceTargets: settings.announceTargets.length
|
|
26798
|
+
}
|
|
26799
|
+
});
|
|
26800
|
+
return this.alarmConfig();
|
|
26801
|
+
}
|
|
26802
|
+
/**
|
|
26803
|
+
* The panel as it is NOW — see `alarm-live.ts`.
|
|
26804
|
+
*
|
|
26805
|
+
* ONE read of the union of every mode's covered devices, so each is read
|
|
26806
|
+
* once however many modes cover it. A read that fails is not an error here:
|
|
26807
|
+
* every covered device is then `unknown` (D49) and the line says why — a
|
|
26808
|
+
* status the UI polls must not fail because a contact is asleep.
|
|
26809
|
+
*/
|
|
26810
|
+
async alarmLive(panel) {
|
|
26811
|
+
const modes = panel.availableModes();
|
|
26812
|
+
const rules = this.rules.list();
|
|
26813
|
+
const covered = new Map(modes.map((mode) => [mode, alarmCoveredDevices(rules, panel.deviceId, mode)]));
|
|
26814
|
+
const ids = alarmLiveReadIds(covered);
|
|
26815
|
+
let openings = /* @__PURE__ */ new Map();
|
|
26816
|
+
const read = this.deps.readDeviceOpenings;
|
|
26817
|
+
let readThrew = false;
|
|
26818
|
+
if (ids.length > 0 && read === void 0) this.logger.warn("alarm live view: no openings reader is wired — every opening is unknown", {
|
|
26819
|
+
tags: { deviceId: panel.deviceId },
|
|
26820
|
+
meta: { covered: ids.length }
|
|
26821
|
+
});
|
|
26822
|
+
else if (ids.length > 0 && read !== void 0) try {
|
|
26823
|
+
openings = await read(ids);
|
|
26824
|
+
} catch (err) {
|
|
26825
|
+
readThrew = true;
|
|
26826
|
+
this.logger.warn("alarm live view: openings read failed — every opening is unknown", {
|
|
26827
|
+
tags: { deviceId: panel.deviceId },
|
|
26828
|
+
meta: {
|
|
26829
|
+
covered: ids.length,
|
|
26830
|
+
error: err instanceof Error ? err.message : String(err)
|
|
26831
|
+
}
|
|
26832
|
+
});
|
|
26833
|
+
}
|
|
26834
|
+
const unanswered = ids.filter((id) => !openings.has(id));
|
|
26835
|
+
if (read !== void 0 && !readThrew && unanswered.length > 0) this.logger.warn("alarm live view: covered devices did not answer — shown as unknown", {
|
|
26836
|
+
tags: { deviceId: panel.deviceId },
|
|
26837
|
+
meta: {
|
|
26838
|
+
covered: ids.length,
|
|
26839
|
+
unanswered: unanswered.length,
|
|
26840
|
+
unansweredIds: unanswered.join(",")
|
|
26841
|
+
}
|
|
26842
|
+
});
|
|
26843
|
+
return buildAlarmLive({
|
|
26844
|
+
state: panel.currentState(),
|
|
26845
|
+
target: panel.currentTarget(),
|
|
26846
|
+
modes,
|
|
26847
|
+
covered,
|
|
26848
|
+
openings,
|
|
26849
|
+
nonBlocking: panel.settings().nonBlocking,
|
|
26850
|
+
exclusions: panel.exclusionPhases(),
|
|
26851
|
+
name: this.deviceNameFromDirectory()
|
|
25641
26852
|
});
|
|
25642
26853
|
}
|
|
25643
|
-
/** Settings + the DERIVED coverage. Read together — see the cap doc. */
|
|
25644
|
-
alarmConfig() {
|
|
26854
|
+
/** Settings + the DERIVED coverage + the live panel. Read together — see the cap doc. */
|
|
26855
|
+
async alarmConfig() {
|
|
25645
26856
|
const panel = this.alarmPanel;
|
|
25646
26857
|
if (panel === null) return {
|
|
25647
26858
|
deviceId: null,
|
|
@@ -25651,6 +26862,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25651
26862
|
return {
|
|
25652
26863
|
deviceId: panel.deviceId,
|
|
25653
26864
|
settings: panel.settings(),
|
|
26865
|
+
live: await this.alarmLive(panel),
|
|
25654
26866
|
coverage: this.coverageFor(panel).map((c) => ({
|
|
25655
26867
|
...c,
|
|
25656
26868
|
deviceIds: [...c.deviceIds],
|
|
@@ -25794,6 +27006,10 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25794
27006
|
const panel = this.alarmPanel;
|
|
25795
27007
|
if (panel === null) return;
|
|
25796
27008
|
this.deviceStates.note(panel.deviceId, report.state);
|
|
27009
|
+
this.panelGateWords = {
|
|
27010
|
+
deviceId: panel.deviceId,
|
|
27011
|
+
words: alarmGateWords(report.state, report.target)
|
|
27012
|
+
};
|
|
25797
27013
|
const transition = report.transition;
|
|
25798
27014
|
if (transition === null) return;
|
|
25799
27015
|
this.scheduleAlarmTransition(panel, transition);
|
|
@@ -25805,28 +27021,29 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25805
27021
|
* On the SAME serialized chain as every other evaluation, because both of
|
|
25806
27022
|
* those follow-ups read what the evaluation just enqueued:
|
|
25807
27023
|
*
|
|
25808
|
-
* -
|
|
25809
|
-
* rule addresses — but only where a
|
|
25810
|
-
* a target that is both would get
|
|
27024
|
+
* - EVERY alarm transition is also announced to the panel's own
|
|
27025
|
+
* `announceTargets`, which no rule addresses (D630) — but only where a
|
|
27026
|
+
* rule has not already delivered it, or a target that is both would get
|
|
27027
|
+
* the same sentence twice;
|
|
25811
27028
|
* - a TRIGGERED with an attribution supersedes the triggering rule's own
|
|
25812
27029
|
* still-pending rows, for exactly the targets the alarm reached. That is
|
|
25813
27030
|
* the combined notification: one row carrying the alarm's words and that
|
|
25814
27031
|
* rule's pictures, instead of two pushes about one incident.
|
|
25815
27032
|
*/
|
|
25816
|
-
scheduleAlarmTransition(panel, transition
|
|
25817
|
-
const
|
|
27033
|
+
scheduleAlarmTransition(panel, transition) {
|
|
27034
|
+
const announcement = this.alarmAnnouncement(panel, transition);
|
|
25818
27035
|
const incoming = incomingFromAlarmTransition({
|
|
25819
27036
|
transition,
|
|
25820
27037
|
panelDeviceId: panel.deviceId,
|
|
25821
27038
|
lookup: this.deviceDirectory.lookup(),
|
|
25822
|
-
...
|
|
27039
|
+
...announcement.text !== null ? { body: announcement.text } : {}
|
|
25823
27040
|
});
|
|
25824
27041
|
this.evalChain = this.evalChain.then(async () => {
|
|
25825
27042
|
try {
|
|
25826
27043
|
const report = await this.evaluateAndEnqueue(incoming);
|
|
25827
27044
|
const reached = new Set(report.outcomes.flatMap((o) => o.targetIds));
|
|
25828
|
-
|
|
25829
|
-
await this.settleCombinedAlarm(transition, incoming, report, reached);
|
|
27045
|
+
const announced = await this.enqueueAlarmAnnouncement(panel, incoming, announcement, reached);
|
|
27046
|
+
await this.settleCombinedAlarm(transition, incoming, report, reached, announced);
|
|
25830
27047
|
} catch (err) {
|
|
25831
27048
|
this.logger.warn("alarm transition evaluation failed", {
|
|
25832
27049
|
tags: { deviceId: panel.deviceId },
|
|
@@ -25840,28 +27057,41 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25840
27057
|
});
|
|
25841
27058
|
}
|
|
25842
27059
|
/**
|
|
25843
|
-
* The
|
|
25844
|
-
*
|
|
27060
|
+
* The sentence an alarm transition is said with, and the targets it goes to
|
|
27061
|
+
* — for EVERY alarm kind, always the panel's `announceTargets` (D630: the
|
|
27062
|
+
* `announceArm` switch is gone; rules may add more recipients, never fewer).
|
|
25845
27063
|
*
|
|
25846
|
-
*
|
|
25847
|
-
* watch" — because they are the same answer given thirty seconds apart. The
|
|
25848
|
-
* arming one is the useful one: it reaches the operator while the exit delay
|
|
25849
|
-
* is still running, so a mode that protects nothing can still be fixed.
|
|
27064
|
+
* `text` is a composed body only where the words depend on the rule set:
|
|
25850
27065
|
*
|
|
25851
|
-
*
|
|
25852
|
-
*
|
|
25853
|
-
*
|
|
25854
|
-
*
|
|
25855
|
-
*
|
|
25856
|
-
*
|
|
27066
|
+
* - ARM and ARMING compose from the SAME coverage — "what does this mode
|
|
27067
|
+
* actually watch" — because they are the same answer given thirty seconds
|
|
27068
|
+
* apart. A COVERAGE GAP does not suppress the sentence, it changes it.
|
|
27069
|
+
* - a REFUSAL names the devices that refused it, and — since its row
|
|
27070
|
+
* carries the Arm anyway — where the button is.
|
|
27071
|
+
*
|
|
27072
|
+
* Every other kind is `null`: the intake picks its catalog body and params.
|
|
25857
27073
|
*/
|
|
25858
|
-
|
|
27074
|
+
alarmAnnouncement(panel, transition) {
|
|
27075
|
+
const targetIds = panel.settings().announceTargets;
|
|
27076
|
+
if (targetIds.length === 0) this.logger.info("alarm transition has no announcement target", {
|
|
27077
|
+
tags: { deviceId: panel.deviceId },
|
|
27078
|
+
meta: { kind: transition.kind }
|
|
27079
|
+
});
|
|
27080
|
+
return {
|
|
27081
|
+
text: this.alarmAnnouncementText(panel, transition),
|
|
27082
|
+
targetIds
|
|
27083
|
+
};
|
|
27084
|
+
}
|
|
27085
|
+
/** The composed body of {@link alarmAnnouncement}, or null for a catalog body. */
|
|
27086
|
+
alarmAnnouncementText(panel, transition) {
|
|
25859
27087
|
const kind = transition.kind;
|
|
25860
|
-
if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
|
|
25861
27088
|
const mode = transition.mode;
|
|
27089
|
+
if (kind === "alarm-arm-refused") {
|
|
27090
|
+
if (mode === void 0) return null;
|
|
27091
|
+
return buildArmRefusedAnnouncement(mode, (transition.blocking ?? []).map((d) => d.name), transition.refusalId !== void 0);
|
|
27092
|
+
}
|
|
27093
|
+
if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
|
|
25862
27094
|
if (mode === void 0) return null;
|
|
25863
|
-
const settings = panel.settings();
|
|
25864
|
-
const tags = { deviceId: panel.deviceId };
|
|
25865
27095
|
const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
|
|
25866
27096
|
if (coverage === void 0) return null;
|
|
25867
27097
|
const name = this.deviceNameFromDirectory();
|
|
@@ -25869,7 +27099,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25869
27099
|
const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
|
|
25870
27100
|
const gap = alarmCoverageGap(coverage);
|
|
25871
27101
|
if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
|
|
25872
|
-
tags,
|
|
27102
|
+
tags: { deviceId: panel.deviceId },
|
|
25873
27103
|
meta: {
|
|
25874
27104
|
mode,
|
|
25875
27105
|
kind,
|
|
@@ -25879,41 +27109,27 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25879
27109
|
skipped: coverage.skippedDevices.length
|
|
25880
27110
|
}
|
|
25881
27111
|
});
|
|
25882
|
-
|
|
25883
|
-
text,
|
|
25884
|
-
targetIds: []
|
|
25885
|
-
};
|
|
25886
|
-
if (settings.announceTargets.length === 0) {
|
|
25887
|
-
this.logger.info("alarm armed but no announcement target is configured", {
|
|
25888
|
-
tags,
|
|
25889
|
-
meta: { mode }
|
|
25890
|
-
});
|
|
25891
|
-
return {
|
|
25892
|
-
text,
|
|
25893
|
-
targetIds: []
|
|
25894
|
-
};
|
|
25895
|
-
}
|
|
25896
|
-
return {
|
|
25897
|
-
text,
|
|
25898
|
-
targetIds: settings.announceTargets
|
|
25899
|
-
};
|
|
27112
|
+
return text;
|
|
25900
27113
|
}
|
|
25901
27114
|
/**
|
|
25902
|
-
* Put the
|
|
27115
|
+
* Put the alarm announcement in the outbox, under {@link NC_ALARM_ANNOUNCE_RULE_ID}.
|
|
25903
27116
|
*
|
|
25904
27117
|
* The row is what makes this a notification like any other: durable, retried,
|
|
25905
27118
|
* visible in history, and rendered in the reader's language at send time
|
|
25906
|
-
* rather than frozen in the hub's at
|
|
25907
|
-
* own id, so every
|
|
25908
|
-
* is deduped.
|
|
27119
|
+
* rather than frozen in the hub's at transition time. `recordId` is the
|
|
27120
|
+
* transition's own id, so every transition is a new row (cooldown 0) and a
|
|
27121
|
+
* replay of the same one is deduped.
|
|
25909
27122
|
*
|
|
25910
|
-
* Targets a RULE already reached are skipped — one
|
|
25911
|
-
* push per person, whichever path addressed them.
|
|
27123
|
+
* Targets a RULE already reached are skipped — one transition, one sentence,
|
|
27124
|
+
* one push per person, whichever path addressed them.
|
|
25912
27125
|
*/
|
|
25913
|
-
async
|
|
25914
|
-
const targets =
|
|
25915
|
-
if (targets.length === 0) return;
|
|
27126
|
+
async enqueueAlarmAnnouncement(panel, incoming, announcement, reached) {
|
|
27127
|
+
const targets = announcement.targetIds.filter((id) => !reached.has(id));
|
|
27128
|
+
if (targets.length === 0) return [];
|
|
25916
27129
|
const subject = incoming.subject;
|
|
27130
|
+
const alarmSource = subject.systemEvent?.alarmSource;
|
|
27131
|
+
const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
|
|
27132
|
+
const media = sourceRule !== null ? mediaPolicyFields(sourceRule, void 0) : { media: "none" };
|
|
25917
27133
|
const entries = targets.map((targetId) => ({
|
|
25918
27134
|
ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
|
|
25919
27135
|
targetId,
|
|
@@ -25924,9 +27140,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25924
27140
|
ruleName: "alarm",
|
|
25925
27141
|
delivery: "system-event",
|
|
25926
27142
|
priority: 2,
|
|
25927
|
-
media
|
|
27143
|
+
...media,
|
|
25928
27144
|
subject: {
|
|
25929
27145
|
deviceId: panel.deviceId,
|
|
27146
|
+
...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : {},
|
|
27147
|
+
...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : {},
|
|
25930
27148
|
className: "alarm",
|
|
25931
27149
|
zones: [],
|
|
25932
27150
|
timestamp: subject.timestamp,
|
|
@@ -25935,14 +27153,17 @@ var NotificationCenter = class NotificationCenter {
|
|
|
25935
27153
|
}
|
|
25936
27154
|
}));
|
|
25937
27155
|
const inserted = await this.outbox.enqueue(entries);
|
|
25938
|
-
this.logger.info("alarm
|
|
27156
|
+
this.logger.info("alarm announcement enqueued", {
|
|
25939
27157
|
tags: { deviceId: panel.deviceId },
|
|
25940
27158
|
meta: {
|
|
27159
|
+
kind: subject.systemEvent?.kind,
|
|
25941
27160
|
targets: targets.length,
|
|
25942
27161
|
inserted,
|
|
25943
|
-
bodyKey:
|
|
27162
|
+
bodyKey: subject.systemEvent?.bodyKey,
|
|
27163
|
+
media: media.media
|
|
25944
27164
|
}
|
|
25945
27165
|
});
|
|
27166
|
+
return targets;
|
|
25946
27167
|
}
|
|
25947
27168
|
/**
|
|
25948
27169
|
* Deliver to targets the producer named (`controls.directTargetIds`).
|
|
@@ -26001,12 +27222,12 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26001
27222
|
* went out twenty seconds ago — that is not a duplicate, it is the
|
|
26002
27223
|
* escalation, and this says so rather than silently doing nothing.
|
|
26003
27224
|
*/
|
|
26004
|
-
async settleCombinedAlarm(transition, incoming, report, reached) {
|
|
27225
|
+
async settleCombinedAlarm(transition, incoming, report, reached, announced) {
|
|
26005
27226
|
if (transition.kind !== "alarm-triggered") return;
|
|
26006
27227
|
const tags = { deviceId: incoming.subject.deviceId };
|
|
26007
27228
|
const source = transition.source;
|
|
26008
|
-
if (reached.size === 0) {
|
|
26009
|
-
this.logger.warn("alarm TRIGGERED but
|
|
27229
|
+
if (reached.size === 0 && announced.length === 0) {
|
|
27230
|
+
this.logger.warn("alarm TRIGGERED but neither a rule nor an announce target heard it", {
|
|
26010
27231
|
tags,
|
|
26011
27232
|
meta: {
|
|
26012
27233
|
candidates: report.candidates,
|
|
@@ -26016,19 +27237,21 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26016
27237
|
return;
|
|
26017
27238
|
}
|
|
26018
27239
|
if (source === void 0) return;
|
|
27240
|
+
const combined = new Set([...reached, ...announced]);
|
|
26019
27241
|
const dropped = await this.outbox.supersede({
|
|
26020
27242
|
ruleId: source.ruleId,
|
|
26021
27243
|
recordId: source.recordId,
|
|
26022
|
-
targetIds: [...
|
|
27244
|
+
targetIds: [...combined]
|
|
26023
27245
|
});
|
|
26024
27246
|
this.logger.info("alarm notification combined with the rule that triggered it", {
|
|
26025
27247
|
tags,
|
|
26026
27248
|
meta: {
|
|
26027
27249
|
sourceRuleId: source.ruleId,
|
|
26028
27250
|
rule: source.ruleName,
|
|
26029
|
-
targets:
|
|
27251
|
+
targets: combined.size,
|
|
27252
|
+
announced: announced.length,
|
|
26030
27253
|
superseded: dropped,
|
|
26031
|
-
alreadyDelivered:
|
|
27254
|
+
alreadyDelivered: combined.size - dropped
|
|
26032
27255
|
}
|
|
26033
27256
|
});
|
|
26034
27257
|
}
|
|
@@ -26128,7 +27351,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26128
27351
|
let noOwnerReported = false;
|
|
26129
27352
|
const rejections = [];
|
|
26130
27353
|
for (const rule of candidates) {
|
|
26131
|
-
const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
27354
|
+
const evaluation = evaluateRule(rule, subject, this.deviceStateLookup((id) => this.deviceStates.get(id)), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
|
|
26132
27355
|
if (!evaluation.matched) {
|
|
26133
27356
|
if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
|
|
26134
27357
|
if (evaluation.excludedBySystemEventFilter !== void 0) this.reportSystemFilterExclusion(rule, subject, evaluation.excludedBySystemEventFilter);
|
|
@@ -26150,6 +27373,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26150
27373
|
kind,
|
|
26151
27374
|
failed: evaluation.failedCondition,
|
|
26152
27375
|
classes: subject.classNames,
|
|
27376
|
+
...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
|
|
26153
27377
|
eventId: subject.recordId,
|
|
26154
27378
|
...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
|
|
26155
27379
|
}
|
|
@@ -26273,15 +27497,24 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26273
27497
|
});
|
|
26274
27498
|
if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
26275
27499
|
noOwnerReported = true;
|
|
26276
|
-
|
|
27500
|
+
const linked = subject.linkedCameraIds;
|
|
27501
|
+
const meta = {
|
|
27502
|
+
recordId: subject.recordId,
|
|
27503
|
+
...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
|
|
27504
|
+
ruleId: rule.id,
|
|
27505
|
+
rule: rule.name
|
|
27506
|
+
};
|
|
27507
|
+
if (linked !== void 0) this.logger.debug(linked.length === 0 ? "device event from a sensor linked to no camera — this notification ships text-only" : `device event from a sensor linked to ${linked.length} camera(s), none opted into tracked events — this notification ships text-only`, {
|
|
26277
27508
|
tags: { deviceId: subject.deviceId },
|
|
26278
27509
|
meta: {
|
|
26279
|
-
|
|
26280
|
-
|
|
26281
|
-
ruleId: rule.id,
|
|
26282
|
-
rule: rule.name
|
|
27510
|
+
...meta,
|
|
27511
|
+
linkedCameraIds: [...linked]
|
|
26283
27512
|
}
|
|
26284
27513
|
});
|
|
27514
|
+
else this.logger.warn("device event has no marker track — this notification ships text-only", {
|
|
27515
|
+
tags: { deviceId: subject.deviceId },
|
|
27516
|
+
meta
|
|
27517
|
+
});
|
|
26285
27518
|
}
|
|
26286
27519
|
if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
|
|
26287
27520
|
noOwnerReported = true;
|
|
@@ -26609,20 +27842,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26609
27842
|
...upgrade !== void 0 ? { enhancement: true } : {},
|
|
26610
27843
|
priority: rule.priority,
|
|
26611
27844
|
...rule.template !== void 0 ? { template: rule.template } : {},
|
|
26612
|
-
|
|
26613
|
-
...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
|
|
26614
|
-
...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
|
|
27845
|
+
...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds),
|
|
26615
27846
|
...confirm !== void 0 ? {
|
|
26616
27847
|
confirm,
|
|
26617
27848
|
...cooldown !== void 0 ? { cooldown } : {}
|
|
26618
27849
|
} : {},
|
|
26619
|
-
...!noMedia && mediaRule.media.frame !== void 0 ? { mediaFrame: mediaRule.media.frame } : {},
|
|
26620
|
-
...!noMedia && mediaRule.media.profile !== void 0 ? { mediaProfile: mediaRule.media.profile } : {},
|
|
26621
|
-
...!noMedia && mediaRule.media.gif === true ? { mediaGif: true } : {},
|
|
26622
|
-
...!noMedia && mediaRule.media.clip === true ? { mediaClip: true } : {},
|
|
26623
|
-
...!noMedia && mediaRule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: mediaRule.media.clipPreRollSec } : {},
|
|
26624
|
-
...!noMedia && mediaRule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: mediaRule.media.clipSpeed } : {},
|
|
26625
|
-
...!noMedia && mediaRule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: mediaRule.media.clipPostRollSec } : {},
|
|
26626
27850
|
...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
|
|
26627
27851
|
...group !== void 0 && group.members.length > 1 ? { group: {
|
|
26628
27852
|
key: group.key,
|
|
@@ -26853,8 +28077,6 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26853
28077
|
const devices = rule.conditions.devices;
|
|
26854
28078
|
if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
|
|
26855
28079
|
}
|
|
26856
|
-
const panel = this.alarmPanel;
|
|
26857
|
-
if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
|
|
26858
28080
|
this.deviceStates.setWatched(gated);
|
|
26859
28081
|
this.zoneOwners.setWatched(zoneScoped);
|
|
26860
28082
|
this.sceneStates.setWatched(sceneIds);
|
|
@@ -26868,21 +28090,41 @@ var NotificationCenter = class NotificationCenter {
|
|
|
26868
28090
|
}
|
|
26869
28091
|
}
|
|
26870
28092
|
/**
|
|
26871
|
-
*
|
|
28093
|
+
* Read every excluded device's opening and feed it back to the panel.
|
|
26872
28094
|
*
|
|
26873
28095
|
* The reconcile half of the two-phase lift. The EVENT half
|
|
26874
|
-
* ({@link
|
|
26875
|
-
*
|
|
26876
|
-
*
|
|
26877
|
-
*
|
|
26878
|
-
|
|
26879
|
-
|
|
28096
|
+
* ({@link observeOpeningEvent}) is the fast one, but an event can be dropped
|
|
28097
|
+
* (D8), so the tick re-reads the handful of excluded devices — through the
|
|
28098
|
+
* same `readDeviceOpenings` table the arm check uses, so a cover lifts on
|
|
28099
|
+
* `closed` and a lock on `locked` exactly as on the event path. A device the
|
|
28100
|
+
* read cannot answer for, or that reads unknown, changes nothing (D49).
|
|
28101
|
+
* Never throws into the tick.
|
|
28102
|
+
*/
|
|
28103
|
+
async observeAlarmOpenings() {
|
|
26880
28104
|
const panel = this.alarmPanel;
|
|
26881
|
-
|
|
28105
|
+
const read = this.deps.readDeviceOpenings;
|
|
28106
|
+
if (panel === null || read === void 0) return;
|
|
26882
28107
|
const excluded = panel.excludedDevices();
|
|
26883
28108
|
if (excluded.length === 0) return;
|
|
28109
|
+
let openings;
|
|
28110
|
+
try {
|
|
28111
|
+
openings = await read(excluded);
|
|
28112
|
+
} catch (err) {
|
|
28113
|
+
this.logger.warn("alarm exclusion reconcile: the openings read failed — nothing lifted", {
|
|
28114
|
+
tags: { deviceId: panel.deviceId },
|
|
28115
|
+
meta: {
|
|
28116
|
+
excluded: excluded.length,
|
|
28117
|
+
error: String(err)
|
|
28118
|
+
}
|
|
28119
|
+
});
|
|
28120
|
+
return;
|
|
28121
|
+
}
|
|
26884
28122
|
const now = this.now();
|
|
26885
|
-
for (const deviceId of excluded)
|
|
28123
|
+
for (const deviceId of excluded) {
|
|
28124
|
+
const opening = openings.get(deviceId);
|
|
28125
|
+
if (opening?.kind !== "reading") continue;
|
|
28126
|
+
panel.observeOpening(deviceId, opening.reading, now);
|
|
28127
|
+
}
|
|
26886
28128
|
}
|
|
26887
28129
|
/**
|
|
26888
28130
|
* Log the watched occupancy key set, on CHANGE only.
|
|
@@ -27021,7 +28263,7 @@ var NotificationCenter = class NotificationCenter {
|
|
|
27021
28263
|
await this.deviceDirectory.refresh();
|
|
27022
28264
|
await this.zoneOwners.refresh();
|
|
27023
28265
|
this.sceneStates.refresh();
|
|
27024
|
-
this.
|
|
28266
|
+
await this.observeAlarmOpenings();
|
|
27025
28267
|
await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
|
|
27026
28268
|
}
|
|
27027
28269
|
/**
|
|
@@ -27323,11 +28565,11 @@ var NotificationCenter = class NotificationCenter {
|
|
|
27323
28565
|
const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
|
|
27324
28566
|
for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
|
|
27325
28567
|
}
|
|
27326
|
-
let lookup = (id) => this.deviceStates.get(id);
|
|
28568
|
+
let lookup = this.deviceStateLookup((id) => this.deviceStates.get(id));
|
|
27327
28569
|
const gated = rule.conditions.deviceState;
|
|
27328
28570
|
if (gated !== void 0 && this.deps.readDeviceStates !== void 0) try {
|
|
27329
28571
|
const fresh = await this.deps.readDeviceStates([gated.deviceId]);
|
|
27330
|
-
lookup = (id) => fresh.get(id) ?? this.deviceStates.get(id);
|
|
28572
|
+
lookup = this.deviceStateLookup((id) => fresh.get(id) ?? this.deviceStates.get(id));
|
|
27331
28573
|
} catch {}
|
|
27332
28574
|
let zoneLookup = this.zoneOwners.lookup();
|
|
27333
28575
|
const zoneScope = rule.conditions.devices;
|
|
@@ -27591,15 +28833,28 @@ function systemEventKeys(kind) {
|
|
|
27591
28833
|
titleKey: `system.${kind}.title`,
|
|
27592
28834
|
bodyKey: `system.${kind}.body.agent`
|
|
27593
28835
|
};
|
|
27594
|
-
if (kind
|
|
28836
|
+
if (PER_MODE_TITLE_KINDS.has(kind)) return {
|
|
27595
28837
|
titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
|
|
27596
28838
|
bodyKey: `system.${kind}.body`
|
|
27597
28839
|
};
|
|
28840
|
+
if (kind === "alarm-sensor-bypass") return {
|
|
28841
|
+
titleKey: `system.${kind}.title`,
|
|
28842
|
+
bodyKey: `system.${kind}.body.ignored`
|
|
28843
|
+
};
|
|
27598
28844
|
return {
|
|
27599
28845
|
titleKey: `system.${kind}.title`,
|
|
27600
28846
|
bodyKey: `system.${kind}.body`
|
|
27601
28847
|
};
|
|
27602
28848
|
}
|
|
28849
|
+
/** The alarm kinds whose TITLE key is per arm mode (`….title.<mode>`). */
|
|
28850
|
+
var PER_MODE_TITLE_KINDS = new Set([
|
|
28851
|
+
"alarm-arm-refused",
|
|
28852
|
+
"alarm-check-failed",
|
|
28853
|
+
"alarm-arming",
|
|
28854
|
+
"alarm-armed",
|
|
28855
|
+
"alarm-pending",
|
|
28856
|
+
"alarm-rearmed"
|
|
28857
|
+
]);
|
|
27603
28858
|
/** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
|
|
27604
28859
|
var TEST_ALARM_MODE = "away";
|
|
27605
28860
|
/** The runner a synthetic `addon-crash-loop` stands in for. A test fire has no
|
|
@@ -27834,6 +29089,17 @@ var NcTrackGroupSchema = require_dist.object({
|
|
|
27834
29089
|
sealed: require_dist.boolean()
|
|
27835
29090
|
});
|
|
27836
29091
|
/**
|
|
29092
|
+
* `nc.getAlarmStatus` — the panel as it is now (admin-only for now; see the
|
|
29093
|
+
* envelope's comment). `deviceId: null`
|
|
29094
|
+
* with `live: null` is a node with no panel, which a client must not read as
|
|
29095
|
+
* "disarmed, nothing open". `live` is the published `NcAlarmConfig.live`
|
|
29096
|
+
* schema, taken from it rather than re-declared.
|
|
29097
|
+
*/
|
|
29098
|
+
var NcAlarmStatusSchema = require_dist.object({
|
|
29099
|
+
deviceId: require_dist.number().int().nullable(),
|
|
29100
|
+
live: require_dist.NcAlarmConfigSchema.shape.live.unwrap().nullable()
|
|
29101
|
+
});
|
|
29102
|
+
/**
|
|
27837
29103
|
* The action catalog — the tRPC contract Group B (the viewer client) consumes
|
|
27838
29104
|
* against `addonId: 'pipeline-analytics'`. Every entry that depends on the
|
|
27839
29105
|
* caller identity is declared `caller: 'required'` so the dispatcher forwards
|
|
@@ -27899,6 +29165,76 @@ var ncActions = require_dist.defineCustomActions({
|
|
|
27899
29165
|
caller: "required"
|
|
27900
29166
|
}),
|
|
27901
29167
|
/**
|
|
29168
|
+
* The ALARM, for the viewer and the admin Alarm tab (D630).
|
|
29169
|
+
*
|
|
29170
|
+
* Bridge actions and not cap methods, for one reason each way:
|
|
29171
|
+
*
|
|
29172
|
+
* - A REFUSED ARM IS A RESULT HERE. `alarm-panel.arm` is validated by the
|
|
29173
|
+
* hub's generated router against the HOST's copy of the cap, which says
|
|
29174
|
+
* `z.void()` — so on the cap a refusal can only be a thrown string. The
|
|
29175
|
+
* response schema of these actions ({@link NcAlarmArmOutcomeSchema})
|
|
29176
|
+
* lives in the addon and is validated only here, so a refusal travels as
|
|
29177
|
+
* `{ outcome: 'refused', refusalId, blocking }` and the viewer can offer
|
|
29178
|
+
* the Arm anyway bound to exactly that refusal.
|
|
29179
|
+
* - The UIs never send a device id. There is one panel per hub and the
|
|
29180
|
+
* centre already holds it; an id on the wire would be one more thing a
|
|
29181
|
+
* client could get wrong.
|
|
29182
|
+
*
|
|
29183
|
+
* WHO may call what. The WRITES — arm, Arm anyway, disarm — are ADMIN-ONLY,
|
|
29184
|
+
* in the envelope and again in the handler. This is not the cap's authority
|
|
29185
|
+
* and must not pretend to be: `alarm-panel.arm` / `disarm` are DEVICE-scoped
|
|
29186
|
+
* (the host checks the caller's grant on THAT panel), while `addons.custom`
|
|
29187
|
+
* is SYSTEM-scoped and knows nothing about per-device grants. Opening these
|
|
29188
|
+
* to any authenticated user would widen who can disarm the house to anyone
|
|
29189
|
+
* with a login. A non-admin keeps arming and disarming through the
|
|
29190
|
+
* device-scoped cap, exactly as today, and gets past a refusal with the Arm
|
|
29191
|
+
* anyway button on the refusal notification (an HMAC grant bound to that one
|
|
29192
|
+
* refusal).
|
|
29193
|
+
*
|
|
29194
|
+
* `nc.getAlarmStatus` is READ-ONLY, and ADMIN-ONLY too until the viewer
|
|
29195
|
+
* work scopes it. It names every covered device, open or not, with its name
|
|
29196
|
+
* and state — a map of which windows of the house are open, served over the
|
|
29197
|
+
* system-scoped `addons.custom` to ANY login, regardless of which devices
|
|
29198
|
+
* that login may see. The device-scoped way to read a panel is the cap's
|
|
29199
|
+
* `getStatus`; this one opens up once it filters to the caller's grants.
|
|
29200
|
+
* The config pair is the admin's, as `getAlarmConfig` / `setAlarmConfig` are
|
|
29201
|
+
* on the cap: it carries the delays, the recipients and the non-blocking
|
|
29202
|
+
* lists.
|
|
29203
|
+
*/
|
|
29204
|
+
"nc.getAlarmStatus": require_dist.customAction(require_dist.object({}), NcAlarmStatusSchema, {
|
|
29205
|
+
auth: "admin",
|
|
29206
|
+
caller: "required"
|
|
29207
|
+
}),
|
|
29208
|
+
"nc.armAlarm": require_dist.customAction(require_dist.object({ mode: require_dist.AlarmArmModeSchema }), NcAlarmArmOutcomeSchema, {
|
|
29209
|
+
kind: "mutation",
|
|
29210
|
+
auth: "admin",
|
|
29211
|
+
caller: "required"
|
|
29212
|
+
}),
|
|
29213
|
+
/**
|
|
29214
|
+
* `panel.armAnyway(refusalId)` — the same bound, one-shot path the refusal
|
|
29215
|
+
* notification's button takes: a second press is `used`, a panel that moved
|
|
29216
|
+
* since is `stale`, and a device that opened since is a NEW refusal.
|
|
29217
|
+
*/
|
|
29218
|
+
"nc.armAlarmAnyway": require_dist.customAction(require_dist.object({ refusalId: require_dist.string().min(1) }), NcAlarmArmOutcomeSchema, {
|
|
29219
|
+
kind: "mutation",
|
|
29220
|
+
auth: "admin",
|
|
29221
|
+
caller: "required"
|
|
29222
|
+
}),
|
|
29223
|
+
"nc.disarmAlarm": require_dist.customAction(require_dist.object({}), require_dist.object({ success: require_dist.literal(true) }), {
|
|
29224
|
+
kind: "mutation",
|
|
29225
|
+
auth: "admin",
|
|
29226
|
+
caller: "required"
|
|
29227
|
+
}),
|
|
29228
|
+
"nc.getAlarmConfig": require_dist.customAction(require_dist.object({}), require_dist.NcAlarmConfigSchema, {
|
|
29229
|
+
auth: "admin",
|
|
29230
|
+
caller: "required"
|
|
29231
|
+
}),
|
|
29232
|
+
"nc.setAlarmConfig": require_dist.customAction(require_dist.object({ patch: require_dist.NcAlarmSettingsPatchSchema }), require_dist.NcAlarmConfigSchema, {
|
|
29233
|
+
kind: "mutation",
|
|
29234
|
+
auth: "admin",
|
|
29235
|
+
caller: "required"
|
|
29236
|
+
}),
|
|
29237
|
+
/**
|
|
27902
29238
|
* The profiles a rule's AI section may be PINNED to — the picker's options.
|
|
27903
29239
|
*
|
|
27904
29240
|
* A bridge action and not `llm.listProfiles` straight from the client, for
|
|
@@ -28246,7 +29582,22 @@ function requireCaller$1(caller) {
|
|
|
28246
29582
|
if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
|
|
28247
29583
|
return caller;
|
|
28248
29584
|
}
|
|
29585
|
+
/**
|
|
29586
|
+
* An ADMIN caller, or a refusal. The dispatcher already gates an
|
|
29587
|
+
* `auth: 'admin'` action; this is the same check on this side of the bridge,
|
|
29588
|
+
* so a dispatcher that did not forward the flag cannot open it.
|
|
29589
|
+
*/
|
|
29590
|
+
function requireAdmin(caller) {
|
|
29591
|
+
const c = requireCaller$1(caller);
|
|
29592
|
+
if (!c.isAdmin) throw new Error("forbidden: admin required");
|
|
29593
|
+
return c;
|
|
29594
|
+
}
|
|
28249
29595
|
function makeNcActionHandlers(deps) {
|
|
29596
|
+
/** The alarm half, or a refusal by name — never a made-up "disarmed". */
|
|
29597
|
+
const requireAlarm = (action) => {
|
|
29598
|
+
if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
|
|
29599
|
+
return deps.alarm;
|
|
29600
|
+
};
|
|
28250
29601
|
/** A rule the caller may EDIT — must exist and be owned by the caller. */
|
|
28251
29602
|
const assertOwnsRule = (ruleId) => {
|
|
28252
29603
|
const rule = deps.ruleStore.get(ruleId);
|
|
@@ -28467,6 +29818,31 @@ function makeNcActionHandlers(deps) {
|
|
|
28467
29818
|
} });
|
|
28468
29819
|
return { success: true };
|
|
28469
29820
|
},
|
|
29821
|
+
"nc.getAlarmStatus": async (_input, caller) => {
|
|
29822
|
+
requireAdmin(caller);
|
|
29823
|
+
return requireAlarm("nc.getAlarmStatus").status();
|
|
29824
|
+
},
|
|
29825
|
+
"nc.armAlarm": async (input, caller) => {
|
|
29826
|
+
const c = requireAdmin(caller);
|
|
29827
|
+
return requireAlarm("nc.armAlarm").arm(input.mode, c.userId);
|
|
29828
|
+
},
|
|
29829
|
+
"nc.armAlarmAnyway": async (input, caller) => {
|
|
29830
|
+
const c = requireAdmin(caller);
|
|
29831
|
+
return requireAlarm("nc.armAlarmAnyway").armAnyway(input.refusalId, c.userId);
|
|
29832
|
+
},
|
|
29833
|
+
"nc.disarmAlarm": async (_input, caller) => {
|
|
29834
|
+
const c = requireAdmin(caller);
|
|
29835
|
+
await requireAlarm("nc.disarmAlarm").disarm(c.userId);
|
|
29836
|
+
return { success: true };
|
|
29837
|
+
},
|
|
29838
|
+
"nc.getAlarmConfig": async (_input, caller) => {
|
|
29839
|
+
requireAdmin(caller);
|
|
29840
|
+
return requireAlarm("nc.getAlarmConfig").getConfig();
|
|
29841
|
+
},
|
|
29842
|
+
"nc.setAlarmConfig": async (input, caller) => {
|
|
29843
|
+
requireAdmin(caller);
|
|
29844
|
+
return requireAlarm("nc.setAlarmConfig").setConfig(input.patch);
|
|
29845
|
+
},
|
|
28470
29846
|
"nc.listAiProfiles": async (_input, caller) => {
|
|
28471
29847
|
requireCaller$1(caller);
|
|
28472
29848
|
const list = deps.listAiProfiles;
|
|
@@ -70725,8 +72101,27 @@ var LinkedCamerasCache = class {
|
|
|
70725
72101
|
deps;
|
|
70726
72102
|
ttlMs;
|
|
70727
72103
|
index = /* @__PURE__ */ new Map();
|
|
72104
|
+
/**
|
|
72105
|
+
* source deviceId → EVERY camera that has it among its linked devices,
|
|
72106
|
+
* ignoring the tracked-events opt-in. Built in the same loop as
|
|
72107
|
+
* {@link index}; the opt-in governs timeline rows only.
|
|
72108
|
+
*/
|
|
72109
|
+
allIndex = /* @__PURE__ */ new Map();
|
|
70728
72110
|
/** Every camera id seen by the last successful build. */
|
|
70729
72111
|
cameras = /* @__PURE__ */ new Set();
|
|
72112
|
+
/**
|
|
72113
|
+
* Each camera's links as last READ successfully. A later rebuild that cannot
|
|
72114
|
+
* read a camera's links carries these forward rather than dropping them: the
|
|
72115
|
+
* mirror stays authoritative until something contradicts it (D49).
|
|
72116
|
+
*/
|
|
72117
|
+
linksByCamera = /* @__PURE__ */ new Map();
|
|
72118
|
+
/** False until a camera list has been read once. A later failed list keeps
|
|
72119
|
+
* the previous index, and this stays true. */
|
|
72120
|
+
cameraListRead = false;
|
|
72121
|
+
/** Listed cameras whose links have NEVER been read. Non-empty (or no camera
|
|
72122
|
+
* list yet) ⇒ the index has not answered: a device absent from it is
|
|
72123
|
+
* UNKNOWN, not unlinked (D49). */
|
|
72124
|
+
unanswered = [];
|
|
70730
72125
|
/** Ms timestamp of the last build; null = never built / invalidated. */
|
|
70731
72126
|
builtAt = null;
|
|
70732
72127
|
building = null;
|
|
@@ -70740,6 +72135,32 @@ var LinkedCamerasCache = class {
|
|
|
70740
72135
|
return this.index.get(sourceDeviceId) ?? [];
|
|
70741
72136
|
}
|
|
70742
72137
|
/**
|
|
72138
|
+
* EVERY camera that links `sourceDeviceId`, whether or not it opted the
|
|
72139
|
+
* device into tracked events ([] when none). The notification side asks this
|
|
72140
|
+
* one; timeline rows ask {@link camerasFor}.
|
|
72141
|
+
*/
|
|
72142
|
+
async allCamerasFor(sourceDeviceId, nowMs = Date.now()) {
|
|
72143
|
+
await this.ensureFresh(nowMs);
|
|
72144
|
+
return this.allIndex.get(sourceDeviceId) ?? [];
|
|
72145
|
+
}
|
|
72146
|
+
/**
|
|
72147
|
+
* True when the index ANSWERED: a camera list has been read, and every listed
|
|
72148
|
+
* camera's links have been read at least once (a camera whose read failed
|
|
72149
|
+
* later answers with its last good links). False means an empty answer from the other methods
|
|
72150
|
+
* is not knowledge and must not be acted on as "linked to nothing".
|
|
72151
|
+
*/
|
|
72152
|
+
async answered(nowMs = Date.now()) {
|
|
72153
|
+
await this.ensureFresh(nowMs);
|
|
72154
|
+
return this.cameraListRead && this.unanswered.length === 0;
|
|
72155
|
+
}
|
|
72156
|
+
/** What the index could not answer, as of the last build — no refresh. */
|
|
72157
|
+
gap() {
|
|
72158
|
+
return {
|
|
72159
|
+
cameraListRead: this.cameraListRead,
|
|
72160
|
+
unansweredCameraIds: [...this.unanswered]
|
|
72161
|
+
};
|
|
72162
|
+
}
|
|
72163
|
+
/**
|
|
70743
72164
|
* True when `deviceId` is ITSELF a camera. Answered off the same build as
|
|
70744
72165
|
* {@link camerasFor} — a sensor cap bound on a camera needs both facts in
|
|
70745
72166
|
* the same event, and a second enumeration would double the cost of the
|
|
@@ -70754,10 +72175,13 @@ var LinkedCamerasCache = class {
|
|
|
70754
72175
|
this.builtAt = null;
|
|
70755
72176
|
}
|
|
70756
72177
|
/** Test/maintenance hook: replace the index directly. */
|
|
70757
|
-
seed(index, builtAt, cameraIds = []) {
|
|
72178
|
+
seed(index, builtAt, cameraIds = [], allIndex = index) {
|
|
70758
72179
|
this.index = new Map(index);
|
|
72180
|
+
this.allIndex = new Map(allIndex);
|
|
70759
72181
|
this.cameras = new Set(cameraIds);
|
|
70760
72182
|
this.builtAt = builtAt;
|
|
72183
|
+
this.cameraListRead = true;
|
|
72184
|
+
this.unanswered = [];
|
|
70761
72185
|
}
|
|
70762
72186
|
/** Rebuild (single-flight) when never built or past the TTL. */
|
|
70763
72187
|
async ensureFresh(nowMs) {
|
|
@@ -70779,7 +72203,8 @@ var LinkedCamerasCache = class {
|
|
|
70779
72203
|
*
|
|
70780
72204
|
* A batch that REJECTS falls back rather than emptying the index: a stale
|
|
70781
72205
|
* reverse index still routes sensor events, an empty one silently routes
|
|
70782
|
-
* none.
|
|
72206
|
+
* none. A camera whose links could not be read is simply ABSENT from the
|
|
72207
|
+
* returned map — the caller carries its previous links forward.
|
|
70783
72208
|
*/
|
|
70784
72209
|
async resolveLinks(cameraIds) {
|
|
70785
72210
|
const batch = this.deps.linkedDevicesBatch;
|
|
@@ -70794,23 +72219,38 @@ var LinkedCamerasCache = class {
|
|
|
70794
72219
|
const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
|
|
70795
72220
|
out.set(cameraId, devices);
|
|
70796
72221
|
} catch (err) {
|
|
70797
|
-
this.deps.onError?.("getLinkedDevices", err);
|
|
72222
|
+
this.deps.onError?.("getLinkedDevices", err, cameraId);
|
|
70798
72223
|
}
|
|
70799
72224
|
return out;
|
|
70800
72225
|
}
|
|
70801
72226
|
async rebuild(nowMs) {
|
|
70802
72227
|
try {
|
|
70803
72228
|
const cameraIds = await this.deps.cameras.listCameraIds();
|
|
70804
|
-
const
|
|
72229
|
+
const fresh = await this.resolveLinks(cameraIds);
|
|
72230
|
+
const linksByCamera = /* @__PURE__ */ new Map();
|
|
72231
|
+
const unanswered = [];
|
|
72232
|
+
for (const cameraId of cameraIds) {
|
|
72233
|
+
const links = fresh.get(cameraId) ?? this.linksByCamera.get(cameraId);
|
|
72234
|
+
if (links === void 0) unanswered.push(cameraId);
|
|
72235
|
+
else linksByCamera.set(cameraId, links);
|
|
72236
|
+
}
|
|
70805
72237
|
const next = /* @__PURE__ */ new Map();
|
|
72238
|
+
const nextAll = /* @__PURE__ */ new Map();
|
|
70806
72239
|
for (const cameraId of cameraIds) for (const d of linksByCamera.get(cameraId) ?? []) {
|
|
72240
|
+
const all = nextAll.get(d.deviceId);
|
|
72241
|
+
if (all === void 0) nextAll.set(d.deviceId, [cameraId]);
|
|
72242
|
+
else if (!all.includes(cameraId)) all.push(cameraId);
|
|
70807
72243
|
if (d.producesTrackedEvents === false) continue;
|
|
70808
72244
|
const list = next.get(d.deviceId);
|
|
70809
72245
|
if (list === void 0) next.set(d.deviceId, [cameraId]);
|
|
70810
72246
|
else if (!list.includes(cameraId)) list.push(cameraId);
|
|
70811
72247
|
}
|
|
70812
72248
|
this.index = next;
|
|
72249
|
+
this.allIndex = nextAll;
|
|
70813
72250
|
this.cameras = new Set(cameraIds);
|
|
72251
|
+
this.linksByCamera = linksByCamera;
|
|
72252
|
+
this.unanswered = unanswered;
|
|
72253
|
+
this.cameraListRead = true;
|
|
70814
72254
|
this.builtAt = nowMs;
|
|
70815
72255
|
} catch (err) {
|
|
70816
72256
|
this.deps.onError?.("listCameraIds", err);
|
|
@@ -70834,13 +72274,25 @@ async function resolveSensorEventCameras(cache, data) {
|
|
|
70834
72274
|
const linkedCameraIds = await cache.camerasFor(data.deviceId);
|
|
70835
72275
|
const isCamera = await cache.isCamera(data.deviceId);
|
|
70836
72276
|
const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
|
|
70837
|
-
if (cameraIds.length
|
|
72277
|
+
if (cameraIds.length > 0) return {
|
|
72278
|
+
cameraIds,
|
|
72279
|
+
dropReason: null,
|
|
72280
|
+
linkedCameraIds: []
|
|
72281
|
+
};
|
|
72282
|
+
if (isCamera) return {
|
|
72283
|
+
cameraIds,
|
|
72284
|
+
dropReason: "camera-native-cap",
|
|
72285
|
+
linkedCameraIds: []
|
|
72286
|
+
};
|
|
72287
|
+
if (!await cache.answered()) return {
|
|
70838
72288
|
cameraIds,
|
|
70839
|
-
dropReason:
|
|
72289
|
+
dropReason: "camera-index-unknown",
|
|
72290
|
+
linkedCameraIds: []
|
|
70840
72291
|
};
|
|
70841
72292
|
return {
|
|
70842
72293
|
cameraIds,
|
|
70843
|
-
dropReason:
|
|
72294
|
+
dropReason: "no-camera",
|
|
72295
|
+
linkedCameraIds: await cache.allCamerasFor(data.deviceId)
|
|
70844
72296
|
};
|
|
70845
72297
|
}
|
|
70846
72298
|
/**
|
|
@@ -70850,20 +72302,37 @@ async function resolveSensorEventCameras(cache, data) {
|
|
|
70850
72302
|
* camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
|
|
70851
72303
|
* self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
|
|
70852
72304
|
* churn costs one map lookup. Returns the number of rows inserted (0 when
|
|
70853
|
-
* unmapped, or when the device
|
|
72305
|
+
* unmapped, or when no camera takes a row for the device — a non-camera
|
|
72306
|
+
* sensor then goes to `onUnowned` instead, evaluated without a row, once the
|
|
72307
|
+
* camera index answered).
|
|
70854
72308
|
* Telemetry-lossy by design (D8) — inserts are best-effort.
|
|
70855
72309
|
*/
|
|
70856
72310
|
async function ingestSensorStateChange(deps, data, timestamp) {
|
|
70857
72311
|
const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
|
|
70858
72312
|
if (descriptor === void 0) return 0;
|
|
70859
|
-
const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
|
|
72313
|
+
const { cameraIds, dropReason, linkedCameraIds } = await resolveSensorEventCameras(deps.cache, data);
|
|
72314
|
+
const slice = data.slice;
|
|
72315
|
+
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
72316
|
+
const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
|
|
72317
|
+
if (dropReason === "no-camera" && deps.onUnowned !== void 0) {
|
|
72318
|
+
const ev = {
|
|
72319
|
+
id: makeId(),
|
|
72320
|
+
deviceId: data.deviceId,
|
|
72321
|
+
sourceDeviceId: data.deviceId,
|
|
72322
|
+
kind: descriptor.kind,
|
|
72323
|
+
value,
|
|
72324
|
+
timestamp
|
|
72325
|
+
};
|
|
72326
|
+
deps.onUnowned({
|
|
72327
|
+
event: ev,
|
|
72328
|
+
linkedCameraIds
|
|
72329
|
+
});
|
|
72330
|
+
return 0;
|
|
72331
|
+
}
|
|
70860
72332
|
if (dropReason !== null) {
|
|
70861
72333
|
deps.onDropped?.(dropReason, descriptor.kind);
|
|
70862
72334
|
return 0;
|
|
70863
72335
|
}
|
|
70864
|
-
const slice = data.slice;
|
|
70865
|
-
const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
|
|
70866
|
-
const makeId = deps.makeId ?? (() => `pa-sensor-${(0, node_crypto.randomUUID)()}`);
|
|
70867
72336
|
let inserted = 0;
|
|
70868
72337
|
for (const cameraId of cameraIds) {
|
|
70869
72338
|
const ev = {
|
|
@@ -71933,12 +73402,12 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71933
73402
|
*
|
|
71934
73403
|
* `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
|
|
71935
73404
|
* ten that have a boolean whose edge means something. `doorbell`, `button`,
|
|
71936
|
-
* `
|
|
71937
|
-
*
|
|
73405
|
+
* `presence`, `enum-sensor` and `event-emitter` are NOT levels — their ARRIVAL
|
|
73406
|
+
* is the event, and three presses in a row are three markers.
|
|
71938
73407
|
* Gating them would re-ship the failure of 2026-08-08, when camera 615's press
|
|
71939
73408
|
* produced a row, a ring, a matched rule and no marker.
|
|
71940
73409
|
*
|
|
71941
|
-
* ## A cover is a level whose level is a WORD
|
|
73410
|
+
* ## A cover — and a lock — is a level whose level is a WORD
|
|
71942
73411
|
*
|
|
71943
73412
|
* `cover` is the sixteenth cap, and it is neither of the above. Its level is
|
|
71944
73413
|
* the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
|
|
@@ -71966,6 +73435,39 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71966
73435
|
* is reported once as if the word had just changed. The alarm needs covers
|
|
71967
73436
|
* to be eventful to see one open (D630).
|
|
71968
73437
|
*
|
|
73438
|
+
* `lock-control` follows the same rule on its `state` word, with `locked` as
|
|
73439
|
+
* the resting word. It used to pass through as "arrival is the event", and a
|
|
73440
|
+
* lock is exactly what the alarm watches: a placeholder `unlocked` written by
|
|
73441
|
+
* a provider constructor after a respawn was an unlock, and the armed panel
|
|
73442
|
+
* could fire on it (D630).
|
|
73443
|
+
*
|
|
73444
|
+
* ## A placeholder slice is not a state
|
|
73445
|
+
*
|
|
73446
|
+
* Provider constructors write a cold slice before they have read the device —
|
|
73447
|
+
* `{ entryOpen: false, lastChangedAt: 0 }` for an HA binary sensor, `unlocked`
|
|
73448
|
+
* for an HA or Homematic lock, `closed` for an HA cover. `lastChangedAt: 0`
|
|
73449
|
+
* is the one field that says "nobody has read this yet", and a slice carrying
|
|
73450
|
+
* it is dropped BEFORE any cap-specific rule (`cold-slice`) and writes no
|
|
73451
|
+
* memory. Remembered, it became a baseline the real state then "changed"
|
|
73452
|
+
* from: a placeholder `closed` followed by the real `open` was an opening,
|
|
73453
|
+
* and while the panel was armed that opening could lift an exclusion and fire
|
|
73454
|
+
* the siren. Dropped, the real slice is a first sighting and goes through the
|
|
73455
|
+
* freshness floor like any other.
|
|
73456
|
+
*
|
|
73457
|
+
* The change stamp is a three-way convention every provider writes and this
|
|
73458
|
+
* gate and the alarm (`alarm-openings.ts`) both read:
|
|
73459
|
+
*
|
|
73460
|
+
* - `0` — a placeholder: the VALUE is unknown. Dropped here, `unknown` there.
|
|
73461
|
+
* - `> 0` but below the floor — a KNOWN value whose change time is old or
|
|
73462
|
+
* unknown. A provider that REPLAYS a cached value after a respawn and has
|
|
73463
|
+
* no upstream change time (Homematic: XML-RPC `getParamset` carries none)
|
|
73464
|
+
* stamps it `1`, epoch + 1 ms (`HM_SEEDED_CHANGED_AT`). Here it seeds a
|
|
73465
|
+
* baseline and is never a first-sighting event; there it is a real reading,
|
|
73466
|
+
* so an arm check still blocks on a lock that is really unlocked. Stamping
|
|
73467
|
+
* such a replay `now` is the laundering this rules out: a lock unlocked for
|
|
73468
|
+
* hours cleared the floor as a fresh unlock after a hub restart.
|
|
73469
|
+
* - at or above the floor — a change that happened then.
|
|
73470
|
+
*
|
|
71969
73471
|
* ## Nothing here logs, and no drop is silent
|
|
71970
73472
|
*
|
|
71971
73473
|
* The gate is pure towards the world (no bus, no logger, no clock of its own —
|
|
@@ -71980,9 +73482,15 @@ async function projectSensorMarkers(deps, data, timestamp) {
|
|
|
71980
73482
|
* Caps whose level is a string word rather than a boolean: cap → slice field.
|
|
71981
73483
|
* See "A cover is a level whose level is a WORD" above.
|
|
71982
73484
|
*/
|
|
71983
|
-
var WORD_LEVEL_FIELD = {
|
|
73485
|
+
var WORD_LEVEL_FIELD = {
|
|
73486
|
+
cover: "state",
|
|
73487
|
+
"lock-control": "state"
|
|
73488
|
+
};
|
|
71984
73489
|
/** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
|
|
71985
|
-
var WORD_LEVEL_INACTIVE = {
|
|
73490
|
+
var WORD_LEVEL_INACTIVE = {
|
|
73491
|
+
cover: "closed",
|
|
73492
|
+
"lock-control": "locked"
|
|
73493
|
+
};
|
|
71986
73494
|
var SensorTransitionGate = class {
|
|
71987
73495
|
/** Last observed boolean, keyed by `${deviceId}:${capName}`. */
|
|
71988
73496
|
lastValues = /* @__PURE__ */ new Map();
|
|
@@ -72005,11 +73513,13 @@ var SensorTransitionGate = class {
|
|
|
72005
73513
|
/**
|
|
72006
73514
|
* Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
|
|
72007
73515
|
*
|
|
72008
|
-
* A
|
|
72009
|
-
*
|
|
72010
|
-
*
|
|
73516
|
+
* A placeholder slice (`lastChangedAt === 0`) is dropped first, for every
|
|
73517
|
+
* cap, and remembers nothing. Otherwise a cap with no level passes through
|
|
73518
|
+
* unconditionally, and a cap that HAS one passes only when its level moved —
|
|
73519
|
+
* a boolean in either direction, or a word-level cap's word changing.
|
|
72011
73520
|
*/
|
|
72012
73521
|
observe(deviceId, capName, slice) {
|
|
73522
|
+
if (slice["lastChangedAt"] === 0) return this.drop(deviceId, capName, "cold-slice");
|
|
72013
73523
|
const wordField = WORD_LEVEL_FIELD[capName];
|
|
72014
73524
|
if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
|
|
72015
73525
|
if (!require_dist.isSourceCap(capName)) return { changed: true };
|
|
@@ -72094,6 +73604,36 @@ var SensorTransitionGate = class {
|
|
|
72094
73604
|
});
|
|
72095
73605
|
}
|
|
72096
73606
|
};
|
|
73607
|
+
var CameraIndexUnknownDrops = class {
|
|
73608
|
+
drops = /* @__PURE__ */ new Map();
|
|
73609
|
+
windowStartedAt;
|
|
73610
|
+
windowMs;
|
|
73611
|
+
constructor(options) {
|
|
73612
|
+
this.windowStartedAt = options.nowMs;
|
|
73613
|
+
this.windowMs = options.windowMs ?? 6e4;
|
|
73614
|
+
}
|
|
73615
|
+
record(deviceId, capName) {
|
|
73616
|
+
const key = `${deviceId}:${capName}`;
|
|
73617
|
+
const prior = this.drops.get(key);
|
|
73618
|
+
this.drops.set(key, {
|
|
73619
|
+
deviceId,
|
|
73620
|
+
capName,
|
|
73621
|
+
count: (prior?.count ?? 0) + 1
|
|
73622
|
+
});
|
|
73623
|
+
}
|
|
73624
|
+
/**
|
|
73625
|
+
* The window's drops, sorted by `(deviceId, capName)`, once the window has
|
|
73626
|
+
* elapsed; [] before that or when nothing dropped. Taking resets the window.
|
|
73627
|
+
*/
|
|
73628
|
+
takeSummary(nowMs) {
|
|
73629
|
+
if (nowMs - this.windowStartedAt < this.windowMs) return [];
|
|
73630
|
+
this.windowStartedAt = nowMs;
|
|
73631
|
+
if (this.drops.size === 0) return [];
|
|
73632
|
+
const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName));
|
|
73633
|
+
this.drops.clear();
|
|
73634
|
+
return out;
|
|
73635
|
+
}
|
|
73636
|
+
};
|
|
72097
73637
|
//#endregion
|
|
72098
73638
|
//#region src/pipeline-analytics/services/synthetic-track.ts
|
|
72099
73639
|
/**
|
|
@@ -77250,6 +78790,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
77250
78790
|
startedAtMs: Date.now(),
|
|
77251
78791
|
now: () => Date.now()
|
|
77252
78792
|
});
|
|
78793
|
+
/** `camera-index-unknown` drops, summarised per window — see the file. */
|
|
78794
|
+
cameraIndexUnknownDrops = new CameraIndexUnknownDrops({ nowMs: Date.now() });
|
|
77253
78795
|
/** The ONE projector of non-spatial events into synthetic snapshot-carrying
|
|
77254
78796
|
* tracks — linked sensor/control state changes AND audio markers. */
|
|
77255
78797
|
syntheticTracks = null;
|
|
@@ -78672,6 +80214,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78672
80214
|
previewTexts: center.textCatalog,
|
|
78673
80215
|
...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
|
|
78674
80216
|
artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
|
|
80217
|
+
alarm: center.alarmActions(),
|
|
78675
80218
|
ruleStatus: {
|
|
78676
80219
|
forRule: (rule) => center.ruleStatusFor(rule),
|
|
78677
80220
|
forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
|
|
@@ -78800,19 +80343,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
78800
80343
|
const panel = this.alarmPanelDevice;
|
|
78801
80344
|
if (panel === null || this.alarmPanelWired) return;
|
|
78802
80345
|
this.alarmPanelWired = true;
|
|
78803
|
-
center.setAlarmPanel(
|
|
78804
|
-
deviceId: panel.id,
|
|
78805
|
-
settings: () => panel.settings(),
|
|
78806
|
-
applySettings: (patch) => panel.applySettings(patch),
|
|
78807
|
-
availableModes: () => NC_ALARM_MODES,
|
|
78808
|
-
noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
|
|
78809
|
-
excludedDevices: () => panel.excludedDevices(),
|
|
78810
|
-
observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
|
|
78811
|
-
});
|
|
80346
|
+
center.setAlarmPanel(ncAlarmPanelPort(panel));
|
|
78812
80347
|
panel.setPublishHook((report) => center.onAlarmPublish(report));
|
|
78813
80348
|
panel.setArmGuard(center.alarmArmGuard());
|
|
78814
80349
|
center.onAlarmPublish({
|
|
78815
80350
|
state: panel.currentState(),
|
|
80351
|
+
target: panel.currentTarget(),
|
|
78816
80352
|
transition: null
|
|
78817
80353
|
});
|
|
78818
80354
|
this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
|
|
@@ -79255,10 +80791,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
79255
80791
|
} },
|
|
79256
80792
|
linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
|
|
79257
80793
|
linkedDevicesBatch: { getLinkedDevicesBatch: (input) => api.deviceManager.getLinkedDevicesBatch.query(input) },
|
|
79258
|
-
onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", {
|
|
79259
|
-
|
|
79260
|
-
|
|
79261
|
-
|
|
80794
|
+
onError: (scope, err, cameraId) => logger.warn("linked-cameras cache refresh failed", {
|
|
80795
|
+
...cameraId !== void 0 ? { tags: { deviceId: cameraId } } : {},
|
|
80796
|
+
meta: {
|
|
80797
|
+
scope,
|
|
80798
|
+
error: require_dist.errMsg(err)
|
|
80799
|
+
}
|
|
80800
|
+
})
|
|
79262
80801
|
});
|
|
79263
80802
|
const syntheticTracks = new SyntheticTrackMaterializer({
|
|
79264
80803
|
snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
|
|
@@ -79711,6 +81250,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
79711
81250
|
}
|
|
79712
81251
|
return out;
|
|
79713
81252
|
},
|
|
81253
|
+
readDeviceOpenings: (ids) => readAlarmOpenings(ids, (deviceId) => api.deviceManager.loadRuntimeState.query({ deviceId }), this.ctx.logger),
|
|
79714
81254
|
readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
|
|
79715
81255
|
sceneId: monitor.id,
|
|
79716
81256
|
deviceId,
|
|
@@ -85442,11 +86982,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85442
86982
|
if (store === null || cache === null) return;
|
|
85443
86983
|
if (!this.sensorSliceCarriedATransition(data)) return;
|
|
85444
86984
|
const persisted = [];
|
|
86985
|
+
const unowned = [];
|
|
85445
86986
|
try {
|
|
85446
86987
|
await ingestSensorStateChange({
|
|
85447
86988
|
sink: store,
|
|
85448
86989
|
cache,
|
|
85449
86990
|
onPersisted: (ev) => persisted.push(ev),
|
|
86991
|
+
onUnowned: (u) => unowned.push(u),
|
|
85450
86992
|
onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
|
|
85451
86993
|
}, data, timestamp);
|
|
85452
86994
|
} catch (err) {
|
|
@@ -85458,6 +87000,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85458
87000
|
}
|
|
85459
87001
|
});
|
|
85460
87002
|
}
|
|
87003
|
+
this.flushCameraIndexUnknownDrops();
|
|
85461
87004
|
const markerByCamera = /* @__PURE__ */ new Map();
|
|
85462
87005
|
const materializer = this.syntheticTracks;
|
|
85463
87006
|
if (materializer !== null) try {
|
|
@@ -85501,7 +87044,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85501
87044
|
}
|
|
85502
87045
|
});
|
|
85503
87046
|
}
|
|
85504
|
-
for (const ev of persisted)
|
|
87047
|
+
for (const ev of persisted) try {
|
|
87048
|
+
this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
|
|
87049
|
+
} catch (err) {
|
|
87050
|
+
this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
|
|
87051
|
+
tags: { deviceId: ev.deviceId },
|
|
87052
|
+
meta: {
|
|
87053
|
+
kind: ev.kind,
|
|
87054
|
+
sourceDeviceId: ev.sourceDeviceId,
|
|
87055
|
+
error: require_dist.errMsg(err)
|
|
87056
|
+
}
|
|
87057
|
+
});
|
|
87058
|
+
}
|
|
87059
|
+
for (const { event: ev, linkedCameraIds } of unowned) {
|
|
87060
|
+
this.ctx.logger.debug(linkedCameraIds.length === 0 ? "sensor event linked to no camera — evaluated without a timeline row" : `sensor event linked to ${linkedCameraIds.length} camera(s), none opted into tracked events — evaluated without a timeline row`, {
|
|
87061
|
+
tags: { deviceId: ev.sourceDeviceId },
|
|
87062
|
+
meta: {
|
|
87063
|
+
kind: ev.kind,
|
|
87064
|
+
linkedCameraIds: [...linkedCameraIds]
|
|
87065
|
+
}
|
|
87066
|
+
});
|
|
87067
|
+
try {
|
|
87068
|
+
this.notificationCenter?.onUnownedSensorEvent(ev, linkedCameraIds);
|
|
87069
|
+
} catch (err) {
|
|
87070
|
+
this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
|
|
87071
|
+
tags: { deviceId: ev.sourceDeviceId },
|
|
87072
|
+
meta: {
|
|
87073
|
+
kind: ev.kind,
|
|
87074
|
+
linkedCameraIds: [...linkedCameraIds],
|
|
87075
|
+
error: require_dist.errMsg(err)
|
|
87076
|
+
}
|
|
87077
|
+
});
|
|
87078
|
+
}
|
|
87079
|
+
}
|
|
85505
87080
|
}
|
|
85506
87081
|
/**
|
|
85507
87082
|
* True when this slice carried a real transition of its sensor.
|
|
@@ -85555,6 +87130,29 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85555
87130
|
});
|
|
85556
87131
|
}
|
|
85557
87132
|
/**
|
|
87133
|
+
* One `info` line per (device, cap) per window for the evaluations refused
|
|
87134
|
+
* because the camera index has not answered, or none. It drops evaluations
|
|
87135
|
+
* (a contact that might trigger the alarm), so it is `info`; it is a COUNT
|
|
87136
|
+
* so a cold-start outage cannot log at bus rate. `unansweredCameraIds` names
|
|
87137
|
+
* the cameras whose links were never read (`cameraListRead: false` = not
|
|
87138
|
+
* even the camera list).
|
|
87139
|
+
*/
|
|
87140
|
+
flushCameraIndexUnknownDrops() {
|
|
87141
|
+
const summary = this.cameraIndexUnknownDrops.takeSummary(Date.now());
|
|
87142
|
+
if (summary.length === 0) return;
|
|
87143
|
+
const gap = this.linkedCamerasCache?.gap();
|
|
87144
|
+
for (const drop of summary) this.ctx.logger.info("sensor events NOT evaluated — the camera index has not answered, so \"linked to no camera\" is unknown", {
|
|
87145
|
+
tags: { deviceId: drop.deviceId },
|
|
87146
|
+
meta: {
|
|
87147
|
+
capName: drop.capName,
|
|
87148
|
+
count: drop.count,
|
|
87149
|
+
reason: "camera-index-unknown",
|
|
87150
|
+
cameraListRead: gap?.cameraListRead ?? false,
|
|
87151
|
+
unansweredCameraIds: [...gap?.unansweredCameraIds ?? []]
|
|
87152
|
+
}
|
|
87153
|
+
});
|
|
87154
|
+
}
|
|
87155
|
+
/**
|
|
85558
87156
|
* Say out loud that a MAPPED sensor cap ingested nothing, and why.
|
|
85559
87157
|
*
|
|
85560
87158
|
* BOTH branches are `debug`, deliberately. After the self-camera fix neither
|
|
@@ -85565,7 +87163,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85565
87163
|
* binds a native `motion` cap on the camera device and motion churns
|
|
85566
87164
|
* continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
|
|
85567
87165
|
* log flood measured in millions of lines a day — its own outage.
|
|
85568
|
-
* - `no-camera` is every sensor in the fleet that no camera links.
|
|
87166
|
+
* - `no-camera` is every sensor in the fleet that no camera links. Since
|
|
87167
|
+
* D630 §6 it is still EVALUATED by the notification rules (through
|
|
87168
|
+
* `onUnowned`) — this line only says no timeline row was written, and it
|
|
87169
|
+
* fires only when no one took the camera-less event.
|
|
85569
87170
|
*
|
|
85570
87171
|
* What must never happen again is the branch being INVISIBLE: a doorbell
|
|
85571
87172
|
* press that ingested nothing produced not one line for months. A debug line
|
|
@@ -85573,7 +87174,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
|
|
|
85573
87174
|
* dark" the moment anyone asks it.
|
|
85574
87175
|
*/
|
|
85575
87176
|
logSensorIngestDrop(data, reason, kind) {
|
|
85576
|
-
|
|
87177
|
+
if (reason === "camera-index-unknown") {
|
|
87178
|
+
this.cameraIndexUnknownDrops.record(data.deviceId, data.capName);
|
|
87179
|
+
return;
|
|
87180
|
+
}
|
|
87181
|
+
const message = reason === "camera-native-cap" ? "sensor-event ingest produced NO row — the camera already emits this cap through its own path" : "sensor-event ingest produced NO row — no camera takes a row for this device, and nothing took it for evaluation";
|
|
85577
87182
|
this.ctx.logger.debug(message, {
|
|
85578
87183
|
tags: { deviceId: data.deviceId },
|
|
85579
87184
|
meta: {
|