@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.
@@ -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-Br0p61E2.js");
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": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
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": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
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
- /** Send a notification when a mode takes effect. Off until asked for. */
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 that announcement goes to — see `NcAlarmSettingsSchema`. */
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
- * Sensors the CURRENT arm is ignoring because they were open when the
1381
- * operator confirmed it, and how far each is through its two-phase lift.
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 arm attempt that was REFUSED, and is therefore confirmable.
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 seconds ago, and a confirmation that survived a restart would
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 press.
1449
- */
1450
- lastRefusal = null;
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
- /** Wire the open-contact check and the refusal announcement. */
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
- * One contact reading, from whoever saw it — the sensor's own event, or the
1522
- * centre's reconcile tick.
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
- observeContact(deviceId, reading, now = Date.now()) {
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 sensor closed and opened again", {
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
- * Refuse the arm, bypass around it, or let it through — and leave the
1549
- * exclusions the arm will run with.
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 guard threw ⇒ arm, and SAY so. Refusing on a failed read would tell
1556
- * the operator to go and shut a window that is already shut (the coverage
1557
- * derivation makes the same choice: an unreadable authority answers
1558
- * "unknown", never "excluded").
1559
- * - nothing reads open ⇒ arm clean, and any exclusion a previous arm left
1560
- * is dropped.
1561
- *
1562
- * The refusal throws. See {@link NcAlarmArmRefusedError} for why that is the
1563
- * channel rather than a return value.
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 openContacts;
2076
+ if (guard === null) return { kind: "unguarded" };
2077
+ let openings;
1569
2078
  try {
1570
- openContacts = await guard.openContacts(mode);
2079
+ openings = await guard.openings(mode);
1571
2080
  } catch (err) {
1572
- this.ctx.logger.warn("alarm arm guard could not read the contacts — arming anyway", {
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
- openContacts,
1584
- previous: this.lastRefusal,
1585
- at: now
2095
+ open: openings.open,
2096
+ nonBlocking: this.nonBlockingFor(mode),
2097
+ ...acknowledged !== void 0 ? { acknowledged } : {}
1586
2098
  });
1587
- if (verdict.kind === "clear") {
1588
- this.lastRefusal = null;
1589
- if (this.exclusions.list().length > 0) {
1590
- this.exclusions.clear();
1591
- this.persist();
1592
- }
1593
- return;
1594
- }
1595
- if (verdict.kind === "bypassed") {
1596
- this.lastRefusal = null;
1597
- this.exclusions.exclude(verdict.bypassed.map((c) => c.deviceId), now);
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.lastRefusal = {
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
- at: now
2165
+ blockingIds: blocking.map((d) => d.deviceId),
2166
+ blocking,
2167
+ at: now,
2168
+ panelChangedAt: this.machine.changedAt
1612
2169
  };
1613
- for (const contact of verdict.blocking) this.ctx.logger.warn("alarm arm REFUSED — this sensor is open", {
1614
- tags: { deviceId: contact.deviceId },
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: contact.name
2176
+ name: d.name,
2177
+ state: d.state,
2178
+ refusalId: refusal.id
1619
2179
  }
1620
2180
  });
1621
- guard.announceRefusal({
1622
- mode,
1623
- blocking: verdict.blocking,
1624
- retrySeconds: Math.round(ARM_BYPASS_WINDOW_MS / 1e3),
2181
+ this.armGuard?.announceRefusal({
2182
+ refusal,
2183
+ blocking,
2184
+ state: stateAt(this.machine, now),
1625
2185
  at: now
1626
2186
  });
1627
- throw new NcAlarmArmRefusedError({
1628
- mode,
1629
- blocking: verdict.blocking,
1630
- retryUntil: verdict.retryUntil
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 now = Date.now();
1638
- await this.applyArmGuard(mode, now);
1639
- this.machine = arm(mode, this.delays, now);
1640
- this.attribution.clear();
1641
- this.publish(now);
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
- this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
2238
+ const state = stateAt(this.machine, now);
2239
+ this.ctx.logger.info(ignoredTriggerMessage(state), {
1670
2240
  tags: { deviceId: this.id },
1671
- meta: { state: stateAt(this.machine, now) }
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$1 = "alarm-panel";
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$1) return true;
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-armed` | **Disarma**, destructive |
7271
- * | `alarm-disarmed` | **Arma …** per declared mode, max three |
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 current = deviceState?.(c.deviceState.deviceId);
9056
- if (current === void 0) return fail("deviceState");
9057
- if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
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 = source?.deviceId ?? panelDeviceId;
11333
+ const deviceId = alarmSubjectDevice(transition, panelDeviceId);
10424
11334
  const identity = input.lookup(deviceId);
10425
11335
  const kind = transition.kind;
10426
- const combined = combinedAlarmText(source);
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: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
10443
- bodyKey: combined?.bodyKey ?? input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
11361
+ titleKey: keys.titleKey,
11362
+ bodyKey: keys.bodyKey,
10444
11363
  textParams: {
10445
- ...deviceTextParams(deviceId, identity),
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
- ...source !== void 0 ? { alarmSource: source } : {}
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}}\nRepeat within {{seconds}}s to arm with bypass.",
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}}\nRipeti entro {{seconds}}s per inserire con bypass.",
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
- if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-disarmed") return kind;
23212
- return null;
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
- function historySystemEventKind(kind) {
23507
- if (kind === "alarm-arming") return "alarm-armed";
23508
- if (kind === "alarm-arm-refused") return "alarm-disarmed";
23509
- return kind;
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: historySystemEventKind(systemEvent.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 rule = this.rules.get(input.ruleId);
23809
- if (rule === null) return [];
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: rule.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: ({ actionId, action, mode }) => {
24857
+ mintUrl: (button) => {
23872
24858
  const id = (0, node_crypto.randomUUID)();
23873
- const panelDeviceId = panel?.deviceId ?? 0;
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
- kind: "alarm",
23882
- id,
23883
- action,
23884
- ...mode !== void 0 ? { mode } : {},
23885
- panelDeviceId,
23886
- deviceId: input.deviceId,
23887
- actionId,
23888
- targetId: input.targetId,
23889
- expiresAt
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.observeContactEvent(event);
26174
+ this.observeOpeningEvent(event);
25143
26175
  this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
25144
26176
  }
25145
- /** The contact reading a sensor row carries, handed to the panel. Nothing at
25146
- * all for any other sensor kind — see {@link observeAlarmContacts}. */
25147
- observeContactEvent(event) {
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 || event.kind !== "contact") return;
25150
- const entryOpen = event.value?.["entryOpen"];
25151
- if (typeof entryOpen !== "boolean") return;
25152
- panel.observeContact(event.sourceDeviceId, entryOpen ? "open" : "closed", event.timestamp);
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
- * contacts a mode covers, and neither can reach the other on its own.
26583
+ * devices a mode covers, and neither can reach the other on its own.
25536
26584
  */
25537
26585
  alarmArmGuard() {
25538
26586
  return {
25539
- openContacts: (mode) => this.alarmOpenContacts(mode),
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 contacts this mode covers read OPEN, right now.
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 sensors the mode names, not a
25547
- * refresh of the whole watched set: the arm is a person pressing a button and
25548
- * the answer has to be about this second, but it must not become a fan-out
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
- * Three fail-open rules, and all three exist because refusing an arm on
25552
- * ignorance is worse than arming:
26603
+ * Never a guess, in either direction (D49):
25553
26604
  *
25554
- * - a sensor the read could not answer for falls back to the mirrored state
25555
- * (bounded staleness, the same contract the `deviceState` gate lives with);
25556
- * - a sensor NEITHER can answer for is not blocking, and says so in a line;
25557
- * - a mode whose contact rule names no devices blocks nothing, and says so —
25558
- * an unscoped contact rule covers every contact on the hub, and a refusal
25559
- * naming devices the operator never associated with the alarm is one they
25560
- * cannot act on.
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 alarmOpenContacts(mode) {
26615
+ async alarmOpenings(mode) {
25563
26616
  const panel = this.alarmPanel;
25564
- if (panel === null) return [];
25565
- const candidates = alarmBlockingCandidates(this.rules.list(), panel.deviceId, mode);
25566
- if (candidates.length === 0) return [];
25567
- const fresh = await this.readContactStates(candidates);
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 state = fresh.get(deviceId) ?? this.deviceStates.get(deviceId);
25573
- if (state === void 0) {
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 (state === "open") open.push({
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((c) => c.deviceId).join(",")
26664
+ openIds: open.map((d) => d.deviceId).join(",")
25597
26665
  }
25598
26666
  });
25599
- return open;
25600
- }
25601
- /** One bounded read of the named contacts. Never throws — an unreadable
25602
- * sensor is simply absent, exactly as `NcDeviceStateCache` contracts. */
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 the arm announcement takes (D126: the alarm speaks through the
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
- const text = buildArmRefusedAnnouncement(input.mode, input.blocking.map((c) => c.name), input.retrySeconds);
25629
- const transition = {
26686
+ this.scheduleAlarmTransition(panel, {
25630
26687
  kind: "alarm-arm-refused",
25631
- state: "disarmed",
25632
- previous: "disarmed",
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
- const settings = panel.settings();
25637
- const targetIds = settings.announceArm ? settings.announceTargets : [];
25638
- this.scheduleAlarmTransition(panel, transition, {
25639
- text,
25640
- targetIds
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
- * - an ARM is also announced to the panel's own `announceTargets`, which no
25809
- * rule addresses — but only where a rule has not already delivered it, or
25810
- * a target that is both would get the same sentence twice;
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, prepared) {
25817
- const arm = prepared ?? this.armAnnouncement(panel, transition);
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
- ...arm !== null ? { body: arm.text } : {}
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
- if (arm !== null) await this.enqueueArmAnnouncement(panel, incoming, arm, reached);
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 arm/arming sentence and the targets it goes to, or null when the
25844
- * transition is not one of those two.
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
- * Both kinds compose from the SAME coverage — "what does this mode actually
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
- * Every way of NOT announcing produces a line: an announcement that silently
25852
- * did not arrive is indistinguishable from the alarm not having armed, which
25853
- * is the exact confusion this feature exists to remove. A COVERAGE GAP no
25854
- * longer suppresses the sentence — it changes it. Suppressing it left the
25855
- * operator with a title and an empty line (the fallback body key existed in
25856
- * neither locale) and no way to learn that the mode was gated on by nothing.
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
- armAnnouncement(panel, transition) {
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
- if (!settings.announceArm) return {
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 arm announcement in the outbox, under {@link NC_ALARM_ANNOUNCE_RULE_ID}.
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 arm time. `recordId` is the transition's
25907
- * own id, so every arm is a new row (cooldown 0) and a replay of the same one
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 arm, one sentence, 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 enqueueArmAnnouncement(panel, incoming, arm, reached) {
25914
- const targets = arm.targetIds.filter((id) => !reached.has(id));
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: "none",
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 arm announcement enqueued", {
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: arm.text.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 no enabled rule is gated on alarm-triggered", {
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: [...reached]
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: reached.size,
27251
+ targets: combined.size,
27252
+ announced: announced.length,
26030
27253
  superseded: dropped,
26031
- alreadyDelivered: reached.size - dropped
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
- this.logger.warn("device event has no marker track — this notification ships text-only", {
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
- recordId: subject.recordId,
26280
- ...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
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
- media: noMedia ? "none" : mediaRule.media.attach,
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
- * Feed every excluded sensor's mirrored state back to the panel.
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 onSensorEventPersisted}) is the fast one, but it only exists for a
26875
- * sensor linked to a camera — the seven contacts on this hub are linked to
26876
- * none, so without this an exclusion would never lift at all and the sensor
26877
- * would stay out of the alarm until a disarm.
26878
- */
26879
- observeAlarmContacts() {
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
- if (panel === null) return;
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) panel.observeContact(deviceId, contactReadingOf(this.deviceStates.get(deviceId)), now);
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.observeAlarmContacts();
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 === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-arm-refused") return {
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 linksByCamera = await this.resolveLinks(cameraIds);
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 === 0) return {
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: isCamera ? "camera-native-cap" : "no-camera"
72289
+ dropReason: "camera-index-unknown",
72290
+ linkedCameraIds: []
70840
72291
  };
70841
72292
  return {
70842
72293
  cameraIds,
70843
- dropReason: null
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 belongs to no camera at all).
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
- * `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
71937
- * — their ARRIVAL is the event, and three presses in a row are three markers.
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 = { cover: "state" };
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 = { cover: "closed" };
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 cap with no level passes through unconditionally. A cap that HAS one
72009
- * passes only when its level moved — a boolean in either direction, or a
72010
- * word-level cap's word changing.
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", { meta: {
79259
- scope,
79260
- error: require_dist.errMsg(err)
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) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
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
- 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 — the device is not a camera and is linked to none";
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: {