@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.
@@ -1,4 +1,4 @@
1
- import { $ as SCENE_DIVERGED, $t as hydrateSchema, A as NcConditionDescriptorSchema, At as occupancyScope, B as NcSystemEventKindSchema, Bt as storageOccupancyCapability, C as MediaFileKindEnum, Ct as isKnownTemplateVar, Dt as kebabToCamel, E as NC_DEFAULT_SNOOZE_MINUTES, Et as isSourceCap, F as NcRuleUpdateInputSchema, Ft as readDeviceStateFrom, G as OpsLogEntrySchema, Gt as zoneAnalyticsCapability, H as NcTemplatePreviewInputSchema, Ht as systemEventFilterApplies, I as NcScheduleSchema, It as readTimelapseGeneratedAt, J as RECORDING_SOURCE_CAMSTACK_ADDON, Jt as CamProfileSchema, Kt as errMsg$1, L as NcSnoozeInputSchema, Lt as resolveLocationMode, M as NcRulePatchSchema, Mt as pickClusterStepModels, N as NcRuleSchema, Nt as pipelineAnalyticsCapability, O as NC_TAXONOMY, Ot as mayWriteToLocation, P as NcRuleTargetSchema, Pt as plateGalleryCapability, Q as SCENE_DEFAULT_UNCOVERED_POLICY, Qt as defineCustomActions, R as NcSnoozeSchema, S as MOTION_CLOSE_AFTER_MS, St as isEventOwnerType, T as NC_CONDITION_CATALOG, Tt as isScheduleActive, U as NcTemplatePreviewSchema, Ut as templateVarsFor, V as NcTaxonomySchema, Vt as subKindsOf, W as NcTemplateVarDescriptorSchema, Wt as vectorDimFromBase64, Xt as createEvent, Y as RetrainStatusSchema, Yt as DeviceType, Z as SCENE_DEFAULT_ANCHOR_THRESHOLD, Zt as customAction, _ as FailureCounters, _t as evictionPolicyOfLocation, a as CLUSTER_MODEL_SCOPED_STEPS, an as boolean, at as TrackSourceSchema, b as MAX_BIRTH_DECISION_RECORDS, cn as number, ct as assertTimelapseCadences, dn as record, dt as buildEventKindDescriptor, en as isDeviceScopedCap, et as SceneMonitorSchema, f as DeclaredDevices, fn as string, ft as cosineSimilarity$1, g as FULL_IMAGE_BBOX, gt as evaluateSensorEdge, h as FIRST_LEVEL_MACRO_CLASSES, ht as encodeVectorBase64, i as BirthDecisionRecordSchema, in as array, it as TimelapseRuleSchema, j as NcRuleInputSchema, k as NC_TEMPLATE_VARS, kt as notificationRulesCapability, ln as object, lt as audioMetricsCapability, m as EVENT_OWNER_TYPES, mn as EventCategory, n as ArchivedDebugNoteSchema, nn as sleep$1, nt as TimelapseRuleInputSchema, o as COCO_TO_MACRO, on as discriminatedUnion, ot as addonWidgetsSourceCapability, p as EVENT_KIND_BY_CAP, pn as unknown, pt as deriveRecordingMode, q as RECORDING_EXPORT_MAX_READ_BYTES, qt as BaseAddon, r as BaseDevice, rn as _enum, rt as TimelapseRulePatchSchema, s as DEFAULT_EVENT_COLOR, sn as literal, st as alarmPanelCapability, t as AUDIO_MACRO_LABELS, tn as nodePin, tt as TIMELAPSE_DENSE_FLOOR_SEC, u as DETECTION_MACRO_CLASSES, un as partialRecord, ut as audioModeOf, v as LabelAttributionSchema, vt as faceGalleryCapability, w as NC_ALARM_SYSTEM_EVENT_KINDS, wt as isMediaOwnerType, xt as isDetectionMacroClass, y as MAX_ARCHIVED_DEBUG_NOTES, yt as failureContributionCapability, z as NcSnoozeSuppressedSchema, zt as sceneMonitorCapability } from "../dist-D3Yub96m.mjs";
1
+ import { $ as RECORDING_SOURCE_CAMSTACK_ADDON, $t as CamProfileSchema, At as isScheduleActive, B as NcRuleUpdateInputSchema, Bt as readDeviceStateFrom, C as MAX_BIRTH_DECISION_RECORDS, Ct as faceGalleryCapability, D as NC_ALARM_SYSTEM_EVENT_KINDS, Dt as isEventOwnerType, E as MediaFileKindEnum, Et as isDetectionMacroClass, F as NcConditionDescriptorSchema, Ft as occupancyScope, G as NcSystemEventKindSchema, Gt as storageOccupancyCapability, H as NcSnoozeInputSchema, Ht as resolveLocationMode, I as NcRuleInputSchema, J as NcTemplatePreviewSchema, Jt as templateVarsFor, K as NcTaxonomySchema, Kt as subKindsOf, L as NcRulePatchSchema, Lt as pickClusterStepModels, M as NC_TEMPLATE_VARS, Mt as kebabToCamel, N as NcAlarmConfigSchema, Nt as mayWriteToLocation, O as NC_CONDITION_CATALOG, Ot as isKnownTemplateVar, P as NcAlarmSettingsPatchSchema, Pt as notificationRulesCapability, Q as RECORDING_EXPORT_MAX_READ_BYTES, Qt as BaseAddon, R as NcRuleSchema, Rt as pipelineAnalyticsCapability, S as MAX_ARCHIVED_DEBUG_NOTES, St as evictionPolicyOfLocation, T as MOTION_CLOSE_AFTER_MS, U as NcSnoozeSchema, V as NcScheduleSchema, Vt as readTimelapseGeneratedAt, W as NcSnoozeSuppressedSchema, Wt as sceneMonitorCapability, X as OpsLogEntrySchema, Xt as zoneAnalyticsCapability, Y as NcTemplateVarDescriptorSchema, Yt as vectorDimFromBase64, Zt as errMsg$1, _ as FIRST_LEVEL_MACRO_CLASSES, _n as string, _t as cosineSimilarity$1, a as BirthDecisionRecordSchema, an as isDeviceScopedCap, at as SceneMonitorSchema, b as LabelAttributionSchema, bt as encodeVectorBase64, c as CoverStateSchema, cn as _enum, ct as TimelapseRulePatchSchema, dn as discriminatedUnion, dt as addonWidgetsSourceCapability, en as DeviceType, et as RetrainStatusSchema, f as DETECTION_MACRO_CLASSES, fn as literal, ft as alarmPanelCapability, g as EVENT_OWNER_TYPES, gn as record, gt as buildEventKindDescriptor, h as EVENT_KIND_BY_CAP, hn as partialRecord, ht as audioModeOf, i as BaseDevice, in as hydrateSchema, it as SCENE_DIVERGED, j as NC_TAXONOMY, jt as isSourceCap, k as NC_DEFAULT_SNOOZE_MINUTES, kt as isMediaOwnerType, l as DEFAULT_EVENT_COLOR, ln as array, lt as TimelapseRuleSchema, m as DeclaredDevices, mn as object, mt as audioMetricsCapability, n as AlarmArmModeSchema, nn as customAction, nt as SCENE_DEFAULT_ANCHOR_THRESHOLD, o as CLUSTER_MODEL_SCOPED_STEPS, on as nodePin, ot as TIMELAPSE_DENSE_FLOOR_SEC, pn as number, pt as assertTimelapseCadences, q as NcTemplatePreviewInputSchema, qt as systemEventFilterApplies, r as ArchivedDebugNoteSchema, rn as defineCustomActions, rt as SCENE_DEFAULT_UNCOVERED_POLICY, s as COCO_TO_MACRO, sn as sleep$1, st as TimelapseRuleInputSchema, t as AUDIO_MACRO_LABELS, tn as createEvent, un as boolean, ut as TrackSourceSchema, v as FULL_IMAGE_BBOX, vn as unknown, vt as deriveRecordingMode, wt as failureContributionCapability, x as LockStateSchema, xt as evaluateSensorEdge, y as FailureCounters, yn as EventCategory, z as NcRuleTargetSchema, zt as plateGalleryCapability } from "../dist-58Om1e5O.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -227,6 +227,434 @@ var MotionEpisodeTracker = class {
227
227
  }
228
228
  };
229
229
  //#endregion
230
+ //#region src/notification-center/alarm/alarm-mode-coverage.ts
231
+ /**
232
+ * Whether the panel just BECAME armed, in the sense a person means.
233
+ *
234
+ * Two transitions reach an `armed_*` state and only one of them is an arm:
235
+ *
236
+ * - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
237
+ * - `triggered` → `armed_away` is the siren's duration ending and the panel
238
+ * re-arming itself. It is told too (D630), but as `alarm-rearmed`, never as
239
+ * an arm: "Away armed" in the middle of a break-in would read as somebody
240
+ * arming it. `alarmTransition` routes it there before asking this.
241
+ *
242
+ * `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
243
+ * set of devices covered just changed with it — which is the whole content of
244
+ * the message.
245
+ *
246
+ * A null `previous` is the FIRST publish and never announces. A hub restarting
247
+ * while armed re-publishes `armed_away`, and nobody armed anything — every
248
+ * deploy would otherwise send one. Handled here rather than by the caller
249
+ * happening to wire the hook late, because "it works because of the order two
250
+ * unrelated things run in" is how this stops working.
251
+ */
252
+ function shouldAnnounceArm(previous, next) {
253
+ if (previous === null) return false;
254
+ if (!next.startsWith("armed_")) return false;
255
+ if (previous === next) return false;
256
+ if (previous === "triggered") return false;
257
+ return true;
258
+ }
259
+ /** The mode inside an `armed_<mode>` state, or null for any other state. */
260
+ function armModeOf(state, modes) {
261
+ for (const mode of modes) if (state === armedStateFor(mode)) return mode;
262
+ return null;
263
+ }
264
+ /** The `deviceState` word a rule must gate on for `mode` to cover it. */
265
+ function armedStateFor(mode) {
266
+ return `armed_${mode}`;
267
+ }
268
+ /**
269
+ * One entry per mode the panel offers, including modes nothing is gated on.
270
+ *
271
+ * Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
272
+ * most useful thing this can tell an operator, and a list that omits it looks
273
+ * identical to a list where night is covered.
274
+ */
275
+ function alarmModeCoverage(rules, panelDeviceId, modes, skipProbe) {
276
+ return modes.map((mode) => coverageFor(rules, panelDeviceId, mode, skipProbe));
277
+ }
278
+ function coverageFor(rules, panelDeviceId, mode, skipProbe) {
279
+ const wanted = armedStateFor(mode);
280
+ const ids = /* @__PURE__ */ new Set();
281
+ let ruleCount = 0;
282
+ let allDevices = false;
283
+ for (const rule of rules) {
284
+ if (!rule.enabled) continue;
285
+ const gate = rule.conditions.deviceState;
286
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
287
+ if (!gate.states.includes(wanted)) continue;
288
+ ruleCount += 1;
289
+ const scope = rule.conditions.devices;
290
+ if (scope === void 0 || scope.length === 0) {
291
+ allDevices = true;
292
+ continue;
293
+ }
294
+ for (const id of scope) ids.add(id);
295
+ }
296
+ const deviceIds = [...ids].toSorted((a, b) => a - b);
297
+ return {
298
+ mode,
299
+ ruleCount,
300
+ allDevices,
301
+ deviceIds,
302
+ skippedDevices: skippedFor(deviceIds, skipProbe)
303
+ };
304
+ }
305
+ function skippedFor(deviceIds, skipProbe) {
306
+ if (skipProbe === void 0) return [];
307
+ const out = [];
308
+ for (const deviceId of deviceIds) {
309
+ const reason = skipProbe(deviceId);
310
+ if (reason !== void 0) out.push({
311
+ deviceId,
312
+ reason
313
+ });
314
+ }
315
+ return out;
316
+ }
317
+ /**
318
+ * Whether this mode arms anything, and if not, which way it fails.
319
+ *
320
+ * An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
321
+ * ids a rule wrote down, so it cannot answer for a mode that covers every
322
+ * camera on the hub, and reporting "all excluded" from a subset would be a
323
+ * warning about protection that exists.
324
+ */
325
+ function alarmCoverageGap(coverage) {
326
+ if (coverage.ruleCount === 0) return "uncovered";
327
+ if (coverage.allDevices) return null;
328
+ if (coverage.deviceIds.length === 0) return null;
329
+ if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
330
+ return "all-skipped";
331
+ }
332
+ /**
333
+ * "Attivi: Cancello, Garage — Esclusi: Porta retro."
334
+ *
335
+ * A LIST, not a sentence. The mode is in the title and the body is one
336
+ * `Label: names` line per device group, because this is read on a lock screen
337
+ * and the question is always "which one" — the prose version ("3 dispositivi
338
+ * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
339
+ * window. Each variant still owns its own catalog key, so the Texts tab can
340
+ * re-word "nothing is armed" without touching "these are armed".
341
+ *
342
+ * Names, not ids: this is read by a person standing at a door. An id that has
343
+ * no name falls back to a NAMED, translated placeholder rather than being
344
+ * dropped — a silently shorter list would understate what is armed, which is
345
+ * the one error this message must not make. (It used to hard-code the English
346
+ * `Device <id>` into both languages.)
347
+ *
348
+ * **Always a sentence.** It used to return null for a mode no rule is gated on,
349
+ * on the grounds that "Night armed" followed by an unwatched night is a false
350
+ * assurance — true, but the caller then emitted `alarm-armed` anyway with a
351
+ * fallback body key that existed in NEITHER locale, so the operator received a
352
+ * title and an empty line. Silence is not the third option here: SAYING that
353
+ * the mode arms nothing is.
354
+ */
355
+ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
356
+ const rules = `${coverage.ruleCount}`;
357
+ const mode = coverage.mode;
358
+ if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
359
+ const gap = alarmCoverageGap(coverage);
360
+ if (gap === "uncovered") return {
361
+ bodyKey: "alarm.arm.body.uncovered",
362
+ textParams: { mode },
363
+ textCount: 0
364
+ };
365
+ if (gap === "all-skipped") {
366
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
367
+ return {
368
+ bodyKey: "alarm.arm.body.allSkipped",
369
+ textParams: {
370
+ rules,
371
+ mode,
372
+ devices: `${names.length}`,
373
+ names: names.join(", ")
374
+ },
375
+ textCount: names.length
376
+ };
377
+ }
378
+ if (coverage.allDevices) return {
379
+ bodyKey: "alarm.arm.body.allDevices",
380
+ textParams: {
381
+ rules,
382
+ mode
383
+ },
384
+ textCount: coverage.ruleCount
385
+ };
386
+ if (coverage.deviceIds.length === 0) return {
387
+ bodyKey: "alarm.arm.body.noDevices",
388
+ textParams: {
389
+ rules,
390
+ mode
391
+ },
392
+ textCount: coverage.ruleCount
393
+ };
394
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
395
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
396
+ if (skipped.size === 0) return {
397
+ bodyKey: "alarm.arm.body.devices",
398
+ textParams: {
399
+ rules,
400
+ mode,
401
+ devices: `${names.length}`,
402
+ names: names.join(", ")
403
+ },
404
+ textCount: names.length
405
+ };
406
+ return {
407
+ bodyKey: "alarm.arm.body.devicesSkipped",
408
+ textParams: {
409
+ rules,
410
+ mode,
411
+ devices: `${names.length}`,
412
+ names: names.join(", "),
413
+ skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
414
+ },
415
+ textCount: names.length
416
+ };
417
+ }
418
+ /**
419
+ * "Attivi: 2 — Bypassati: Finestra veranda."
420
+ *
421
+ * The bypassed sensors are named and the armed ones are COUNTED, not listed:
422
+ * this sentence's subject is the exception, and burying it under thirteen
423
+ * camera names is how an operator stops reading the arm notification. The
424
+ * count pluralises on what IS armed, like every other arm variant.
425
+ */
426
+ function armedWithBypass(coverage, bypassed, deviceName) {
427
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
428
+ const excluded = new Set(bypassed);
429
+ const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
430
+ return {
431
+ bodyKey: "alarm.arm.body.bypassed",
432
+ textParams: {
433
+ rules: `${coverage.ruleCount}`,
434
+ mode: coverage.mode,
435
+ devices: `${armed}`,
436
+ bypassedNames: bypassed.map(deviceName).join(", ")
437
+ },
438
+ textCount: armed
439
+ };
440
+ }
441
+ /**
442
+ * "Aperti: Finestra veranda, Porta retro" + where the Arm anyway is.
443
+ *
444
+ * The one alarm sentence about something that did not happen, so it has to
445
+ * carry both halves an operator needs to act: WHICH devices are open, and —
446
+ * when the notification carries the button — that its Arm anyway arms without
447
+ * them (D630: pressing arm again is refused again; there is no second-press
448
+ * bypass any more, so the sentence never says "repeat within N seconds").
449
+ * Naming them is the whole message, so the names are the FIRST line.
450
+ */
451
+ function buildArmRefusedAnnouncement(mode, names, withArmAnyway) {
452
+ return {
453
+ bodyKey: withArmAnyway ? "alarm.armRefused.body.hint" : "alarm.armRefused.body",
454
+ textParams: {
455
+ mode,
456
+ devices: `${names.length}`,
457
+ names: names.join(", ")
458
+ },
459
+ textCount: names.length
460
+ };
461
+ }
462
+ /**
463
+ * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
464
+ *
465
+ * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
466
+ * on the way out of a door — and it carries exactly one thing the arm sentence
467
+ * cannot: the warning arrives while the operator can still disarm and fix it.
468
+ * A mode that arms nothing is worth interrupting somebody for at second 0 and
469
+ * merely worth recording at second 30.
470
+ *
471
+ * **It never repeats the device list.** The countdown lives in the TITLE
472
+ * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
473
+ * list arrives thirty seconds later in the arm announcement — printing it
474
+ * twice is the verbosity that made the operator stop reading both. So the body
475
+ * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
476
+ *
477
+ * `seconds` stays in the params because the title asks for it, and `textCount`
478
+ * stays with it: a row enqueued before this deploy still pluralises on the
479
+ * countdown it was written for.
480
+ */
481
+ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
482
+ const textParams = {
483
+ mode: coverage.mode,
484
+ seconds: `${seconds}`
485
+ };
486
+ if (bypassed.length > 0) return {
487
+ bodyKey: "alarm.arming.body.bypassed",
488
+ textParams: {
489
+ ...textParams,
490
+ bypassedNames: bypassed.map(deviceName).join(", ")
491
+ },
492
+ textCount: seconds
493
+ };
494
+ const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
495
+ const gap = alarmCoverageGap(coverage);
496
+ if (gap === "uncovered") return {
497
+ bodyKey: "alarm.arming.body.uncovered",
498
+ textParams,
499
+ textCount: seconds
500
+ };
501
+ if (gap === "all-skipped") return {
502
+ bodyKey: "alarm.arming.body.allSkipped",
503
+ textParams: {
504
+ ...textParams,
505
+ names: skippedNames
506
+ },
507
+ textCount: seconds
508
+ };
509
+ if (coverage.skippedDevices.length > 0) return {
510
+ bodyKey: "alarm.arming.body.skipped",
511
+ textParams: {
512
+ ...textParams,
513
+ names: skippedNames
514
+ },
515
+ textCount: coverage.skippedDevices.length
516
+ };
517
+ return {
518
+ bodyKey: "alarm.arming.body",
519
+ textParams,
520
+ textCount: seconds
521
+ };
522
+ }
523
+ //#endregion
524
+ //#region src/notification-center/alarm/alarm-arm-guard.ts
525
+ /**
526
+ * How long an Arm anyway stays pressable — the panel's refusal and the
527
+ * button's grant TTL (`action-token.ts`) use this one constant. It lives here,
528
+ * with the arm decision, so the token module does not import the device.
529
+ */
530
+ var ARM_ANYWAY_TTL_MS = 5 * 6e4;
531
+ /**
532
+ * One covered device that reads OPEN, with the name a person will read and the
533
+ * word its cap reported (`open`, `stopped`, `unlocked`, …) — not only contacts:
534
+ * a cover or a lock that is not secure is an opening too (D630).
535
+ */
536
+ var NcAlarmOpenDeviceSchema = object({
537
+ deviceId: number().int(),
538
+ name: string(),
539
+ state: string()
540
+ }).readonly();
541
+ /** Why an Arm anyway was not honoured — see `NcAlarmPanelDevice.armAnyway`. */
542
+ var NcAlarmArmAnywayRejectionSchema = _enum([
543
+ "unknown-refusal",
544
+ "stale",
545
+ "expired",
546
+ "used"
547
+ ]);
548
+ /**
549
+ * What one arm attempt — plain or Arm anyway — came to.
550
+ *
551
+ * A schema, and it lives HERE, in the addon, because it is the response of the
552
+ * `nc.armAlarm` / `nc.armAlarmAnyway` BRIDGE actions: the bridge validates
553
+ * against the addon's own copy, so a refusal can travel as a result (its id and
554
+ * the names of what is open) and the viewer can offer the Arm anyway bound to
555
+ * it. The cap cannot: `alarm-panel.arm` answers `z.void()` on the HOST's router,
556
+ * which is why the cap throws {@link NcAlarmArmRefusedError} instead.
557
+ */
558
+ var NcAlarmArmOutcomeSchema = discriminatedUnion("outcome", [
559
+ object({
560
+ outcome: _enum(["armed", "arming"]),
561
+ excluded: array(NcAlarmOpenDeviceSchema).readonly()
562
+ }).readonly(),
563
+ object({
564
+ outcome: literal("refused"),
565
+ refusalId: string(),
566
+ blocking: array(NcAlarmOpenDeviceSchema).readonly()
567
+ }).readonly(),
568
+ object({
569
+ outcome: literal("rejected"),
570
+ reason: NcAlarmArmAnywayRejectionSchema
571
+ }).readonly()
572
+ ]);
573
+ /**
574
+ * The devices a mode covers, ascending and de-duplicated.
575
+ *
576
+ * A rule qualifies when it is enabled and gated on THIS panel in THIS mode;
577
+ * every device it names is covered, whatever its kind. A camera it names is
578
+ * covered too, and is simply never an opening (`alarmOpeningFrom` answers
579
+ * `not-opening` for it, and the arm check skips it without a word).
580
+ *
581
+ * A rule with NO device scope contributes nothing: it covers every device on
582
+ * the hub, and blocking on a set the operator never wrote down produces a
583
+ * refusal naming devices they never associated with the alarm. The caller says
584
+ * so in a log line rather than inventing the list.
585
+ */
586
+ function alarmCoveredDevices(rules, panelDeviceId, mode) {
587
+ const wanted = armedStateFor(mode);
588
+ const ids = /* @__PURE__ */ new Set();
589
+ for (const rule of rules) {
590
+ if (!rule.enabled) continue;
591
+ const gate = rule.conditions.deviceState;
592
+ if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
593
+ if (!gate.states.includes(wanted)) continue;
594
+ const scope = rule.conditions.devices;
595
+ if (scope === void 0 || scope.length === 0) continue;
596
+ for (const id of scope) ids.add(id);
597
+ }
598
+ return [...ids].toSorted((a, b) => a - b);
599
+ }
600
+ /**
601
+ * Refuse or arm — the whole decision, as one partition of the open devices.
602
+ *
603
+ * A device may be open at arm time for exactly two reasons: the mode TOLERATES
604
+ * it (its non-blocking list), or the operator was shown it in a refusal and
605
+ * pressed that refusal's Arm anyway (`acknowledged`). Anything else open
606
+ * refuses — including a device that opened AFTER the refusal the operator is
607
+ * confirming: they were never shown it, and excluding it would arm the alarm
608
+ * around an opening nobody agreed to.
609
+ *
610
+ * There is no memory here. A plain arm pressed twice is the same question
611
+ * twice and gets the same answer; the only way past a refusal is the explicit
612
+ * Arm anyway, whose lifecycle (one-shot, stale, expired) the panel owns.
613
+ *
614
+ * Excluded is EVERY open device — tolerated and acknowledged alike — and the
615
+ * exclusion is two-phase (`alarm-exclusions.ts`): each one is watched again
616
+ * once it has closed and opened.
617
+ */
618
+ function decideArm(input) {
619
+ const tolerated = new Set([...input.nonBlocking, ...input.acknowledged ?? []]);
620
+ const blocking = input.open.filter((d) => !tolerated.has(d.deviceId));
621
+ if (blocking.length > 0) return {
622
+ kind: "refused",
623
+ blocking
624
+ };
625
+ return {
626
+ kind: "arm",
627
+ excluded: input.open
628
+ };
629
+ }
630
+ /**
631
+ * The error `alarmPanel.arm` throws when it refuses.
632
+ *
633
+ * A THROW and not a return value, and the reason is a train: the hub's
634
+ * generated router validates this method's response against the HOST's copy of
635
+ * the cap, which says `z.void()`. An addon that answered a structured refusal
636
+ * would have it rejected by the router in front of it — so the refusal reaches
637
+ * the caller as the one channel that already works across that boundary, and
638
+ * the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
639
+ * ordinary outbox, with its own Arm anyway) rather than a UI error being the
640
+ * only trace.
641
+ *
642
+ * The message is deliberately actionable and English (an exception message is
643
+ * a developer/API surface; the notification is the operator's).
644
+ */
645
+ var NcAlarmArmRefusedError = class extends Error {
646
+ code = "alarm-arm-refused";
647
+ mode;
648
+ blocking;
649
+ constructor(input) {
650
+ const names = input.blocking.map((d) => d.name).join(", ");
651
+ super(`arm refused — open: ${names}. Use "Arm anyway" to arm with them excluded.`);
652
+ this.name = "NcAlarmArmRefusedError";
653
+ this.mode = input.mode;
654
+ this.blocking = input.blocking;
655
+ }
656
+ };
657
+ //#endregion
230
658
  //#region src/notification-center/action-token.ts
231
659
  /**
232
660
  * The authority behind a notification button.
@@ -257,6 +685,18 @@ var MotionEpisodeTracker = class {
257
685
  * accepted: a deploy kills the buttons on notifications already delivered,
258
686
  * which with a short TTL were nearly dead anyway.
259
687
  */
688
+ /**
689
+ * A granted action that ran and was REFUSED for a reason the person holding
690
+ * the phone can act on — "the alarm changed since this notification". Its
691
+ * message is shown on the page the tap lands on; any other throw stays the
692
+ * generic "it did not work".
693
+ */
694
+ var NcActionRefusedError = class extends Error {
695
+ constructor(message) {
696
+ super(message);
697
+ this.name = "NcActionRefusedError";
698
+ }
699
+ };
260
700
  /** Sign `(id, exp)` — the exact string the verifier recomputes. Same shape as
261
701
  * the artifact plane's, deliberately: one signing convention in this addon. */
262
702
  function signActionToken(secret, id, expMs) {
@@ -322,7 +762,9 @@ var ALARM_DISARM_TTL_MS = 5 * 6e4;
322
762
  function actionTtlMs(subject) {
323
763
  switch (subject.kind) {
324
764
  case "snooze": return SNOOZE_ACTION_TTL_MS;
325
- case "alarm": return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
765
+ case "alarm":
766
+ if (subject.action === "arm-anyway") return ARM_ANYWAY_TTL_MS;
767
+ return subject.action === "disarm" ? ALARM_DISARM_TTL_MS : DEFAULT_ACTION_TTL_MS;
326
768
  case "sequence": return DEFAULT_ACTION_TTL_MS;
327
769
  case "addon-update": return DEFAULT_ACTION_TTL_MS;
328
770
  case "server-update": return DEFAULT_ACTION_TTL_MS;
@@ -404,7 +846,9 @@ function describeGrant(grant) {
404
846
  switch (grant.kind) {
405
847
  case "sequence": return `${grant.ruleName} — ${grant.sequence}`;
406
848
  case "snooze": return grant.classes === void 0 || grant.classes.length === 0 ? `Silenced for ${grant.minutes} min` : `Silenced for ${grant.minutes} min — ${grant.classes.join(", ")}`;
407
- case "alarm": return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
849
+ case "alarm":
850
+ if (grant.action === "arm-anyway") return "Alarm armed, around the open devices";
851
+ return grant.action === "disarm" ? "Alarm disarmed" : `Alarm armed${grant.mode !== void 0 ? ` (${grant.mode})` : ""}`;
408
852
  case "addon-update": return `${grant.packages.length} addon update(s) on ${grant.nodeId}`;
409
853
  case "server-update": return `Server update on ${grant.nodeId}`;
410
854
  }
@@ -431,7 +875,12 @@ function grantMeta(grant) {
431
875
  minutes: grant.minutes,
432
876
  classes: grant.classes ?? null
433
877
  };
434
- case "alarm": return {
878
+ case "alarm": return grant.action === "arm-anyway" ? {
879
+ ...common,
880
+ alarm: grant.action,
881
+ refusalId: grant.refusalId,
882
+ panelDeviceId: grant.panelDeviceId
883
+ } : {
435
884
  ...common,
436
885
  alarm: grant.action,
437
886
  mode: grant.mode ?? null,
@@ -501,6 +950,18 @@ var NcActionPlane = class {
501
950
  });
502
951
  this.reply(res, background, 200, "Done", describeGrant(grant));
503
952
  } catch (err) {
953
+ if (err instanceof NcActionRefusedError) {
954
+ this.deps.logger.info("nc action refused after the token was spent", {
955
+ tags: { deviceId: grant.deviceId },
956
+ meta: {
957
+ kind: grant.kind,
958
+ ...grantMeta(grant),
959
+ reason: err.message
960
+ }
961
+ });
962
+ this.reply(res, background, 200, "Nothing was done", err.message);
963
+ return;
964
+ }
504
965
  this.deps.logger.warn("nc action failed after the token was spent", {
505
966
  tags: { deviceId: grant.deviceId },
506
967
  meta: {
@@ -530,6 +991,334 @@ var NcActionPlane = class {
530
991
  }
531
992
  };
532
993
  //#endregion
994
+ //#region src/notification-center/alarm/alarm-openings.ts
995
+ /**
996
+ * What "open" means to the alarm — D630's table, in one place.
997
+ *
998
+ * Read from the RAW runtime slices, not from the collapsed `deviceState`
999
+ * word: the collapse is first-match over every cap, so it cannot say whether a
1000
+ * device is an opening at all, and a camera and an unreadable window would both
1001
+ * come back `undefined`.
1002
+ *
1003
+ * Three answers, never two (D49 — an unreadable state is unknown, never closed
1004
+ * and never open):
1005
+ *
1006
+ * - `not-opening` — no opening slice at all: a camera named by an intrusion
1007
+ * rule, a switch. It takes no part in the arm decision.
1008
+ * - `unknown` — an opening cap's slice IS there, but its field is missing or
1009
+ * is not a word that cap defines, or the slice is a provider's PLACEHOLDER
1010
+ * (`lastChangedAt === 0`: written by a constructor before anybody read the
1011
+ * device — an HA lock's `unlocked`, an HA cover's `closed`, a binary
1012
+ * sensor's `entryOpen: false`). It carries the cap and the raw value (for a
1013
+ * placeholder, the whole slice) so the caller's log line can say what it
1014
+ * saw. It never blocks and is never armed as secure on a guess — and a
1015
+ * placeholder is never taken for a state, so neither the reconcile tick,
1016
+ * the arm check nor an exclusion lift acts on one.
1017
+ * - `reading` — open or secure, with the state word a person will read.
1018
+ * ANY non-zero `lastChangedAt` is a reading, however old — including `1`,
1019
+ * the stamp a provider writes on a value it REPLAYED after a respawn with
1020
+ * no upstream change time (Homematic `HM_SEEDED_CHANGED_AT`): the value is
1021
+ * known, only its change time is not. The transition gate
1022
+ * (`sensor-transition-gate.ts`, "A placeholder slice is not a state") reads
1023
+ * the same stamp as "not a fresh event"; `0` alone means "not read yet".
1024
+ *
1025
+ * A read that FAILED never reaches {@link alarmOpeningFrom}: the caller leaves
1026
+ * that id out of its answer (see {@link readAlarmOpenings}).
1027
+ *
1028
+ * Anything short of fully secure is open: a cover stopped half-way, a lock that
1029
+ * jammed, a cover still closing when the arm is decided.
1030
+ *
1031
+ * PRECEDENCE, when one device carries more than one opening slice: `contact`
1032
+ * first, then `cover`, then `lock-control`. The contact is the physical truth
1033
+ * about the opening — a reed switch on the frame says the window is open even
1034
+ * when the motor's last reported word says `closed`. A slice that cannot be
1035
+ * read falls through to the next; only when none of them reads is the device
1036
+ * `unknown`, naming the first unreadable cap.
1037
+ *
1038
+ * The vocabulary of each word is its cap's own schema (`CoverStateSchema`,
1039
+ * `LockStateSchema`) — a word the cap does not define is not guessed at.
1040
+ */
1041
+ /** The one state word, per cap, that counts as secure. Everything else is open. */
1042
+ var COVER_SECURE = "closed";
1043
+ var LOCK_SECURE = "locked";
1044
+ var NOT_OPENING = { kind: "not-opening" };
1045
+ function sliceOf(runtime, cap) {
1046
+ const slice = runtime[cap];
1047
+ if (slice === void 0) return void 0;
1048
+ return slice !== null && typeof slice === "object" ? slice : null;
1049
+ }
1050
+ function fieldOf(slice, field) {
1051
+ if (slice === null) return void 0;
1052
+ return Object.entries(slice).find(([key]) => key === field)?.[1];
1053
+ }
1054
+ /** A provider's cold placeholder: nobody has read the device yet (D49 ⇒ unknown). */
1055
+ function isPlaceholder(slice) {
1056
+ return fieldOf(slice, "lastChangedAt") === 0;
1057
+ }
1058
+ function reading(word, secure) {
1059
+ return {
1060
+ kind: "reading",
1061
+ reading: word === secure ? "secure" : "open",
1062
+ state: word
1063
+ };
1064
+ }
1065
+ function readContact(runtime) {
1066
+ const slice = sliceOf(runtime, "contact");
1067
+ if (slice === void 0) return { kind: "absent" };
1068
+ if (isPlaceholder(slice)) return {
1069
+ kind: "unknown",
1070
+ cap: "contact",
1071
+ raw: slice
1072
+ };
1073
+ const raw = fieldOf(slice, "entryOpen");
1074
+ if (typeof raw !== "boolean") return {
1075
+ kind: "unknown",
1076
+ cap: "contact",
1077
+ raw
1078
+ };
1079
+ return raw ? {
1080
+ kind: "reading",
1081
+ reading: "open",
1082
+ state: "open"
1083
+ } : {
1084
+ kind: "reading",
1085
+ reading: "secure",
1086
+ state: "closed"
1087
+ };
1088
+ }
1089
+ function readCover(runtime) {
1090
+ const slice = sliceOf(runtime, "cover");
1091
+ if (slice === void 0) return { kind: "absent" };
1092
+ if (isPlaceholder(slice)) return {
1093
+ kind: "unknown",
1094
+ cap: "cover",
1095
+ raw: slice
1096
+ };
1097
+ const raw = fieldOf(slice, "state");
1098
+ const parsed = CoverStateSchema.safeParse(raw);
1099
+ return parsed.success ? reading(parsed.data, COVER_SECURE) : {
1100
+ kind: "unknown",
1101
+ cap: "cover",
1102
+ raw
1103
+ };
1104
+ }
1105
+ function readLock(runtime) {
1106
+ const slice = sliceOf(runtime, "lock-control");
1107
+ if (slice === void 0) return { kind: "absent" };
1108
+ if (isPlaceholder(slice)) return {
1109
+ kind: "unknown",
1110
+ cap: "lock-control",
1111
+ raw: slice
1112
+ };
1113
+ const raw = fieldOf(slice, "state");
1114
+ const parsed = LockStateSchema.safeParse(raw);
1115
+ return parsed.success ? reading(parsed.data, LOCK_SECURE) : {
1116
+ kind: "unknown",
1117
+ cap: "lock-control",
1118
+ raw
1119
+ };
1120
+ }
1121
+ /** D630's table over one device's runtime slices — see the header for the three answers. */
1122
+ function alarmOpeningFrom(runtime) {
1123
+ let firstUnknown = null;
1124
+ for (const read of [
1125
+ readContact(runtime),
1126
+ readCover(runtime),
1127
+ readLock(runtime)
1128
+ ]) {
1129
+ if (read.kind === "reading") return read;
1130
+ if (read.kind === "unknown" && firstUnknown === null) firstUnknown = read;
1131
+ }
1132
+ return firstUnknown ?? NOT_OPENING;
1133
+ }
1134
+ /**
1135
+ * The sensor-event kind (`CAP_TO_KIND` in `@camstack/types`) → the cap whose
1136
+ * slice the row's `value` is. Only the opening kinds; every other kind is not
1137
+ * an opening.
1138
+ */
1139
+ var OPENING_CAP_BY_EVENT_KIND = {
1140
+ contact: "contact",
1141
+ cover: "cover",
1142
+ lock: "lock-control"
1143
+ };
1144
+ /**
1145
+ * The same table, read off one sensor-event row: its `value` IS the slice of
1146
+ * the cap its `kind` names, so the event path and the reconcile read answer in
1147
+ * one vocabulary — a cover is secure only when `closed`, a lock only when
1148
+ * `locked`, whichever path saw it.
1149
+ */
1150
+ function alarmOpeningFromSensorEvent(kind, value) {
1151
+ const cap = OPENING_CAP_BY_EVENT_KIND[kind];
1152
+ if (cap === void 0) return NOT_OPENING;
1153
+ return alarmOpeningFrom({ [cap]: value });
1154
+ }
1155
+ function classifyCoveredOpening(opening) {
1156
+ if (opening === void 0) return {
1157
+ kind: "unknown",
1158
+ why: "read-failed"
1159
+ };
1160
+ if (opening.kind === "unknown") return {
1161
+ kind: "unknown",
1162
+ why: "unknown-word",
1163
+ cap: opening.cap,
1164
+ raw: opening.raw
1165
+ };
1166
+ if (opening.kind === "not-opening" || opening.reading !== "open") return { kind: "skip" };
1167
+ return {
1168
+ kind: "open",
1169
+ state: opening.state
1170
+ };
1171
+ }
1172
+ /**
1173
+ * An `unknown` opening's `raw`, for a log line: a placeholder carries the whole
1174
+ * slice, which `String()` would flatten to `[object Object]`.
1175
+ */
1176
+ function rawForLog(raw) {
1177
+ return raw !== null && typeof raw === "object" ? JSON.stringify(raw) : String(raw);
1178
+ }
1179
+ /** How many runtime reads `readAlarmOpenings` keeps in flight at once. */
1180
+ var OPENINGS_READ_CONCURRENCY = 8;
1181
+ /**
1182
+ * The Notification Center's `readDeviceOpenings` dep, given the one runtime
1183
+ * read it needs. Every id that could be read is present with its
1184
+ * {@link NcAlarmOpening}; an id whose read THREW is ABSENT (unknown, D49) and
1185
+ * leaves one debug line tagged with its `deviceId`.
1186
+ */
1187
+ async function readAlarmOpenings(ids, loadRuntime, logger) {
1188
+ const out = /* @__PURE__ */ new Map();
1189
+ const readOne = async (id) => {
1190
+ try {
1191
+ out.set(id, alarmOpeningFrom(await loadRuntime(id)));
1192
+ } catch (err) {
1193
+ logger.debug("device-opening read failed", {
1194
+ tags: { deviceId: id },
1195
+ meta: { error: String(err) }
1196
+ });
1197
+ }
1198
+ };
1199
+ for (let i = 0; i < ids.length; i += OPENINGS_READ_CONCURRENCY) await Promise.all(ids.slice(i, i + OPENINGS_READ_CONCURRENCY).map(readOne));
1200
+ return out;
1201
+ }
1202
+ //#endregion
1203
+ //#region src/notification-center/alarm/alarm-exclusions.ts
1204
+ function isPhase(value) {
1205
+ return value === "open" || value === "closed";
1206
+ }
1207
+ var NcAlarmExclusions = class {
1208
+ entries = [];
1209
+ /** Replace the whole set — one arm, one set of exclusions. */
1210
+ exclude(deviceIds, at) {
1211
+ this.entries = [...new Set(deviceIds)].map((deviceId) => ({
1212
+ deviceId,
1213
+ phase: "open",
1214
+ at,
1215
+ announced: false
1216
+ }));
1217
+ }
1218
+ /** Every excluded sensor, in the order it was excluded. */
1219
+ list() {
1220
+ return this.entries.map((e) => e.deviceId);
1221
+ }
1222
+ /** True while this sensor's events must not reach the panel. */
1223
+ blocks(deviceId) {
1224
+ return this.entries.some((e) => e.deviceId === deviceId);
1225
+ }
1226
+ /**
1227
+ * Feed one opening's reading, and say what it CHANGED.
1228
+ *
1229
+ * Three answers, not two, because the caller has to persist on both of the
1230
+ * changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
1231
+ * only the re-arm, so a hub redeployed between the close and the re-open came
1232
+ * back with the sensor still in phase `open` — and the exclusion then needed
1233
+ * a SECOND close to lift. A phase that lives only in RAM is half a durable
1234
+ * exclusion.
1235
+ *
1236
+ * An unreadable state changes nothing (D49): a failed read must never lift an
1237
+ * exclusion the operator asked for, and must never swallow the close that
1238
+ * would have.
1239
+ */
1240
+ observe(deviceId, reading, _at) {
1241
+ if (reading === void 0) return "unchanged";
1242
+ const current = this.entries.find((e) => e.deviceId === deviceId);
1243
+ if (current === void 0) return "unchanged";
1244
+ if (reading === "secure") {
1245
+ if (current.phase === "closed") return "unchanged";
1246
+ this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
1247
+ ...e,
1248
+ phase: "closed"
1249
+ } : e);
1250
+ return "closed";
1251
+ }
1252
+ if (current.phase === "open") return "unchanged";
1253
+ this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
1254
+ return "rearmed";
1255
+ }
1256
+ /**
1257
+ * Mark this sensor's ignored trigger as announced — true only the FIRST time
1258
+ * in this arm (the caller then announces and persists), false for a repeat
1259
+ * or for a sensor that is not excluded.
1260
+ */
1261
+ markAnnounced(deviceId) {
1262
+ const current = this.entries.find((e) => e.deviceId === deviceId);
1263
+ if (current === void 0 || current.announced) return false;
1264
+ this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
1265
+ ...e,
1266
+ announced: true
1267
+ } : e);
1268
+ return true;
1269
+ }
1270
+ /** The arm this belongs to is over. */
1271
+ clear() {
1272
+ this.entries = [];
1273
+ }
1274
+ /** What to persist. Plain data — it rides the panel's config blob. */
1275
+ snapshot() {
1276
+ return this.entries.map((e) => ({
1277
+ deviceId: e.deviceId,
1278
+ phase: e.phase,
1279
+ at: e.at,
1280
+ announced: e.announced
1281
+ }));
1282
+ }
1283
+ /**
1284
+ * Rebuild from the persisted blob — a GUARD, never a cast.
1285
+ *
1286
+ * Anything unrecognised restores NOTHING, which is the failure that shows
1287
+ * itself (a bypassed sensor that fires) rather than the one that hides (a
1288
+ * sensor excluded forever by a blob nobody can read). A bare id with no phase
1289
+ * restores as still-OPEN: it needs a close before it can re-arm, which is the
1290
+ * half that cannot cause a false alarm.
1291
+ */
1292
+ restore(raw) {
1293
+ if (!Array.isArray(raw)) return;
1294
+ const out = [];
1295
+ for (const item of raw) {
1296
+ if (typeof item === "number" && Number.isInteger(item)) {
1297
+ out.push({
1298
+ deviceId: item,
1299
+ phase: "open",
1300
+ at: 0,
1301
+ announced: false
1302
+ });
1303
+ continue;
1304
+ }
1305
+ if (item === null || typeof item !== "object") continue;
1306
+ const rec = { ...item };
1307
+ const deviceId = rec["deviceId"];
1308
+ if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
1309
+ const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
1310
+ const at = typeof rec["at"] === "number" ? rec["at"] : 0;
1311
+ out.push({
1312
+ deviceId,
1313
+ phase,
1314
+ at,
1315
+ announced: rec["announced"] === true
1316
+ });
1317
+ }
1318
+ this.entries = out;
1319
+ }
1320
+ };
1321
+ //#endregion
533
1322
  //#region src/notification-center/alarm/alarm-state-machine.ts
534
1323
  /**
535
1324
  * Defaults an operator can change per panel. `home` has no exit delay on
@@ -585,6 +1374,19 @@ function exitDelayAt(m, now) {
585
1374
  armedAt: m.armedAt
586
1375
  };
587
1376
  }
1377
+ /**
1378
+ * The entry delay running at `now`, or `undefined` when none is. Gated on
1379
+ * `stateAt`, like {@link exitDelayAt}: `triggerAt` survives on the machine
1380
+ * after the siren sounds, until a settle drops it.
1381
+ */
1382
+ function pendingAt(m, now) {
1383
+ if (m.triggerAt === void 0) return void 0;
1384
+ if (stateAt(m, now) !== "pending") return void 0;
1385
+ return {
1386
+ target: m.target,
1387
+ triggerAt: m.triggerAt
1388
+ };
1389
+ }
588
1390
  /** Whether the panel is armed enough for a trigger to matter. */
589
1391
  function isArmed(m, now) {
590
1392
  const s = stateAt(m, now);
@@ -677,512 +1479,6 @@ function settle(m, now) {
677
1479
  };
678
1480
  return m;
679
1481
  }
680
- //#endregion
681
- //#region src/notification-center/alarm/alarm-mode-coverage.ts
682
- /**
683
- * Whether the panel just BECAME armed, in the sense a person means.
684
- *
685
- * Two transitions reach an `armed_*` state and only one of them is an arm:
686
- *
687
- * - `arming`/`disarmed` → `armed_away` is somebody arming the alarm. Announce.
688
- * - `triggered` → `armed_away` is the siren's duration ending and the panel
689
- * re-arming itself. Announcing it would send "Away armed" in the middle of
690
- * a break-in, seconds after the alarm notification, saying nothing new.
691
- *
692
- * `armed_home` → `armed_away` IS an arm: the operator changed mode, and the
693
- * set of devices covered just changed with it — which is the whole content of
694
- * the message.
695
- *
696
- * A null `previous` is the FIRST publish and never announces. A hub restarting
697
- * while armed re-publishes `armed_away`, and nobody armed anything — every
698
- * deploy would otherwise send one. Handled here rather than by the caller
699
- * happening to wire the hook late, because "it works because of the order two
700
- * unrelated things run in" is how this stops working.
701
- */
702
- function shouldAnnounceArm(previous, next) {
703
- if (previous === null) return false;
704
- if (!next.startsWith("armed_")) return false;
705
- if (previous === next) return false;
706
- if (previous === "triggered") return false;
707
- return true;
708
- }
709
- /** The mode inside an `armed_<mode>` state, or null for any other state. */
710
- function armModeOf(state, modes) {
711
- for (const mode of modes) if (state === armedStateFor(mode)) return mode;
712
- return null;
713
- }
714
- /** The `deviceState` word a rule must gate on for `mode` to cover it. */
715
- function armedStateFor(mode) {
716
- return `armed_${mode}`;
717
- }
718
- /**
719
- * One entry per mode the panel offers, including modes nothing is gated on.
720
- *
721
- * Empty modes are KEPT rather than filtered: "Night arms nothing" is the single
722
- * most useful thing this can tell an operator, and a list that omits it looks
723
- * identical to a list where night is covered.
724
- */
725
- function alarmModeCoverage(rules, panelDeviceId, modes, skipProbe) {
726
- return modes.map((mode) => coverageFor(rules, panelDeviceId, mode, skipProbe));
727
- }
728
- function coverageFor(rules, panelDeviceId, mode, skipProbe) {
729
- const wanted = armedStateFor(mode);
730
- const ids = /* @__PURE__ */ new Set();
731
- let ruleCount = 0;
732
- let allDevices = false;
733
- for (const rule of rules) {
734
- if (!rule.enabled) continue;
735
- const gate = rule.conditions.deviceState;
736
- if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
737
- if (!gate.states.includes(wanted)) continue;
738
- ruleCount += 1;
739
- const scope = rule.conditions.devices;
740
- if (scope === void 0 || scope.length === 0) {
741
- allDevices = true;
742
- continue;
743
- }
744
- for (const id of scope) ids.add(id);
745
- }
746
- const deviceIds = [...ids].toSorted((a, b) => a - b);
747
- return {
748
- mode,
749
- ruleCount,
750
- allDevices,
751
- deviceIds,
752
- skippedDevices: skippedFor(deviceIds, skipProbe)
753
- };
754
- }
755
- function skippedFor(deviceIds, skipProbe) {
756
- if (skipProbe === void 0) return [];
757
- const out = [];
758
- for (const deviceId of deviceIds) {
759
- const reason = skipProbe(deviceId);
760
- if (reason !== void 0) out.push({
761
- deviceId,
762
- reason
763
- });
764
- }
765
- return out;
766
- }
767
- /**
768
- * Whether this mode arms anything, and if not, which way it fails.
769
- *
770
- * An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
771
- * ids a rule wrote down, so it cannot answer for a mode that covers every
772
- * camera on the hub, and reporting "all excluded" from a subset would be a
773
- * warning about protection that exists.
774
- */
775
- function alarmCoverageGap(coverage) {
776
- if (coverage.ruleCount === 0) return "uncovered";
777
- if (coverage.allDevices) return null;
778
- if (coverage.deviceIds.length === 0) return null;
779
- if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
780
- return "all-skipped";
781
- }
782
- /**
783
- * "Attivi: Cancello, Garage — Esclusi: Porta retro."
784
- *
785
- * A LIST, not a sentence. The mode is in the title and the body is one
786
- * `Label: names` line per device group, because this is read on a lock screen
787
- * and the question is always "which one" — the prose version ("3 dispositivi
788
- * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
789
- * window. Each variant still owns its own catalog key, so the Texts tab can
790
- * re-word "nothing is armed" without touching "these are armed".
791
- *
792
- * Names, not ids: this is read by a person standing at a door. An id that has
793
- * no name falls back to a NAMED, translated placeholder rather than being
794
- * dropped — a silently shorter list would understate what is armed, which is
795
- * the one error this message must not make. (It used to hard-code the English
796
- * `Device <id>` into both languages.)
797
- *
798
- * **Always a sentence.** It used to return null for a mode no rule is gated on,
799
- * on the grounds that "Night armed" followed by an unwatched night is a false
800
- * assurance — true, but the caller then emitted `alarm-armed` anyway with a
801
- * fallback body key that existed in NEITHER locale, so the operator received a
802
- * title and an empty line. Silence is not the third option here: SAYING that
803
- * the mode arms nothing is.
804
- */
805
- function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
806
- const rules = `${coverage.ruleCount}`;
807
- const mode = coverage.mode;
808
- if (bypassed.length > 0) return armedWithBypass(coverage, bypassed, deviceName);
809
- const gap = alarmCoverageGap(coverage);
810
- if (gap === "uncovered") return {
811
- bodyKey: "alarm.arm.body.uncovered",
812
- textParams: { mode },
813
- textCount: 0
814
- };
815
- if (gap === "all-skipped") {
816
- const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
817
- return {
818
- bodyKey: "alarm.arm.body.allSkipped",
819
- textParams: {
820
- rules,
821
- mode,
822
- devices: `${names.length}`,
823
- names: names.join(", ")
824
- },
825
- textCount: names.length
826
- };
827
- }
828
- if (coverage.allDevices) return {
829
- bodyKey: "alarm.arm.body.allDevices",
830
- textParams: {
831
- rules,
832
- mode
833
- },
834
- textCount: coverage.ruleCount
835
- };
836
- if (coverage.deviceIds.length === 0) return {
837
- bodyKey: "alarm.arm.body.noDevices",
838
- textParams: {
839
- rules,
840
- mode
841
- },
842
- textCount: coverage.ruleCount
843
- };
844
- const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
845
- const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
846
- if (skipped.size === 0) return {
847
- bodyKey: "alarm.arm.body.devices",
848
- textParams: {
849
- rules,
850
- mode,
851
- devices: `${names.length}`,
852
- names: names.join(", ")
853
- },
854
- textCount: names.length
855
- };
856
- return {
857
- bodyKey: "alarm.arm.body.devicesSkipped",
858
- textParams: {
859
- rules,
860
- mode,
861
- devices: `${names.length}`,
862
- names: names.join(", "),
863
- skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
864
- },
865
- textCount: names.length
866
- };
867
- }
868
- /**
869
- * "Attivi: 2 — Bypassati: Finestra veranda."
870
- *
871
- * The bypassed sensors are named and the armed ones are COUNTED, not listed:
872
- * this sentence's subject is the exception, and burying it under thirteen
873
- * camera names is how an operator stops reading the arm notification. The
874
- * count pluralises on what IS armed, like every other arm variant.
875
- */
876
- function armedWithBypass(coverage, bypassed, deviceName) {
877
- const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
878
- const excluded = new Set(bypassed);
879
- const armed = coverage.allDevices ? coverage.ruleCount : coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length;
880
- return {
881
- bodyKey: "alarm.arm.body.bypassed",
882
- textParams: {
883
- rules: `${coverage.ruleCount}`,
884
- mode: coverage.mode,
885
- devices: `${armed}`,
886
- bypassedNames: bypassed.map(deviceName).join(", ")
887
- },
888
- textCount: armed
889
- };
890
- }
891
- /**
892
- * "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
893
- *
894
- * The one alarm sentence about something that did not happen, so it has to
895
- * carry both halves an operator needs to act: WHICH sensors are open, and that
896
- * pressing arm again arms without them. Naming them is the whole message — a
897
- * refusal that says only "cannot arm" is a panel the operator will conclude is
898
- * broken — so the names are the FIRST line and the retry hint is the second.
899
- */
900
- function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
901
- return {
902
- bodyKey: "alarm.armRefused.body",
903
- textParams: {
904
- mode,
905
- devices: `${names.length}`,
906
- names: names.join(", "),
907
- seconds: `${retrySeconds}`
908
- },
909
- textCount: names.length
910
- };
911
- }
912
- /**
913
- * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
914
- *
915
- * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
916
- * on the way out of a door — and it carries exactly one thing the arm sentence
917
- * cannot: the warning arrives while the operator can still disarm and fix it.
918
- * A mode that arms nothing is worth interrupting somebody for at second 0 and
919
- * merely worth recording at second 30.
920
- *
921
- * **It never repeats the device list.** The countdown lives in the TITLE
922
- * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
923
- * list arrives thirty seconds later in the arm announcement — printing it
924
- * twice is the verbosity that made the operator stop reading both. So the body
925
- * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
926
- *
927
- * `seconds` stays in the params because the title asks for it, and `textCount`
928
- * stays with it: a row enqueued before this deploy still pluralises on the
929
- * countdown it was written for.
930
- */
931
- function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
932
- const textParams = {
933
- mode: coverage.mode,
934
- seconds: `${seconds}`
935
- };
936
- if (bypassed.length > 0) return {
937
- bodyKey: "alarm.arming.body.bypassed",
938
- textParams: {
939
- ...textParams,
940
- bypassedNames: bypassed.map(deviceName).join(", ")
941
- },
942
- textCount: seconds
943
- };
944
- const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
945
- const gap = alarmCoverageGap(coverage);
946
- if (gap === "uncovered") return {
947
- bodyKey: "alarm.arming.body.uncovered",
948
- textParams,
949
- textCount: seconds
950
- };
951
- if (gap === "all-skipped") return {
952
- bodyKey: "alarm.arming.body.allSkipped",
953
- textParams: {
954
- ...textParams,
955
- names: skippedNames
956
- },
957
- textCount: seconds
958
- };
959
- if (coverage.skippedDevices.length > 0) return {
960
- bodyKey: "alarm.arming.body.skipped",
961
- textParams: {
962
- ...textParams,
963
- names: skippedNames
964
- },
965
- textCount: coverage.skippedDevices.length
966
- };
967
- return {
968
- bodyKey: "alarm.arming.body",
969
- textParams,
970
- textCount: seconds
971
- };
972
- }
973
- //#endregion
974
- //#region src/notification-center/alarm/alarm-arm-guard.ts
975
- /**
976
- * How long a refusal stays confirmable.
977
- *
978
- * Long enough to read the notification and press again (the operator is
979
- * standing at the door, phone in hand); short enough that it cannot be the
980
- * state the panel is left in. Sixty seconds is also under the shortest exit
981
- * delay this panel ships with, so a bypass can never outlive the arm it
982
- * belongs to.
983
- */
984
- var ARM_BYPASS_WINDOW_MS = 6e4;
985
- /** The taxonomy kind a door/window contact persists its events under. */
986
- var CONTACT_SENSOR_KIND = "contact";
987
- /**
988
- * The contact sensors a mode would arm, ascending and de-duplicated.
989
- *
990
- * A rule qualifies only when it is enabled, gated on THIS panel in THIS mode,
991
- * and about contacts (`sensorKinds` naming `contact`). Two exclusions are
992
- * deliberate and both are about refusing an arm the operator cannot fix:
993
- *
994
- * - a rule that names CAMERAS (`Intrusione — Fuori casa` names thirteen) is
995
- * not a contact rule, and a camera is never open;
996
- * - a contact rule with NO device scope covers every contact on the hub, and
997
- * blocking on a set the operator never wrote down produces a refusal naming
998
- * devices they never associated with the alarm. The caller says so in a log
999
- * line rather than inventing the list.
1000
- */
1001
- function alarmBlockingCandidates(rules, panelDeviceId, mode) {
1002
- const wanted = armedStateFor(mode);
1003
- const ids = /* @__PURE__ */ new Set();
1004
- for (const rule of rules) {
1005
- if (!rule.enabled) continue;
1006
- const gate = rule.conditions.deviceState;
1007
- if (gate === void 0 || gate.deviceId !== panelDeviceId) continue;
1008
- if (!gate.states.includes(wanted)) continue;
1009
- const kinds = rule.conditions.sensorKinds;
1010
- if (kinds === void 0 || !kinds.includes(CONTACT_SENSOR_KIND)) continue;
1011
- const scope = rule.conditions.devices;
1012
- if (scope === void 0 || scope.length === 0) continue;
1013
- for (const id of scope) ids.add(id);
1014
- }
1015
- return [...ids].toSorted((a, b) => a - b);
1016
- }
1017
- /**
1018
- * Every contact any mode of this panel could block on — the set whose state is
1019
- * worth keeping mirrored.
1020
- *
1021
- * Read on the rule-reload tick, so the arm path has a recent answer even when
1022
- * the targeted read at arm time fails, and so an excluded sensor's close can be
1023
- * noticed on a hub where the sensor is linked to no camera (and therefore emits
1024
- * no device event at all — the live installation on 2026-08-13).
1025
- */
1026
- function alarmContactWatchList(rules, panelDeviceId, modes) {
1027
- const ids = /* @__PURE__ */ new Set();
1028
- for (const mode of modes) for (const id of alarmBlockingCandidates(rules, panelDeviceId, mode)) ids.add(id);
1029
- return [...ids].toSorted((a, b) => a - b);
1030
- }
1031
- /**
1032
- * Refuse, bypass or proceed — the whole decision, as one comparison.
1033
- *
1034
- * The bypass requires the SAME mode. "Arm home" refused and "arm away" pressed
1035
- * is a different intent about a different set of sensors, and letting one
1036
- * confirm the other would arm a mode around a window nobody was told about.
1037
- *
1038
- * The bypassed set is what is open NOW, not what was open at the refusal: a
1039
- * window shut between the two presses is armed with everything else, which is
1040
- * the entire point of having refused.
1041
- */
1042
- function decideArm(input) {
1043
- const window = input.windowMs ?? 6e4;
1044
- if (input.openContacts.length === 0) return { kind: "clear" };
1045
- const previous = input.previous;
1046
- if (previous !== null && previous.mode === input.mode && input.at - previous.at <= window) return {
1047
- kind: "bypassed",
1048
- bypassed: input.openContacts
1049
- };
1050
- return {
1051
- kind: "refused",
1052
- blocking: input.openContacts,
1053
- retryUntil: input.at + window
1054
- };
1055
- }
1056
- /**
1057
- * The error `alarmPanel.arm` throws when it refuses.
1058
- *
1059
- * A THROW and not a return value, and the reason is a train: the hub's
1060
- * generated router validates this method's response against the HOST's copy of
1061
- * the cap, which says `z.void()`. An addon that answered a structured refusal
1062
- * would have it rejected by the router in front of it — so the refusal reaches
1063
- * the caller as the one channel that already works across that boundary, and
1064
- * the operator's PHONE gets the sentence (`alarm-arm-refused`, through the
1065
- * ordinary outbox) rather than a UI error being the only trace.
1066
- *
1067
- * The message is deliberately actionable and English (an exception message is
1068
- * a developer/API surface; the notification is the operator's).
1069
- */
1070
- var NcAlarmArmRefusedError = class extends Error {
1071
- code = "alarm-arm-refused";
1072
- mode;
1073
- blocking;
1074
- retryUntil;
1075
- constructor(input) {
1076
- const names = input.blocking.map((c) => c.name).join(", ");
1077
- const seconds = Math.round((input.windowMs ?? 6e4) / 1e3);
1078
- super(`arm refused — open: ${names}. Arm ${input.mode} again within ${String(seconds)}s to arm with them excluded.`);
1079
- this.name = "NcAlarmArmRefusedError";
1080
- this.mode = input.mode;
1081
- this.blocking = input.blocking;
1082
- this.retryUntil = input.retryUntil;
1083
- }
1084
- };
1085
- //#endregion
1086
- //#region src/notification-center/alarm/alarm-exclusions.ts
1087
- function isPhase(value) {
1088
- return value === "open" || value === "closed";
1089
- }
1090
- var NcAlarmExclusions = class {
1091
- entries = [];
1092
- /** Replace the whole set — one arm, one set of exclusions. */
1093
- exclude(deviceIds, at) {
1094
- this.entries = [...new Set(deviceIds)].map((deviceId) => ({
1095
- deviceId,
1096
- phase: "open",
1097
- at
1098
- }));
1099
- }
1100
- /** Every excluded sensor, in the order it was excluded. */
1101
- list() {
1102
- return this.entries.map((e) => e.deviceId);
1103
- }
1104
- /** True while this sensor's events must not reach the panel. */
1105
- blocks(deviceId) {
1106
- return this.entries.some((e) => e.deviceId === deviceId);
1107
- }
1108
- /**
1109
- * Feed one contact reading, and say what it CHANGED.
1110
- *
1111
- * Three answers, not two, because the caller has to persist on both of the
1112
- * changes: the first `alarm-arm-refusal.spec.ts` run of this feature saved
1113
- * only the re-arm, so a hub redeployed between the close and the re-open came
1114
- * back with the sensor still in phase `open` — and the exclusion then needed
1115
- * a SECOND close to lift. A phase that lives only in RAM is half a durable
1116
- * exclusion.
1117
- *
1118
- * An unreadable state changes nothing (D49): a failed read must never lift an
1119
- * exclusion the operator asked for, and must never swallow the close that
1120
- * would have.
1121
- */
1122
- observe(deviceId, reading, _at) {
1123
- if (reading === void 0) return "unchanged";
1124
- const current = this.entries.find((e) => e.deviceId === deviceId);
1125
- if (current === void 0) return "unchanged";
1126
- if (reading === "closed") {
1127
- if (current.phase === "closed") return "unchanged";
1128
- this.entries = this.entries.map((e) => e.deviceId === deviceId ? {
1129
- ...e,
1130
- phase: "closed"
1131
- } : e);
1132
- return "closed";
1133
- }
1134
- if (current.phase === "open") return "unchanged";
1135
- this.entries = this.entries.filter((e) => e.deviceId !== deviceId);
1136
- return "rearmed";
1137
- }
1138
- /** The arm this belongs to is over. */
1139
- clear() {
1140
- this.entries = [];
1141
- }
1142
- /** What to persist. Plain data — it rides the panel's config blob. */
1143
- snapshot() {
1144
- return this.entries.map((e) => ({
1145
- deviceId: e.deviceId,
1146
- phase: e.phase,
1147
- at: e.at
1148
- }));
1149
- }
1150
- /**
1151
- * Rebuild from the persisted blob — a GUARD, never a cast.
1152
- *
1153
- * Anything unrecognised restores NOTHING, which is the failure that shows
1154
- * itself (a bypassed sensor that fires) rather than the one that hides (a
1155
- * sensor excluded forever by a blob nobody can read). A bare id with no phase
1156
- * restores as still-OPEN: it needs a close before it can re-arm, which is the
1157
- * half that cannot cause a false alarm.
1158
- */
1159
- restore(raw) {
1160
- if (!Array.isArray(raw)) return;
1161
- const out = [];
1162
- for (const item of raw) {
1163
- if (typeof item === "number" && Number.isInteger(item)) {
1164
- out.push({
1165
- deviceId: item,
1166
- phase: "open",
1167
- at: 0
1168
- });
1169
- continue;
1170
- }
1171
- if (item === null || typeof item !== "object") continue;
1172
- const rec = { ...item };
1173
- const deviceId = rec["deviceId"];
1174
- if (typeof deviceId !== "number" || !Number.isInteger(deviceId)) continue;
1175
- const phase = isPhase(rec["phase"]) ? rec["phase"] : "open";
1176
- const at = typeof rec["at"] === "number" ? rec["at"] : 0;
1177
- out.push({
1178
- deviceId,
1179
- phase,
1180
- at
1181
- });
1182
- }
1183
- this.entries = out;
1184
- }
1185
- };
1186
1482
  /**
1187
1483
  * WHO the panel's current countdown belongs to.
1188
1484
  *
@@ -1281,6 +1577,18 @@ function alarmTransition(input) {
1281
1577
  ...input.source !== void 0 ? { source: input.source } : {}
1282
1578
  };
1283
1579
  if (next === "arming") return armingTransition(previous, input);
1580
+ if (next === "pending") return pendingTransition(previous, input);
1581
+ if (previous === "triggered" && next.startsWith("armed_")) {
1582
+ const mode = armModeOf(next, input.modes);
1583
+ if (mode === null) return null;
1584
+ return {
1585
+ kind: "alarm-rearmed",
1586
+ state: next,
1587
+ previous,
1588
+ mode,
1589
+ at
1590
+ };
1591
+ }
1284
1592
  if (shouldAnnounceArm(previous, next)) {
1285
1593
  const mode = armModeOf(next, input.modes);
1286
1594
  if (mode === null) return null;
@@ -1323,6 +1631,27 @@ function armingTransition(previous, input) {
1323
1631
  at: input.at
1324
1632
  };
1325
1633
  }
1634
+ /**
1635
+ * The entry delay, as one notification at its START — the mirror of
1636
+ * {@link armingTransition}. Null when the panel did not say which mode the
1637
+ * countdown belongs to and when it ends: "Intrusion — s to disarm" is not a
1638
+ * sentence to send.
1639
+ */
1640
+ function pendingTransition(previous, input) {
1641
+ const pending = input.pending;
1642
+ if (pending === void 0) return null;
1643
+ const mode = armModeOf(pending.target, input.modes);
1644
+ if (mode === null) return null;
1645
+ return {
1646
+ kind: "alarm-pending",
1647
+ state: "pending",
1648
+ previous,
1649
+ mode,
1650
+ seconds: Math.max(0, Math.ceil((pending.triggerAt - input.at) / 1e3)),
1651
+ at: input.at,
1652
+ ...input.source !== void 0 ? { source: input.source } : {}
1653
+ };
1654
+ }
1326
1655
  //#endregion
1327
1656
  //#region src/notification-center/alarm/alarm-panel-device.ts
1328
1657
  /**
@@ -1365,22 +1694,70 @@ var ncAlarmPanelSchema = object({
1365
1694
  exitDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.exitDelaySec),
1366
1695
  entryDelaySec: number().int().min(0).max(600).default(DEFAULT_DELAYS.entryDelaySec),
1367
1696
  triggeredDurationSec: number().int().min(0).max(3600).default(DEFAULT_DELAYS.triggeredDurationSec),
1368
- /** Send a notification when a mode takes effect. Off until asked for. */
1697
+ /**
1698
+ * VESTIGIAL (D630): nothing reads it to decide anything any more — every
1699
+ * transition is announced to `announceTargets`. Kept so a persisted blob
1700
+ * and the settings cap still round-trip it, and read once at boot to tell
1701
+ * an operator who had it OFF that their targets now hear every transition.
1702
+ */
1369
1703
  announceArm: boolean().default(false),
1370
- /** Target ids that announcement goes to — see `NcAlarmSettingsSchema`. */
1704
+ /** Target ids EVERY alarm announcement goes to — see `NcAlarmSettingsSchema`. */
1371
1705
  announceTargets: array(string()).default([]),
1372
1706
  machine: record(string(), unknown()).optional(),
1373
1707
  /**
1374
- * Sensors the CURRENT arm is ignoring because they were open when the
1375
- * operator confirmed it, and how far each is through its two-phase lift.
1708
+ * Devices the CURRENT arm is ignoring because they were open when it armed
1709
+ * (tolerated by the mode's non-blocking list, or confirmed with Arm anyway),
1710
+ * and how far each is through its two-phase lift.
1376
1711
  *
1377
1712
  * Persisted for the same reason the machine is: a deploy in the middle of an
1378
1713
  * armed night would otherwise silently re-arm every sensor the operator
1379
1714
  * agreed to leave out, and they would learn about it from the siren. Read
1380
1715
  * back through a guard, never a cast — see `NcAlarmExclusions.restore`.
1381
1716
  */
1382
- exclusions: array(unknown()).default([])
1717
+ exclusions: array(unknown()).default([]),
1718
+ /**
1719
+ * Per arm mode, the covered devices whose being open does not refuse the arm
1720
+ * (D630). Loose on purpose — `mode` is a string here — and read back through
1721
+ * {@link nonBlockingFromPersisted}, never a cast: a blob written by a later
1722
+ * release with a mode this one does not know must drop that entry, not the
1723
+ * whole config.
1724
+ */
1725
+ nonBlocking: array(object({
1726
+ mode: string(),
1727
+ deviceIds: array(number().int())
1728
+ })).default([])
1383
1729
  });
1730
+ /**
1731
+ * The persisted non-blocking lists, narrowed to modes that exist, first entry
1732
+ * per mode kept — the same "at most one list per mode" the settings schema
1733
+ * enforces at the boundary. A type guard (`safeParse`), never a cast.
1734
+ */
1735
+ function nonBlockingFromPersisted(raw) {
1736
+ const seen = /* @__PURE__ */ new Set();
1737
+ const lists = [];
1738
+ for (const entry of raw) {
1739
+ const mode = AlarmArmModeSchema.safeParse(entry.mode);
1740
+ if (!mode.success || seen.has(mode.data)) continue;
1741
+ seen.add(mode.data);
1742
+ lists.push({
1743
+ mode: mode.data,
1744
+ deviceIds: [...entry.deviceIds]
1745
+ });
1746
+ }
1747
+ return lists;
1748
+ }
1749
+ /**
1750
+ * The log line for a trigger the state machine ignored, per state. `trigger`
1751
+ * ignores four: disarmed, arming (the exit delay), and pending / triggered
1752
+ * (already counting down or going off — a second sensor must not restart it).
1753
+ */
1754
+ function ignoredTriggerMessage(state) {
1755
+ if (state === "arming") return "alarm trigger ignored — the panel is still arming (exit delay)";
1756
+ if (state === "pending") return "alarm trigger ignored — the entry delay is already running";
1757
+ if (state === "triggered") return "alarm trigger ignored — the alarm is already going off";
1758
+ if (state === "disarmed") return "alarm trigger ignored — the panel is disarmed";
1759
+ return "alarm trigger ignored — the panel cannot be triggered in this state";
1760
+ }
1384
1761
  /** `armed_home` and friends — the only targets a machine may settle into. */
1385
1762
  function isArmTarget(value) {
1386
1763
  return value === "disarmed" || typeof value === "string" && value.startsWith("armed_");
@@ -1434,20 +1811,26 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1434
1811
  /** Who answers "is anything open" — null until the centre wires itself in. */
1435
1812
  armGuard = null;
1436
1813
  /**
1437
- * The arm attempt that was REFUSED, and is therefore confirmable.
1814
+ * The LATEST refusal — the only one whose Arm anyway can still arm.
1438
1815
  *
1439
1816
  * In RAM and only in RAM, deliberately: it is a confirmation of something the
1440
- * operator read seconds ago, and a confirmation that survived a restart would
1817
+ * operator read minutes ago, and a confirmation that survived a restart would
1441
1818
  * let a process respawn arm the alarm around an open window nobody re-read.
1442
- * Losing it costs one extra press.
1443
- */
1444
- lastRefusal = null;
1819
+ * Losing it costs one extra tap. Nothing else clears it: a disarm or an arm
1820
+ * moves `machine.changedAt`, and `armAnyway` then finds it stale.
1821
+ */
1822
+ refusal = null;
1823
+ /** The refusal whose Arm anyway was pressed last — a second press is `used`. */
1824
+ spentRefusalId = null;
1825
+ /** Makes refusal ids unique inside one millisecond. */
1826
+ refusalSeq = 0;
1445
1827
  constructor(ctx) {
1446
1828
  super(ctx, ncAlarmPanelSchema, { type: DeviceType.AlarmPanel });
1447
1829
  this.delays = this.readDelays();
1448
1830
  this.restore();
1449
1831
  this.registerAlarmCap();
1450
1832
  this.publish();
1833
+ 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 } });
1451
1834
  }
1452
1835
  /** The operator-visible configuration, as the cap serves it. */
1453
1836
  settings() {
@@ -1455,7 +1838,7 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1455
1838
  ...this.readDelays(),
1456
1839
  announceArm: this.config.get("announceArm"),
1457
1840
  announceTargets: [...this.config.get("announceTargets")],
1458
- nonBlocking: []
1841
+ nonBlocking: nonBlockingFromPersisted(this.config.get("nonBlocking"))
1459
1842
  };
1460
1843
  }
1461
1844
  /**
@@ -1472,6 +1855,10 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1472
1855
  if (patch.triggeredDurationSec !== void 0) await this.config.set("triggeredDurationSec", patch.triggeredDurationSec);
1473
1856
  if (patch.announceArm !== void 0) await this.config.set("announceArm", patch.announceArm);
1474
1857
  if (patch.announceTargets !== void 0) await this.config.set("announceTargets", [...patch.announceTargets]);
1858
+ if (patch.nonBlocking !== void 0) await this.config.set("nonBlocking", patch.nonBlocking.map((l) => ({
1859
+ mode: l.mode,
1860
+ deviceIds: [...l.deviceIds]
1861
+ })));
1475
1862
  this.delays = this.readDelays();
1476
1863
  return this.settings();
1477
1864
  }
@@ -1500,11 +1887,106 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1500
1887
  currentState(now = Date.now()) {
1501
1888
  return stateAt(this.machine, now);
1502
1889
  }
1503
- /** Wire the open-contact check and the refusal announcement. */
1890
+ /** The mode the machine belongs to — see {@link NcAlarmPublishReport.target}. */
1891
+ currentTarget() {
1892
+ return this.machine.target;
1893
+ }
1894
+ /** Wire the openings check and the refusal / check-failed announcements. */
1504
1895
  setArmGuard(hook) {
1505
1896
  this.armGuard = hook;
1506
1897
  }
1507
1898
  /**
1899
+ * A plain arm. Refused on any open covered device the mode does not tolerate
1900
+ * — pressed once or ten times, the answer is the same; the only way past a
1901
+ * refusal is {@link armAnyway} with that refusal's id.
1902
+ *
1903
+ * When the openings could not be read at all it arms (fail-open, announced
1904
+ * as a check failure) and EXCLUDES NOTHING NEW: the previous exclusions are
1905
+ * left as they were, since there is no reading to replace them with. An
1906
+ * `unknown` device neither blocks nor is excluded — nobody was shown it.
1907
+ */
1908
+ async armWith(mode) {
1909
+ const now = Date.now();
1910
+ const read = await this.applyArmGuard(mode);
1911
+ if (read.kind === "unguarded") return this.settleArm(mode, null, now);
1912
+ if (read.kind === "check-failed") {
1913
+ const outcome = this.settleArm(mode, null, now);
1914
+ this.announceUncheckedArm(mode, read.error, now);
1915
+ return outcome;
1916
+ }
1917
+ if (read.verdict.kind === "refused") return this.refuse(mode, read.verdict.blocking, now);
1918
+ return this.settleArm(mode, read.verdict.excluded.map((device) => ({
1919
+ device,
1920
+ why: "non-blocking"
1921
+ })), now);
1922
+ }
1923
+ /**
1924
+ * The Arm anyway on ONE refusal's notification — the only path that arms
1925
+ * around a device that blocks.
1926
+ *
1927
+ * 1. no held refusal, or a different one (a later refusal replaced it) ⇒
1928
+ * `unknown-refusal`; the id of the refusal spent last ⇒ `used`;
1929
+ * 2. the panel changed state since ⇒ `stale`, and the refusal is dropped;
1930
+ * 3. older than {@link ARM_ANYWAY_TTL_MS} ⇒ `expired`, and it is dropped;
1931
+ * 4. otherwise the openings are READ AGAIN. A device that opened since is a
1932
+ * NEW refusal (new id, new notification) listing EVERY device that
1933
+ * blocks now — the still-open confirmed ones too, so its own Arm anyway
1934
+ * can arm around all of them, and the confirmed ones the re-read reads
1935
+ * UNKNOWN (so that Arm anyway still excludes them). Otherwise the arm excludes what is open
1936
+ * now; a refused device that closed meanwhile is armed normally. A
1937
+ * confirmed device the re-read cannot vouch for — the read failed, or
1938
+ * it reads unknown — stays EXCLUDED (D49: unknown is never secure).
1939
+ * 5. the refusal is spent on every outcome of step 4.
1940
+ */
1941
+ async armAnyway(refusalId) {
1942
+ const now = Date.now();
1943
+ const held = this.refusal;
1944
+ if (held === null || held.id !== refusalId) {
1945
+ const reason = refusalId === this.spentRefusalId ? "used" : "unknown-refusal";
1946
+ return this.rejectArmAnyway(refusalId, reason);
1947
+ }
1948
+ if (this.machine.changedAt !== held.panelChangedAt) {
1949
+ this.refusal = null;
1950
+ return this.rejectArmAnyway(refusalId, "stale");
1951
+ }
1952
+ if (now - held.at > 3e5) {
1953
+ this.refusal = null;
1954
+ return this.rejectArmAnyway(refusalId, "expired");
1955
+ }
1956
+ this.refusal = null;
1957
+ this.spentRefusalId = held.id;
1958
+ const read = await this.applyArmGuard(held.mode, held.blockingIds);
1959
+ if (this.machine.changedAt !== held.panelChangedAt) return this.rejectArmAnyway(refusalId, "stale");
1960
+ if (read.kind !== "read") {
1961
+ const outcome = this.settleArm(held.mode, held.blocking.map((device) => ({
1962
+ device,
1963
+ why: "arm-anyway-unverified"
1964
+ })), now);
1965
+ if (read.kind === "check-failed") this.announceUncheckedArm(held.mode, read.error, now);
1966
+ return outcome;
1967
+ }
1968
+ const tolerated = this.nonBlockingFor(held.mode);
1969
+ const unknown = new Set(read.openings.unknown);
1970
+ if (read.verdict.kind === "refused") {
1971
+ const plain = decideArm({
1972
+ mode: held.mode,
1973
+ open: read.openings.open,
1974
+ nonBlocking: tolerated
1975
+ });
1976
+ const blockingNow = plain.kind === "refused" ? plain.blocking : read.verdict.blocking;
1977
+ const shownIds = new Set(blockingNow.map((d) => d.deviceId));
1978
+ const blocking = [...blockingNow, ...held.blocking.filter((d) => unknown.has(d.deviceId) && !shownIds.has(d.deviceId))];
1979
+ return this.refuse(held.mode, blocking, now);
1980
+ }
1981
+ return this.settleArm(held.mode, [...read.verdict.excluded.map((device) => ({
1982
+ device,
1983
+ why: tolerated.includes(device.deviceId) ? "non-blocking" : "arm-anyway"
1984
+ })), ...held.blocking.filter((device) => unknown.has(device.deviceId)).map((device) => ({
1985
+ device,
1986
+ why: "arm-anyway-unknown"
1987
+ }))], now);
1988
+ }
1989
+ /**
1508
1990
  * Sensors the current arm is ignoring — what the armed/arming sentence names
1509
1991
  * as excluded, and the only place that list is published.
1510
1992
  */
@@ -1512,26 +1994,54 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1512
1994
  return this.exclusions.list();
1513
1995
  }
1514
1996
  /**
1515
- * One contact reading, from whoever saw it — the sensor's own event, or the
1516
- * centre's reconcile tick.
1997
+ * The same exclusions, each with its phase — `open` while the device is
1998
+ * still open as it was when armed, `closed` once it has been secured and
1999
+ * its next open re-arms it. What the live view shows; a read, never a copy.
2000
+ */
2001
+ exclusionPhases() {
2002
+ return this.exclusions.snapshot().map((e) => ({
2003
+ deviceId: e.deviceId,
2004
+ phase: e.phase
2005
+ }));
2006
+ }
2007
+ /**
2008
+ * Disarm — the ONE path, for the cap and the `nc.disarmAlarm` bridge alike.
2009
+ * The exclusions belong to the ARM, and this one is over: keeping them would
2010
+ * carry an open window's exemption into tonight's arm, which nobody
2011
+ * confirmed.
2012
+ */
2013
+ async disarm() {
2014
+ this.machine = disarm(Date.now());
2015
+ this.attribution.clear();
2016
+ this.exclusions.clear();
2017
+ this.publish();
2018
+ }
2019
+ /**
2020
+ * One opening's reading (`open` / `secure`, D630's vocabulary — a cover is
2021
+ * secure only when `closed`, a lock only when `locked`), from whoever saw
2022
+ * it: the sensor's own event, or the centre's reconcile tick.
1517
2023
  *
1518
2024
  * The lift is two-phase (`alarm-exclusions.ts`), so this is safe to call with
1519
2025
  * the same reading repeatedly, and an unreadable state changes nothing. Fed
1520
2026
  * BEFORE the event that carried it is evaluated, so the open that re-arms a
1521
2027
  * sensor is already lifted when the rule it fires reaches the panel.
1522
2028
  */
1523
- observeContact(deviceId, reading, now = Date.now()) {
2029
+ observeOpening(deviceId, reading, now = Date.now()) {
1524
2030
  const change = this.exclusions.observe(deviceId, reading, now);
1525
2031
  if (change === "unchanged") return;
1526
2032
  this.persist();
1527
2033
  if (change === "closed") return;
1528
- this.ctx.logger.info("alarm exclusion lifted — the sensor closed and opened again", {
2034
+ this.ctx.logger.info("alarm exclusion lifted — the device was secured and opened again", {
1529
2035
  tags: { deviceId },
1530
2036
  meta: {
1531
2037
  panelDeviceId: this.id,
1532
2038
  remaining: this.exclusions.list().length
1533
2039
  }
1534
2040
  });
2041
+ this.announceBypass({
2042
+ deviceId,
2043
+ event: "lifted"
2044
+ }, now);
1535
2045
  }
1536
2046
  /** Stop the countdown when the device goes away. The state itself is
1537
2047
  * persisted, so nothing is lost — only the announcements stop. */
@@ -1539,108 +2049,166 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1539
2049
  this.stopTicking();
1540
2050
  }
1541
2051
  /**
1542
- * Refuse the arm, bypass around it, or let it through — and leave the
1543
- * exclusions the arm will run with.
2052
+ * Ask the centre what is open and decide — or say why there is nothing to
2053
+ * decide with.
1544
2054
  *
1545
2055
  * Fail-OPEN by construction, in three places, and each one is a decision:
1546
2056
  *
1547
2057
  * - no guard wired (an agent node, a boot pass before the centre exists) ⇒
1548
- * arm. A panel that cannot ask must not refuse.
1549
- * - the guard threw ⇒ arm, and SAY so. Refusing on a failed read would tell
1550
- * the operator to go and shut a window that is already shut (the coverage
1551
- * derivation makes the same choice: an unreadable authority answers
1552
- * "unknown", never "excluded").
1553
- * - nothing reads open ⇒ arm clean, and any exclusion a previous arm left
1554
- * is dropped.
1555
- *
1556
- * The refusal throws. See {@link NcAlarmArmRefusedError} for why that is the
1557
- * channel rather than a return value.
1558
- */
1559
- async applyArmGuard(mode, now) {
2058
+ * `unguarded`, arm. A panel that cannot ask must not refuse.
2059
+ * - the read threw (nothing could be read) ⇒ `check-failed`, arm, and SAY
2060
+ * so — a WARN here and, once the arm has LANDED, `announceCheckFailed` on
2061
+ * the operator's phone. Refusing on a failed read would send the operator
2062
+ * to shut a window that is already shut.
2063
+ * - a covered device whose state is UNKNOWN ⇒ never blocks and is never
2064
+ * counted secure (D49); the guard logs one line each, under its own id.
2065
+ *
2066
+ * `acknowledged` is what an Arm anyway confirms — the ids its refusal SHOWED.
2067
+ */
2068
+ async applyArmGuard(mode, acknowledged) {
1560
2069
  const guard = this.armGuard;
1561
- if (guard === null) return;
1562
- let openContacts;
2070
+ if (guard === null) return { kind: "unguarded" };
2071
+ let openings;
1563
2072
  try {
1564
- openContacts = await guard.openContacts(mode);
2073
+ openings = await guard.openings(mode);
1565
2074
  } catch (err) {
1566
- this.ctx.logger.warn("alarm arm guard could not read the contacts — arming anyway", {
2075
+ this.ctx.logger.warn("alarm arm guard could not read the openings — arming without the check", {
1567
2076
  tags: { deviceId: this.id },
1568
2077
  meta: {
1569
2078
  mode,
1570
2079
  error: String(err)
1571
2080
  }
1572
2081
  });
1573
- return;
2082
+ return {
2083
+ kind: "check-failed",
2084
+ error: String(err)
2085
+ };
1574
2086
  }
1575
2087
  const verdict = decideArm({
1576
2088
  mode,
1577
- openContacts,
1578
- previous: this.lastRefusal,
1579
- at: now
2089
+ open: openings.open,
2090
+ nonBlocking: this.nonBlockingFor(mode),
2091
+ ...acknowledged !== void 0 ? { acknowledged } : {}
1580
2092
  });
1581
- if (verdict.kind === "clear") {
1582
- this.lastRefusal = null;
1583
- if (this.exclusions.list().length > 0) {
1584
- this.exclusions.clear();
1585
- this.persist();
1586
- }
1587
- return;
1588
- }
1589
- if (verdict.kind === "bypassed") {
1590
- this.lastRefusal = null;
1591
- this.exclusions.exclude(verdict.bypassed.map((c) => c.deviceId), now);
2093
+ return {
2094
+ kind: "read",
2095
+ openings,
2096
+ verdict
2097
+ };
2098
+ }
2099
+ /**
2100
+ * The operator's copy of "armed WITHOUT the openings check" — called only
2101
+ * after {@link settleArm}, with the state the panel is really in now. Never
2102
+ * on a path that armed nothing: a stale Arm anyway whose re-read also failed
2103
+ * used to push "Armed … without checking" for an arm that never happened.
2104
+ */
2105
+ announceUncheckedArm(mode, error, now) {
2106
+ this.armGuard?.announceCheckFailed({
2107
+ mode,
2108
+ state: stateAt(this.machine, now),
2109
+ at: now,
2110
+ error
2111
+ });
2112
+ }
2113
+ /** This mode's non-blocking ids, from the persisted lists. */
2114
+ nonBlockingFor(mode) {
2115
+ return nonBlockingFromPersisted(this.config.get("nonBlocking")).find((l) => l.mode === mode)?.deviceIds ?? [];
2116
+ }
2117
+ /**
2118
+ * Arm, with these exclusions.
2119
+ *
2120
+ * `null` exclusions (no guard, or nothing could be read on a plain arm) arm
2121
+ * and leave the exclusions as they were — there is no reading to replace them with.
2122
+ */
2123
+ settleArm(mode, exclusions, now) {
2124
+ const excluded = (exclusions ?? []).map((e) => e.device);
2125
+ if (exclusions !== null) {
2126
+ if (excluded.length > 0) this.exclusions.exclude(excluded.map((d) => d.deviceId), now);
2127
+ else this.exclusions.clear();
1592
2128
  this.persist();
1593
- for (const contact of verdict.bypassed) this.ctx.logger.info("alarm armed with an open sensor excluded", {
1594
- tags: { deviceId: contact.deviceId },
1595
- meta: {
1596
- panelDeviceId: this.id,
1597
- mode,
1598
- name: contact.name
1599
- }
1600
- });
1601
- return;
1602
2129
  }
1603
- this.lastRefusal = {
2130
+ this.machine = arm(mode, this.delays, now);
2131
+ this.attribution.clear();
2132
+ this.publish(now);
2133
+ for (const { device, why } of exclusions ?? []) this.ctx.logger.info("alarm armed with an open device excluded", {
2134
+ tags: { deviceId: device.deviceId },
2135
+ meta: {
2136
+ panelDeviceId: this.id,
2137
+ mode,
2138
+ name: device.name,
2139
+ state: device.state,
2140
+ why
2141
+ }
2142
+ });
2143
+ return {
2144
+ outcome: stateAt(this.machine, now) === "arming" ? "arming" : "armed",
2145
+ excluded
2146
+ };
2147
+ }
2148
+ /**
2149
+ * Record the refusal, say it per device, and hand it to the operator's phone.
2150
+ *
2151
+ * The record REPLACES any earlier one — only the latest refusal's Arm anyway
2152
+ * can arm (an older notification's button is superseded).
2153
+ */
2154
+ refuse(mode, blocking, now) {
2155
+ this.refusalSeq += 1;
2156
+ const refusal = {
2157
+ id: `refusal:${String(this.id)}:${mode}:${String(now)}:${String(this.refusalSeq)}`,
1604
2158
  mode,
1605
- at: now
2159
+ blockingIds: blocking.map((d) => d.deviceId),
2160
+ blocking,
2161
+ at: now,
2162
+ panelChangedAt: this.machine.changedAt
1606
2163
  };
1607
- for (const contact of verdict.blocking) this.ctx.logger.warn("alarm arm REFUSED — this sensor is open", {
1608
- tags: { deviceId: contact.deviceId },
2164
+ this.refusal = refusal;
2165
+ for (const d of blocking) this.ctx.logger.warn("alarm arm REFUSED — this device is open", {
2166
+ tags: { deviceId: d.deviceId },
1609
2167
  meta: {
1610
2168
  panelDeviceId: this.id,
1611
2169
  mode,
1612
- name: contact.name
2170
+ name: d.name,
2171
+ state: d.state,
2172
+ refusalId: refusal.id
1613
2173
  }
1614
2174
  });
1615
- guard.announceRefusal({
1616
- mode,
1617
- blocking: verdict.blocking,
1618
- retrySeconds: Math.round(ARM_BYPASS_WINDOW_MS / 1e3),
2175
+ this.armGuard?.announceRefusal({
2176
+ refusal,
2177
+ blocking,
2178
+ state: stateAt(this.machine, now),
1619
2179
  at: now
1620
2180
  });
1621
- throw new NcAlarmArmRefusedError({
1622
- mode,
1623
- blocking: verdict.blocking,
1624
- retryUntil: verdict.retryUntil
2181
+ return {
2182
+ outcome: "refused",
2183
+ refusalId: refusal.id,
2184
+ blocking
2185
+ };
2186
+ }
2187
+ /** A pressed Arm anyway that did nothing — refused work, so it says so. */
2188
+ rejectArmAnyway(refusalId, reason) {
2189
+ this.ctx.logger.info("alarm Arm anyway rejected — nothing was armed", {
2190
+ tags: { deviceId: this.id },
2191
+ meta: {
2192
+ refusalId,
2193
+ reason
2194
+ }
1625
2195
  });
2196
+ return {
2197
+ outcome: "rejected",
2198
+ reason
2199
+ };
1626
2200
  }
1627
2201
  registerAlarmCap() {
1628
2202
  this.ctx.registerNativeCap(alarmPanelCapability, {
1629
2203
  getStatus: async () => this.status(),
1630
2204
  arm: async ({ mode }) => {
1631
- const now = Date.now();
1632
- await this.applyArmGuard(mode, now);
1633
- this.machine = arm(mode, this.delays, now);
1634
- this.attribution.clear();
1635
- this.publish(now);
1636
- },
1637
- disarm: async () => {
1638
- this.machine = disarm(Date.now());
1639
- this.attribution.clear();
1640
- this.exclusions.clear();
1641
- this.lastRefusal = null;
1642
- this.publish();
2205
+ const outcome = await this.armWith(mode);
2206
+ if (outcome.outcome === "refused") throw new NcAlarmArmRefusedError({
2207
+ mode,
2208
+ blocking: outcome.blocking
2209
+ });
1643
2210
  },
2211
+ disarm: async () => this.disarm(),
1644
2212
  trigger: async () => {
1645
2213
  const now = Date.now();
1646
2214
  const pending = this.attribution.pending(now);
@@ -1656,13 +2224,15 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1656
2224
  state: stateAt(this.machine, now)
1657
2225
  }
1658
2226
  });
2227
+ this.announceIgnoredTrigger(source, now);
1659
2228
  return;
1660
2229
  }
1661
2230
  const next = trigger(this.machine, this.delays, now);
1662
2231
  if (next === this.machine) {
1663
- this.ctx.logger.info("alarm trigger ignored — the panel is not armed", {
2232
+ const state = stateAt(this.machine, now);
2233
+ this.ctx.logger.info(ignoredTriggerMessage(state), {
1664
2234
  tags: { deviceId: this.id },
1665
- meta: { state: stateAt(this.machine, now) }
2235
+ meta: { state }
1666
2236
  });
1667
2237
  return;
1668
2238
  }
@@ -1718,18 +2288,21 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1718
2288
  if (hook === null) return;
1719
2289
  const source = this.attribution.current();
1720
2290
  const arming = exitDelayAt(this.machine, now);
2291
+ const pending = pendingAt(this.machine, now);
1721
2292
  const transition = alarmTransition({
1722
2293
  previous,
1723
2294
  next: state,
1724
2295
  modes: NC_ALARM_MODES,
1725
2296
  at: now,
1726
2297
  ...source !== null ? { source } : {},
1727
- ...arming !== void 0 ? { arming } : {}
2298
+ ...arming !== void 0 ? { arming } : {},
2299
+ ...pending !== void 0 ? { pending } : {}
1728
2300
  });
1729
2301
  if (transition?.kind === "alarm-triggered") this.attribution.clear();
1730
2302
  try {
1731
2303
  hook({
1732
2304
  state,
2305
+ target: this.machine.target,
1733
2306
  transition
1734
2307
  });
1735
2308
  } catch (err) {
@@ -1743,6 +2316,70 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1743
2316
  });
1744
2317
  }
1745
2318
  }
2319
+ /**
2320
+ * A dropped trigger is TOLD, not only logged — an intrusion the alarm
2321
+ * deliberately did not act on is what the operator most needs to hear — but
2322
+ * ONCE per sensor per arm (the mark rides the persisted exclusion), and only
2323
+ * while the panel is armed: during the exit delay `trigger()` would have
2324
+ * dropped it anyway, so the exclusion is not what ignored it.
2325
+ */
2326
+ announceIgnoredTrigger(deviceId, now) {
2327
+ if (!isArmed(this.machine, now)) {
2328
+ this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
2329
+ tags: { deviceId },
2330
+ meta: {
2331
+ panelDeviceId: this.id,
2332
+ state: stateAt(this.machine, now)
2333
+ }
2334
+ });
2335
+ return;
2336
+ }
2337
+ if (!this.exclusions.markAnnounced(deviceId)) {
2338
+ this.ctx.logger.debug("alarm bypass already announced in this arm — repeat not sent", {
2339
+ tags: { deviceId },
2340
+ meta: { panelDeviceId: this.id }
2341
+ });
2342
+ return;
2343
+ }
2344
+ this.persist();
2345
+ this.announceBypass({
2346
+ deviceId,
2347
+ event: "ignored"
2348
+ }, now);
2349
+ }
2350
+ /**
2351
+ * Report a sensor the current arm ignored, or stopped ignoring, through the
2352
+ * publish hook — a report whose state is UNCHANGED, so `lastPublished` is
2353
+ * not touched and the next real transition is still computed from it.
2354
+ */
2355
+ announceBypass(sensor, now) {
2356
+ const hook = this.onPublish;
2357
+ if (hook === null) return;
2358
+ const state = stateAt(this.machine, now);
2359
+ const transition = {
2360
+ kind: "alarm-sensor-bypass",
2361
+ state,
2362
+ previous: state,
2363
+ at: now,
2364
+ sensor
2365
+ };
2366
+ try {
2367
+ hook({
2368
+ state,
2369
+ target: this.machine.target,
2370
+ transition
2371
+ });
2372
+ } catch (err) {
2373
+ this.ctx.logger.warn("alarm publish hook failed", {
2374
+ tags: { deviceId: this.id },
2375
+ meta: {
2376
+ state,
2377
+ transition: transition.kind,
2378
+ error: String(err)
2379
+ }
2380
+ });
2381
+ }
2382
+ }
1746
2383
  /** The three durations, read from the persisted config. */
1747
2384
  readDelays() {
1748
2385
  return {
@@ -1805,6 +2442,25 @@ var NC_ALARM_DECLARATION = {
1805
2442
  DeviceClass: NcAlarmPanelDevice
1806
2443
  };
1807
2444
  //#endregion
2445
+ //#region src/notification-center/alarm/alarm-panel-port.ts
2446
+ function ncAlarmPanelPort(panel) {
2447
+ return {
2448
+ deviceId: panel.id,
2449
+ settings: () => panel.settings(),
2450
+ applySettings: (patch) => panel.applySettings(patch),
2451
+ availableModes: () => NC_ALARM_MODES,
2452
+ noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
2453
+ excludedDevices: () => panel.excludedDevices(),
2454
+ exclusionPhases: () => panel.exclusionPhases(),
2455
+ currentState: () => panel.currentState(),
2456
+ currentTarget: () => panel.currentTarget(),
2457
+ armWith: (mode) => panel.armWith(mode),
2458
+ armAnyway: (refusalId) => panel.armAnyway(refusalId),
2459
+ disarm: () => panel.disarm(),
2460
+ observeOpening: (deviceId, reading, now) => panel.observeOpening(deviceId, reading, now)
2461
+ };
2462
+ }
2463
+ //#endregion
1808
2464
  //#region src/notification-center/artifact-index.ts
1809
2465
  /**
1810
2466
  * @durable class=ledger owner=notification-center
@@ -7104,10 +7760,10 @@ function replaceConditionReferences(monitor, condition, reference) {
7104
7760
  //#endregion
7105
7761
  //#region src/notification-center/snooze-exempt.ts
7106
7762
  /** The cap whose presence in a sequence makes the rule the alarm's trigger. */
7107
- var ALARM_PANEL_CAP$1 = "alarm-panel";
7763
+ var ALARM_PANEL_CAP$2 = "alarm-panel";
7108
7764
  var ALARM_KINDS = new Set(NC_ALARM_SYSTEM_EVENT_KINDS);
7109
7765
  function actuatesAlarmPanel(sequences) {
7110
- for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$1) return true;
7766
+ for (const sequence of sequences) for (const step of sequence.actions) if (step.kind === "cap" && step.cap === ALARM_PANEL_CAP$2) return true;
7111
7767
  return false;
7112
7768
  }
7113
7769
  /**
@@ -7258,10 +7914,11 @@ var MAX_ARM_BUTTONS = 3;
7258
7914
  *
7259
7915
  * | notification | buttons |
7260
7916
  * | --- | --- |
7261
- * | `alarm-arming` | **Disarma**, destructive |
7262
- * | `alarm-triggered` | **Disarma**, destructive |
7263
- * | `alarm-armed` | **Disarma**, destructive |
7264
- * | `alarm-disarmed` | **Arma …** per declared mode, max three |
7917
+ * | `alarm-arming`, `alarm-armed`, `alarm-pending` | **Disarma**, destructive |
7918
+ * | `alarm-triggered`, `alarm-rearmed`, `alarm-check-failed` | **Disarma**, destructive |
7919
+ * | `alarm-disarmed` | **Arma …** per declared (enabled) mode, max three |
7920
+ * | `alarm-arm-refused` | **Arma comunque**, bound to the row's refusal |
7921
+ * | `alarm-sensor-bypass` | nothing — it reports, there is nothing to undo |
7265
7922
  *
7266
7923
  * The `alarm-arming` row is the one that earns its keep: that notification
7267
7924
  * arrives at the START of the exit delay, so its Disarm is the only alarm
@@ -7276,10 +7933,15 @@ var MAX_ARM_BUTTONS = 3;
7276
7933
  * browser. The disarm is `navigate`: it removes a protection, and the operator
7277
7934
  * doing it deserves to SEE that it happened rather than trust a POST nobody
7278
7935
  * confirmed.
7936
+ *
7937
+ * `panel.availableModes` ARE the enabled modes: CamStack has no per-mode
7938
+ * disable setting, so the Arm buttons offer exactly what the panel declares.
7279
7939
  */
7280
7940
  function buildAlarmButtons(input) {
7281
7941
  const panel = input.panel;
7282
7942
  if (panel === null) return [];
7943
+ if (input.kind === "alarm-sensor-bypass") return [];
7944
+ if (input.kind === "alarm-arm-refused") return armAnywayButton(input.refusalId, input);
7283
7945
  if (input.kind === "alarm-disarmed") return panel.availableModes.slice(0, MAX_ARM_BUTTONS).map((mode) => {
7284
7946
  const actionId = `alarm-arm-${mode}`;
7285
7947
  return {
@@ -7307,6 +7969,28 @@ function buildAlarmButtons(input) {
7307
7969
  mode: "navigate"
7308
7970
  }];
7309
7971
  }
7972
+ /**
7973
+ * The one Arm anyway a refusal notification carries — or none, when the row
7974
+ * names no refusal. `navigate` and destructive, like the disarm: it arms
7975
+ * AROUND an open door, and the person doing it should see it happen. Its TTL
7976
+ * is the refusal's own (`ARM_ANYWAY_TTL_MS`, priced by the minter).
7977
+ */
7978
+ function armAnywayButton(refusalId, input) {
7979
+ if (refusalId === void 0) return [];
7980
+ const actionId = "alarm-arm-anyway";
7981
+ return [{
7982
+ id: actionId,
7983
+ label: input.labels.armAnyway,
7984
+ url: input.mintUrl({
7985
+ actionId,
7986
+ action: "arm-anyway",
7987
+ refusalId
7988
+ }),
7989
+ icon: "arm",
7990
+ destructive: true,
7991
+ mode: "navigate"
7992
+ }];
7993
+ }
7310
7994
  //#endregion
7311
7995
  //#region src/notification-center/addon-update-apply.ts
7312
7996
  /**
@@ -7369,6 +8053,80 @@ function buildAddonUpdateButtons(input) {
7369
8053
  }];
7370
8054
  }
7371
8055
  //#endregion
8056
+ //#region src/notification-center/alarm/alarm-gate-words.ts
8057
+ /** The states that belong to an armed mode without being it. */
8058
+ var INCIDENT_STATES = new Set(["pending", "triggered"]);
8059
+ /** `[state]`, plus `target` while `state` is `pending`/`triggered` of an armed mode. */
8060
+ function alarmGateWords(state, target) {
8061
+ if (INCIDENT_STATES.has(state) && target.startsWith("armed_")) return [state, target];
8062
+ return [state];
8063
+ }
8064
+ /**
8065
+ * The engine's lookup: every device answers its one mirrored word, and the
8066
+ * panel answers its gate words — but only while the mirror AGREES on the state.
8067
+ *
8068
+ * The mirror keeps the freshness policy (an expired or cold entry is
8069
+ * `undefined`, which fails the gate closed — D49); the alias only widens an
8070
+ * answer the mirror already gives. A target paired with a state it was not
8071
+ * published with would be a guess, so a disagreement answers the mirror alone.
8072
+ */
8073
+ function panelAwareLookup(read, panel) {
8074
+ return (deviceId) => {
8075
+ const state = read(deviceId);
8076
+ if (state === void 0) return void 0;
8077
+ if (panel !== null && panel.deviceId === deviceId && sameWord(panel.words[0], state)) return panel.words;
8078
+ return [state];
8079
+ };
8080
+ }
8081
+ function sameWord(a, b) {
8082
+ return a !== void 0 && a.trim().toLowerCase() === b.trim().toLowerCase();
8083
+ }
8084
+ //#endregion
8085
+ //#region src/notification-center/alarm/alarm-live.ts
8086
+ /** Every covered id across all modes, ascending, once — what the one read asks for. */
8087
+ function alarmLiveReadIds(covered) {
8088
+ const ids = /* @__PURE__ */ new Set();
8089
+ for (const list of covered.values()) for (const id of list) ids.add(id);
8090
+ return [...ids].toSorted((a, b) => a - b);
8091
+ }
8092
+ function modeOpenings(input, mode) {
8093
+ const tolerated = new Set(input.nonBlocking.find((l) => l.mode === mode)?.deviceIds ?? []);
8094
+ const devices = [];
8095
+ const unknown = [];
8096
+ for (const deviceId of input.covered.get(mode) ?? []) {
8097
+ const verdict = classifyCoveredOpening(input.openings.get(deviceId));
8098
+ if (verdict.kind === "unknown") {
8099
+ unknown.push(deviceId);
8100
+ continue;
8101
+ }
8102
+ if (verdict.kind === "skip") continue;
8103
+ devices.push({
8104
+ deviceId,
8105
+ name: input.name(deviceId),
8106
+ state: verdict.state,
8107
+ blocking: !tolerated.has(deviceId)
8108
+ });
8109
+ }
8110
+ return {
8111
+ mode,
8112
+ devices,
8113
+ unknown
8114
+ };
8115
+ }
8116
+ function buildAlarmLive(input) {
8117
+ return {
8118
+ state: input.state,
8119
+ targetMode: armModeOf(input.target, input.modes),
8120
+ availableModes: [...input.modes],
8121
+ openings: input.modes.map((mode) => modeOpenings(input, mode)),
8122
+ exclusions: input.exclusions.map((e) => ({
8123
+ deviceId: e.deviceId,
8124
+ name: input.name(e.deviceId),
8125
+ phase: e.phase
8126
+ }))
8127
+ };
8128
+ }
8129
+ //#endregion
7372
8130
  //#region src/notification-center/alerts/alert-store.ts
7373
8131
  /**
7374
8132
  * @durable class=ledger owner=notification-center
@@ -8063,6 +8821,52 @@ function maskPolygonOverlap(mask, maskWidth, maskHeight, bbox, polygon, _frameWi
8063
8821
  return insidePolygon / totalMaskPixels;
8064
8822
  }
8065
8823
  //#endregion
8824
+ //#region src/notification-center/alarm/alarm-opening-edge.ts
8825
+ /**
8826
+ * Which way an OPENING moved, read off the slice one sensor event carries
8827
+ * (D630 "Alarm rules fire on the opening edge").
8828
+ *
8829
+ * A rule that belongs to the alarm fires when an opening OPENS — a contact
8830
+ * opens, a cover leaves closed, a lock unlocks — and never when it closes.
8831
+ *
8832
+ * Stateless on purpose: one state change is N SensorEvent rows (one per linked
8833
+ * camera), and a "previous reading" tracker would call the first row an edge
8834
+ * and the other N−1 steady — camera 2's alarm rule would lose the event. The
8835
+ * slice already says which way the device went, and the transition gate
8836
+ * upstream (`sensor-transition-gate.ts`) only lets a row through when the level
8837
+ * (the boolean, or a cover's state WORD) actually changed.
8838
+ *
8839
+ * What is open and what is secure is NOT decided here: it is
8840
+ * {@link alarmOpeningFromSensorEvent}'s table, the same one the arm check
8841
+ * reads. This file adds only the DIRECTION of a word that table calls open:
8842
+ * a word travelling towards secure (`closing`, `locking`) is a closing edge,
8843
+ * a halt or a fault (`stopped`, `jammed`) is `neither` — not an entry, so an
8844
+ * alarm rule does not fire on it, though the arm check still counts it as open
8845
+ * (a different question: "not secure" vs "just opened").
8846
+ *
8847
+ * `undefined` = this kind has no opening meaning, or the slice is unreadable
8848
+ * (D49 — never guessed). The caller then does not filter at all.
8849
+ */
8850
+ /** Open words (per the openings table) that are travelling towards secure. */
8851
+ var TOWARD_SECURE = new Set(["closing", "locking"]);
8852
+ /** Open words that are a halt or a fault: neither an entry nor a close. */
8853
+ var HALTED = new Set(["stopped", "jammed"]);
8854
+ /** Open words that ARE the opening: leaving closed / leaving locked, or a contact apart. */
8855
+ var OPENING = new Set([
8856
+ "open",
8857
+ "opening",
8858
+ "unlocked",
8859
+ "unlocking"
8860
+ ]);
8861
+ function sensorEdgeOf(kind, value) {
8862
+ const opening = alarmOpeningFromSensorEvent(kind, value);
8863
+ if (opening.kind !== "reading") return void 0;
8864
+ if (opening.reading === "secure") return "closing";
8865
+ if (TOWARD_SECURE.has(opening.state)) return "closing";
8866
+ if (HALTED.has(opening.state)) return "neither";
8867
+ if (OPENING.has(opening.state)) return "opening";
8868
+ }
8869
+ //#endregion
8066
8870
  //#region src/notification-center/audio-rule-matcher.ts
8067
8871
  /**
8068
8872
  * AudioWatcher — the Notification Center's matcher for audio rules
@@ -8538,6 +9342,7 @@ function readSensorEventType(value) {
8538
9342
  */
8539
9343
  function subjectFromSensorEvent(ev, markerTrackId) {
8540
9344
  const eventType = readSensorEventType(ev.value);
9345
+ const sensorEdge = sensorEdgeOf(ev.kind, ev.value);
8541
9346
  return {
8542
9347
  kind: "device-event",
8543
9348
  recordId: String(ev.id),
@@ -8549,7 +9354,20 @@ function subjectFromSensorEvent(ev, markerTrackId) {
8549
9354
  zones: [],
8550
9355
  source: "sensor",
8551
9356
  sensorKind: ev.kind,
8552
- ...eventType !== void 0 ? { eventType } : {}
9357
+ ...eventType !== void 0 ? { eventType } : {},
9358
+ ...sensorEdge !== void 0 ? { sensorEdge } : {}
9359
+ };
9360
+ }
9361
+ /**
9362
+ * Build the subject for a sensor no camera takes a row for (D630 §6): the
9363
+ * sensor is its own device (`deviceId === sourceDeviceId`, so no
9364
+ * `sourceDeviceId` field) and there is no marker track — nothing owns a frame
9365
+ * of it. `linkedCameraIds` carries every camera linking it (see the field).
9366
+ */
9367
+ function subjectFromUnownedSensorEvent(ev, linkedCameraIds) {
9368
+ return {
9369
+ ...subjectFromSensorEvent(ev),
9370
+ linkedCameraIds: [...linkedCameraIds]
8553
9371
  };
8554
9372
  }
8555
9373
  /**
@@ -8895,6 +9713,37 @@ function matchesIdentityScope(subject, scope) {
8895
9713
  function ruleAwaitsRecognition(rule) {
8896
9714
  return rule.conditions.identities !== void 0 || rule.conditions.plates !== void 0;
8897
9715
  }
9716
+ var ARMED_WORD_PREFIX = "armed_";
9717
+ var ALARM_PANEL_CAP$1 = "alarm-panel";
9718
+ var ALARM_PANEL_TRIGGER_METHOD = "trigger";
9719
+ /**
9720
+ * A rule "belongs to the alarm" when its `deviceState` gate names ANY device's
9721
+ * `armed_*` word — CamStack's own panel or any other, e.g. an HA-mirrored
9722
+ * alarm panel — or when an ENABLED `onTrigger` sequence pulls the panel's
9723
+ * trigger itself. Such a rule fires on an opening's OPENING edge only: closing
9724
+ * a window while armed is not an intrusion (D630).
9725
+ *
9726
+ * This is NOT the arm check's coverage predicate (`alarm-mode-coverage.ts`):
9727
+ * it ignores which device the gate names, which mode, whether the rule is
9728
+ * enabled and what it is scoped to. It answers only "should this rule refuse
9729
+ * a closing edge", and erring wide there costs a closing notification nobody
9730
+ * gated on an armed word would have wanted anyway.
9731
+ *
9732
+ * Derived, never a stored flag: both rule editors prune condition keys to the
9733
+ * catalog on save, so a new key would be dropped by the first edit.
9734
+ */
9735
+ function ruleBelongsToAlarm(rule) {
9736
+ if ((rule.conditions.deviceState?.states ?? []).some((s) => s.trim().toLowerCase().startsWith(ARMED_WORD_PREFIX))) return true;
9737
+ 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));
9738
+ }
9739
+ /**
9740
+ * The edge an alarm rule refuses: a close, or a halt/fault (a cover stopped
9741
+ * after it already opened would otherwise fire twice; after a close it would
9742
+ * fire as if it had opened; a jammed lock is a fault, not an entry).
9743
+ */
9744
+ function isRefusedAlarmEdge(edge) {
9745
+ return edge === "closing" || edge === "neither";
9746
+ }
8898
9747
  /**
8899
9748
  * Does this rule stop firing on the object event and fire at TRACK CLOSE?
8900
9749
  *
@@ -9045,10 +9894,12 @@ function evaluateRule(rule, subject, deviceState, zoneOwners, sceneStates) {
9045
9894
  }
9046
9895
  const c = rule.conditions;
9047
9896
  if (c.deviceState !== void 0) {
9048
- const current = deviceState?.(c.deviceState.deviceId);
9049
- if (current === void 0) return fail("deviceState");
9050
- if (!toLowerSet(c.deviceState.states).has(current.trim().toLowerCase())) return fail("deviceState");
9897
+ const words = deviceState?.(c.deviceState.deviceId);
9898
+ if (words === void 0 || words.length === 0) return fail("deviceState");
9899
+ const wanted = toLowerSet(c.deviceState.states);
9900
+ if (!words.some((w) => wanted.has(w.trim().toLowerCase()))) return fail("deviceState");
9051
9901
  }
9902
+ if (subject.kind === "device-event" && isRefusedAlarmEdge(subject.sensorEdge) && ruleBelongsToAlarm(rule)) return fail("sensorEdge");
9052
9903
  if (c.scene !== void 0) {
9053
9904
  const observation = sceneStates?.(c.scene.sceneId);
9054
9905
  if (observation === void 0) return fail("scene");
@@ -9473,6 +10324,26 @@ function incomingFromSensorEvent(event, markerTrackId) {
9473
10324
  };
9474
10325
  }
9475
10326
  /**
10327
+ * A sensor no camera takes a timeline row for (D630 §6) — evaluated, never
10328
+ * persisted. The sensor is its own device and there is no marker: a
10329
+ * text-only notification is the design here, not a degradation.
10330
+ */
10331
+ function incomingFromUnownedSensorEvent(event, linkedCameraIds) {
10332
+ return {
10333
+ subject: subjectFromUnownedSensorEvent(event, linkedCameraIds),
10334
+ kind: "device-event",
10335
+ origin: "sensor",
10336
+ log: () => ({
10337
+ tags: { deviceId: event.deviceId },
10338
+ meta: {
10339
+ sensorEventId: event.id,
10340
+ kind: event.kind,
10341
+ linkedCameraIds: [...linkedCameraIds]
10342
+ }
10343
+ })
10344
+ };
10345
+ }
10346
+ /**
9476
10347
  * Audio-event persist (classified episode).
9477
10348
  *
9478
10349
  * `stillOwnerId` is the audio still shelf's owner id for the photograph taken
@@ -10386,7 +11257,8 @@ function unique(values) {
10386
11257
  * therefore keeps the alarm's own sentence, which is also what every row
10387
11258
  * enqueued before this deploy carries.
10388
11259
  */
10389
- function combinedAlarmText(source) {
11260
+ function combinedAlarmText(kind, source) {
11261
+ if (kind !== "alarm-triggered") return null;
10390
11262
  if (source?.className === void 0) return null;
10391
11263
  return {
10392
11264
  titleKey: "alarm.combined.title",
@@ -10394,6 +11266,43 @@ function combinedAlarmText(source) {
10394
11266
  };
10395
11267
  }
10396
11268
  /**
11269
+ * The title and body keys an alarm row carries — ONE function, so the
11270
+ * catalog spec (`alarm-texts.spec.ts`) enumerates exactly what the intake can
11271
+ * select.
11272
+ *
11273
+ * VARIANT SELECTION AT INTAKE, like every other system sentence: the mode is
11274
+ * part of the words ("Away armed"), not a fragment substituted into them,
11275
+ * because the catalog renders in ONE pass and a `{{mode}}` filled with `away`
11276
+ * would deliver the enum value to a person.
11277
+ */
11278
+ function alarmTextKeys(transition, body) {
11279
+ const { kind, source } = transition;
11280
+ const combined = combinedAlarmText(kind, source);
11281
+ if (combined !== null) return combined;
11282
+ const titleKey = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
11283
+ if (body !== void 0) return {
11284
+ titleKey,
11285
+ bodyKey: body.bodyKey
11286
+ };
11287
+ if (kind === "alarm-sensor-bypass" && transition.sensor !== void 0) return {
11288
+ titleKey,
11289
+ bodyKey: `system.${kind}.body.${transition.sensor.event}`
11290
+ };
11291
+ if (kind === "alarm-disarmed" && (transition.previous === "triggered" || transition.previous === "pending")) return {
11292
+ titleKey,
11293
+ bodyKey: `system.${kind}.body.defused`
11294
+ };
11295
+ return {
11296
+ titleKey,
11297
+ bodyKey: source !== void 0 && (kind === "alarm-triggered" || kind === "alarm-pending") ? `system.${kind}.body.rule` : `system.${kind}.body`
11298
+ };
11299
+ }
11300
+ /** Whose event the row is — see {@link incomingFromAlarmTransition}. */
11301
+ function alarmSubjectDevice(transition, panelDeviceId) {
11302
+ if (transition.kind === "alarm-sensor-bypass" && transition.sensor !== void 0) return transition.sensor.deviceId;
11303
+ return transition.source?.deviceId ?? panelDeviceId;
11304
+ }
11305
+ /**
10397
11306
  * The panel's own transition, as a system-event subject.
10398
11307
  *
10399
11308
  * IN-PROCESS, not off the bus. The alarm and the Notification Center live in
@@ -10409,42 +11318,53 @@ function combinedAlarmText(source) {
10409
11318
  * With one, it is about the CAMERA that set it off: that is the media owner,
10410
11319
  * the name the body reads out ("ALLARME — Ingresso"), and the device a rule
10411
11320
  * scoping `deviceIds` means. One sentence, and both halves of it are testable.
11321
+ * A SENSOR BYPASS is about the sensor — its name is the title.
10412
11322
  */
10413
11323
  function incomingFromAlarmTransition(input) {
10414
11324
  const { transition, panelDeviceId } = input;
10415
11325
  const source = transition.source;
10416
- const deviceId = source?.deviceId ?? panelDeviceId;
11326
+ const deviceId = alarmSubjectDevice(transition, panelDeviceId);
10417
11327
  const identity = input.lookup(deviceId);
10418
11328
  const kind = transition.kind;
10419
- const combined = combinedAlarmText(source);
11329
+ const keys = alarmTextKeys(transition, input.body);
11330
+ const sensor = transition.sensor;
11331
+ const alarmSource = kind === "alarm-triggered" ? source : void 0;
11332
+ const facts = {
11333
+ eventId: `alarm:${kind}:${String(transition.at)}${sensor !== void 0 ? `:${String(sensor.deviceId)}` : ""}${transition.refusalId !== void 0 ? `:${transition.refusalId}` : ""}`,
11334
+ timestamp: transition.at,
11335
+ category: `alarm.${kind.slice(6)}`,
11336
+ data: {
11337
+ panelDeviceId,
11338
+ state: transition.state,
11339
+ previous: transition.previous,
11340
+ ...transition.mode !== void 0 ? { mode: transition.mode } : {},
11341
+ ...source !== void 0 ? { sourceRuleId: source.ruleId } : {},
11342
+ ...transition.refusalId !== void 0 ? { refusalId: transition.refusalId } : {},
11343
+ ...sensor !== void 0 ? {
11344
+ sensorDeviceId: sensor.deviceId,
11345
+ event: sensor.event
11346
+ } : {}
11347
+ }
11348
+ };
11349
+ const deviceParams = deviceTextParams(deviceId, identity);
10420
11350
  return systemSubject({
10421
- facts: {
10422
- eventId: `alarm:${kind}:${String(transition.at)}`,
10423
- timestamp: transition.at,
10424
- category: `alarm.${kind.slice(6)}`,
10425
- data: {
10426
- panelDeviceId,
10427
- state: transition.state,
10428
- previous: transition.previous,
10429
- ...transition.mode !== void 0 ? { mode: transition.mode } : {},
10430
- ...source !== void 0 ? { sourceRuleId: source.ruleId } : {}
10431
- }
10432
- },
11351
+ facts,
10433
11352
  kind,
10434
11353
  subject: `alarm:${String(panelDeviceId)}`,
10435
- titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
10436
- bodyKey: combined?.bodyKey ?? input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
11354
+ titleKey: keys.titleKey,
11355
+ bodyKey: keys.bodyKey,
10437
11356
  textParams: {
10438
- ...deviceTextParams(deviceId, identity),
11357
+ ...deviceParams,
11358
+ ...sensor?.name !== void 0 && identity === void 0 ? { deviceName: sensor.name } : {},
10439
11359
  ...transition.mode !== void 0 ? { mode: transition.mode } : {},
10440
11360
  ...source !== void 0 ? { rule: source.ruleName } : {},
10441
- ...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
11361
+ ...kind === "alarm-arming" || kind === "alarm-pending" ? { seconds: `${transition.seconds ?? 0}` } : {},
10442
11362
  ...input.body?.textParams
10443
11363
  },
10444
11364
  deviceId,
10445
11365
  ...identity !== void 0 ? { deviceType: identity.type } : {},
10446
11366
  ...input.body !== void 0 ? { textCount: input.body.textCount } : {},
10447
- ...source !== void 0 ? { alarmSource: source } : {}
11367
+ ...alarmSource !== void 0 ? { alarmSource } : {}
10448
11368
  });
10449
11369
  }
10450
11370
  /** Re-stamp a live-producer envelope as a boot-reconcile replay of the same
@@ -11402,7 +12322,8 @@ var en_default = {
11402
12322
  "alarm.arming.body.skipped": "Excluded: {{names}}",
11403
12323
  "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
11404
12324
  "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
11405
- "alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
12325
+ "alarm.armRefused.body": "Open: {{names}}",
12326
+ "alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
11406
12327
  "alarm.device.unnamed": "Device {{id}}",
11407
12328
  "alarm.combined.title": "Alarm: {{deviceName}}",
11408
12329
  "system.alarm-arm-refused.title.home": "Arming home refused",
@@ -11419,15 +12340,33 @@ var en_default = {
11419
12340
  "system.alarm-armed.body": "Alarm armed",
11420
12341
  "system.alarm-disarmed.title": "Disarmed",
11421
12342
  "system.alarm-disarmed.body": "Alarm disarmed",
12343
+ "system.alarm-disarmed.body.defused": "Alarm disarmed during an alarm",
11422
12344
  "system.alarm-triggered.title": "ALARM",
11423
12345
  "system.alarm-triggered.body": "Alarm triggered",
11424
12346
  "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
12347
+ "system.alarm-pending.title.home": "Intrusion — home, {{seconds}}s to disarm",
12348
+ "system.alarm-pending.title.away": "Intrusion — away, {{seconds}}s to disarm",
12349
+ "system.alarm-pending.title.night": "Intrusion — night, {{seconds}}s to disarm",
12350
+ "system.alarm-pending.body": "Entry delay started",
12351
+ "system.alarm-pending.body.rule": "Started by {{rule}} on {{deviceName}}",
12352
+ "system.alarm-rearmed.title.home": "Re-armed: home",
12353
+ "system.alarm-rearmed.title.away": "Re-armed: away",
12354
+ "system.alarm-rearmed.title.night": "Re-armed: night",
12355
+ "system.alarm-rearmed.body": "The siren ended and the alarm is armed again",
12356
+ "system.alarm-sensor-bypass.title": "{{deviceName}}",
12357
+ "system.alarm-sensor-bypass.body.ignored": "Ignored: it was open when the alarm was armed",
12358
+ "system.alarm-sensor-bypass.body.lifted": "Monitored again: it closed and opened",
12359
+ "system.alarm-check-failed.title.home": "Armed home without checking openings",
12360
+ "system.alarm-check-failed.title.away": "Armed away without checking openings",
12361
+ "system.alarm-check-failed.title.night": "Armed night without checking openings",
12362
+ "system.alarm-check-failed.body": "The device states could not be read",
11425
12363
  "button.snooze.minutes": "Snooze {{minutes}} min",
11426
12364
  "button.snooze.hours": {
11427
12365
  "one": "Snooze {{hours}} hour",
11428
12366
  "other": "Snooze {{hours}} hours"
11429
12367
  },
11430
12368
  "button.alarm.disarm": "Disarm",
12369
+ "button.alarm.armAnyway": "Arm anyway",
11431
12370
  "button.alarm.arm.home": "Arm home",
11432
12371
  "button.alarm.arm.away": "Arm away",
11433
12372
  "button.alarm.arm.night": "Arm night",
@@ -11671,7 +12610,8 @@ var it_default = {
11671
12610
  "alarm.arming.body.skipped": "Esclusi: {{names}}",
11672
12611
  "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
11673
12612
  "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
11674
- "alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
12613
+ "alarm.armRefused.body": "Aperti: {{names}}",
12614
+ "alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
11675
12615
  "alarm.device.unnamed": "Dispositivo {{id}}",
11676
12616
  "alarm.combined.title": "Allarme: {{deviceName}}",
11677
12617
  "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
@@ -11688,15 +12628,33 @@ var it_default = {
11688
12628
  "system.alarm-armed.body": "Allarme inserito",
11689
12629
  "system.alarm-disarmed.title": "Disinserito",
11690
12630
  "system.alarm-disarmed.body": "Allarme disinserito",
12631
+ "system.alarm-disarmed.body.defused": "Allarme disinserito durante un allarme",
11691
12632
  "system.alarm-triggered.title": "ALLARME",
11692
12633
  "system.alarm-triggered.body": "Allarme scattato",
11693
12634
  "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
12635
+ "system.alarm-pending.title.home": "Intrusione — Casa, {{seconds}}s per disinserire",
12636
+ "system.alarm-pending.title.away": "Intrusione — Fuori, {{seconds}}s per disinserire",
12637
+ "system.alarm-pending.title.night": "Intrusione — Notte, {{seconds}}s per disinserire",
12638
+ "system.alarm-pending.body": "Ritardo di ingresso avviato",
12639
+ "system.alarm-pending.body.rule": "Avviato da {{rule}} su {{deviceName}}",
12640
+ "system.alarm-rearmed.title.home": "Reinserito: Casa",
12641
+ "system.alarm-rearmed.title.away": "Reinserito: Fuori",
12642
+ "system.alarm-rearmed.title.night": "Reinserito: Notte",
12643
+ "system.alarm-rearmed.body": "La sirena è terminata e l'allarme è di nuovo inserito",
12644
+ "system.alarm-sensor-bypass.title": "{{deviceName}}",
12645
+ "system.alarm-sensor-bypass.body.ignored": "Ignorato: era aperto all'inserimento",
12646
+ "system.alarm-sensor-bypass.body.lifted": "Di nuovo sorvegliato: si è chiuso e riaperto",
12647
+ "system.alarm-check-failed.title.home": "Inserito Casa senza controllare le aperture",
12648
+ "system.alarm-check-failed.title.away": "Inserito Fuori senza controllare le aperture",
12649
+ "system.alarm-check-failed.title.night": "Inserito Notte senza controllare le aperture",
12650
+ "system.alarm-check-failed.body": "Non è stato possibile leggere lo stato dei dispositivi",
11694
12651
  "button.snooze.minutes": "Silenzia {{minutes}} min",
11695
12652
  "button.snooze.hours": {
11696
12653
  "one": "Silenzia {{hours}} ora",
11697
12654
  "other": "Silenzia {{hours}} ore"
11698
12655
  },
11699
12656
  "button.alarm.disarm": "Disarma",
12657
+ "button.alarm.armAnyway": "Arma comunque",
11700
12658
  "button.alarm.arm.home": "Arma casa",
11701
12659
  "button.alarm.arm.away": "Arma fuori",
11702
12660
  "button.alarm.arm.night": "Arma notte",
@@ -16321,6 +17279,8 @@ var OUTBOX_RECORD_KINDS = new Set([
16321
17279
  function isOutboxRecordKind(x) {
16322
17280
  return typeof x === "string" && OUTBOX_RECORD_KINDS.has(x);
16323
17281
  }
17282
+ /** A stored row back to an entry — a guard, never a cast. Exported for the
17283
+ * specs that read the store a centre wrote to. */
16324
17284
  function rowToEntry$1(id, data) {
16325
17285
  const ruleId = data["ruleId"];
16326
17286
  const targetId = data["targetId"];
@@ -23169,6 +24129,26 @@ var TIMELAPSE_LOOKAHEAD_MS = 9 * 864e5;
23169
24129
  */
23170
24130
  var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
23171
24131
  /**
24132
+ * A rule's media policy, as the fields a row freezes — ONE derivation, used by
24133
+ * every row a rule addresses AND by the alarm's own announcement row when it is
24134
+ * the COMBINED siren (the triggering rule's pictures on the alarm's words).
24135
+ */
24136
+ function mediaPolicyFields(mediaRule, cropZoneIds) {
24137
+ const media = mediaRule.media;
24138
+ return {
24139
+ media: media.attach,
24140
+ ...media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
24141
+ ...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
24142
+ ...media.frame !== void 0 ? { mediaFrame: media.frame } : {},
24143
+ ...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
24144
+ ...media.gif === true ? { mediaGif: true } : {},
24145
+ ...media.clip === true ? { mediaClip: true } : {},
24146
+ ...media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: media.clipPreRollSec } : {},
24147
+ ...media.clipSpeed !== void 0 ? { mediaClipSpeed: media.clipSpeed } : {},
24148
+ ...media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: media.clipPostRollSec } : {}
24149
+ };
24150
+ }
24151
+ /**
23172
24152
  * Synthetic rule id for an export's on-screen picker.
23173
24153
  *
23174
24154
  * The operator named these targets on the export screen, not in a rule. The
@@ -23186,10 +24166,35 @@ var NC_EXPORT_NOTIFY_RULE_ID = "nc-export-notify";
23186
24166
  * notification that says the alarm was disarmed is worse than offering nothing.
23187
24167
  */
23188
24168
  function alarmButtonKind(kind) {
23189
- if (kind === "alarm-triggered" || kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-disarmed") return kind;
23190
- return null;
24169
+ switch (kind) {
24170
+ case "alarm-triggered":
24171
+ case "alarm-armed":
24172
+ case "alarm-arming":
24173
+ case "alarm-disarmed":
24174
+ case "alarm-pending":
24175
+ case "alarm-rearmed":
24176
+ case "alarm-check-failed":
24177
+ case "alarm-arm-refused":
24178
+ case "alarm-sensor-bypass": return kind;
24179
+ default: return null;
24180
+ }
24181
+ }
24182
+ /** The refusal id an `alarm-arm-refused` row froze (`incomingFromAlarmTransition`). */
24183
+ function rowRefusalId(systemEvent) {
24184
+ const value = systemEvent?.data["refusalId"];
24185
+ return typeof value === "string" && value.length > 0 ? value : void 0;
23191
24186
  }
23192
24187
  /**
24188
+ * What a pressed Arm anyway that did nothing tells the person holding the
24189
+ * phone — one sentence per reason the panel refused it.
24190
+ */
24191
+ var ARM_ANYWAY_REJECTION_COPY = {
24192
+ stale: "The alarm changed since this notification — nothing was armed",
24193
+ expired: "This button expired — arm again from the app",
24194
+ used: "Already used",
24195
+ "unknown-refusal": "This refusal is no longer current — arm again"
24196
+ };
24197
+ /**
23193
24198
  * What the sustained-state alerts run on when nothing wired the settings in.
23194
24199
  *
23195
24200
  * The SHIPPED DEFAULTS, not "off". A wiring that silently disabled the feature
@@ -23293,7 +24298,6 @@ function consumablesSliceOf(event) {
23293
24298
  */
23294
24299
  var NC_ALARM_SETTINGS_FALLBACK = {
23295
24300
  ...DEFAULT_DELAYS,
23296
- announceArm: false,
23297
24301
  announceTargets: [],
23298
24302
  nonBlocking: []
23299
24303
  };
@@ -23403,47 +24407,6 @@ function historyRecordKind(kind) {
23403
24407
  return kind;
23404
24408
  }
23405
24409
  /**
23406
- * The system-event half of a history row, in the CAP's shape.
23407
- *
23408
- * The cap contract (`NcHistoryEntry`, frozen in `@camstack/types`) wants a
23409
- * title and a body, and a row written since the text catalog carries keys
23410
- * instead. So history resolves them through the SAME catalog the delivery used
23411
- * — history must read as what the operator was actually sent, and a page that
23412
- * showed a key while the phone showed a sentence would be a second vocabulary
23413
- * to keep in step. A pre-catalog row falls back to the pair frozen on it.
23414
- */
23415
- /**
23416
- * Cap-facing downgrade of a system-event kind — the SAME device as
23417
- * {@link historyRecordKind}, and it exists for the same reason.
23418
- *
23419
- * This addon bundles its own `@camstack/types` (self-contained externals), so
23420
- * it can emit a kind the enum in the RUNNING SERVER does not have. The history
23421
- * router does not: it validates its output against the host's
23422
- * `NcHistorySubjectSchema`, whose `kind` is a `z.enum`. One row carrying a kind
23423
- * the host has never heard of therefore does not degrade — it fails the whole
23424
- * `getHistory` call, for every row, exactly as the enum's own legacy-tail note
23425
- * warns.
23426
- *
23427
- * `alarm-arming` is projected onto `alarm-armed`, its nearest member: same
23428
- * panel, same lifecycle, adjacent instant. Nothing a person reads changes —
23429
- * the row's title and body are resolved from the arming keys ("Inserimento
23430
- * Fuori — attivo tra 30 secondi") — only the machine-facing word does.
23431
- *
23432
- * DELETE THIS when a `@camstack/server` closure carrying `alarm-arming` in
23433
- * `NcSystemEventKindSchema` is installed everywhere this addon runs. Until
23434
- * then it is what lets the exit-delay notification ship without a train.
23435
- */
23436
- /**
23437
- * The mirrored device-state word, narrowed to a contact reading.
23438
- *
23439
- * Anything that is not one of the two contact words is `undefined` — a device
23440
- * whose slice the state table read as `on` / `armed_home` is not a contact, and
23441
- * an exclusion must never be lifted by a word that belongs to another cap.
23442
- */
23443
- function contactReadingOf(state) {
23444
- if (state === "open" || state === "closed") return state;
23445
- }
23446
- /**
23447
24410
  * The subject kind a persisted record kind maps to.
23448
24411
  *
23449
24412
  * `NcOutboxRecordKind` carries two kinds a rule subject never has —
@@ -23481,11 +24444,16 @@ function cooldownSubjectFromOutbox(entry) {
23481
24444
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
23482
24445
  };
23483
24446
  }
23484
- function historySystemEventKind(kind) {
23485
- if (kind === "alarm-arming") return "alarm-armed";
23486
- if (kind === "alarm-arm-refused") return "alarm-disarmed";
23487
- return kind;
23488
- }
24447
+ /**
24448
+ * The system-event half of a history row, in the CAP's shape.
24449
+ *
24450
+ * The cap contract (`NcHistoryEntry`, frozen in `@camstack/types`) wants a
24451
+ * title and a body, and a row written since the text catalog carries keys
24452
+ * instead. So history resolves them through the SAME catalog the delivery used
24453
+ * — history must read as what the operator was actually sent, and a page that
24454
+ * showed a key while the phone showed a sentence would be a second vocabulary
24455
+ * to keep in step. A pre-catalog row falls back to the pair frozen on it.
24456
+ */
23489
24457
  function historySystemEvent(texts, systemEvent) {
23490
24458
  const vars = systemEvent.textParams ?? {};
23491
24459
  const count = systemEvent.textCount;
@@ -23498,7 +24466,7 @@ function historySystemEvent(texts, systemEvent) {
23498
24466
  return frozen ?? "";
23499
24467
  };
23500
24468
  return {
23501
- kind: historySystemEventKind(systemEvent.kind),
24469
+ kind: systemEvent.kind,
23502
24470
  subject: systemEvent.subject,
23503
24471
  title: resolve(systemEvent.titleKey, systemEvent.title),
23504
24472
  body: resolve(systemEvent.bodyKey, systemEvent.body),
@@ -23686,6 +24654,13 @@ var NotificationCenter = class NotificationCenter {
23686
24654
  audioEnabled = false;
23687
24655
  /** The panel this node owns, or null. See {@link NcAlarmPanelPort}. */
23688
24656
  alarmPanel = null;
24657
+ /**
24658
+ * The words the panel answers the `deviceState` gate with — its state, plus
24659
+ * the armed mode a `pending`/`triggered` belongs to (D630). Refreshed on
24660
+ * every publish, next to the one-word mirror; the lookup only uses them
24661
+ * while the mirror agrees on the state (`panelAwareLookup`).
24662
+ */
24663
+ panelGateWords = null;
23689
24664
  /** In-memory cooldown map — seeded from persisted outbox rows on start. */
23690
24665
  lastFiredAt = /* @__PURE__ */ new Map();
23691
24666
  /**
@@ -23783,11 +24758,12 @@ var NotificationCenter = class NotificationCenter {
23783
24758
  async mintButtons(input) {
23784
24759
  const mint = this.deps.mintActionUrl;
23785
24760
  if (mint === void 0) return [];
23786
- const rule = this.rules.get(input.ruleId);
23787
- if (rule === null) return [];
24761
+ const stored = this.rules.get(input.ruleId);
24762
+ const announcement = stored === null && input.ruleId === "nc-alarm-announce";
24763
+ if (stored === null && !announcement) return [];
23788
24764
  const drafts = [];
23789
- const sequenceButtons = this.actionRunner === null ? [] : buildActionButtons({
23790
- actions: rule.actions,
24765
+ const sequenceButtons = this.actionRunner === null || stored === null ? [] : buildActionButtons({
24766
+ actions: stored.actions,
23791
24767
  mintUrl: ({ sequence, actionId }) => {
23792
24768
  const id = randomUUID();
23793
24769
  drafts.push({
@@ -23808,8 +24784,8 @@ var NotificationCenter = class NotificationCenter {
23808
24784
  return id;
23809
24785
  }
23810
24786
  });
23811
- const snoozeButtons = input.ownerUserId === void 0 ? [] : buildSnoozeButtons({
23812
- rule,
24787
+ const snoozeButtons = input.ownerUserId === void 0 || stored === null ? [] : buildSnoozeButtons({
24788
+ rule: stored,
23813
24789
  label: (minutes) => this.snoozeButtonLabel(minutes),
23814
24790
  mintUrl: ({ actionId, minutes }) => {
23815
24791
  const id = randomUUID();
@@ -23836,36 +24812,63 @@ var NotificationCenter = class NotificationCenter {
23836
24812
  });
23837
24813
  const alarmKind = alarmButtonKind(input.systemEvent?.kind);
23838
24814
  const panel = this.alarmPanel;
24815
+ const refusalId = rowRefusalId(input.systemEvent);
24816
+ if (alarmKind === "alarm-arm-refused" && refusalId === void 0) this.logger.debug("refusal notification carries no Arm anyway — the row names no refusal", {
24817
+ tags: { deviceId: input.deviceId },
24818
+ meta: {
24819
+ ruleId: input.ruleId,
24820
+ targetId: input.targetId
24821
+ }
24822
+ });
23839
24823
  const alarmButtons = alarmKind === null ? [] : buildAlarmButtons({
23840
24824
  kind: alarmKind,
23841
24825
  panel: panel === null ? null : {
23842
24826
  deviceId: panel.deviceId,
23843
24827
  availableModes: panel.availableModes()
23844
24828
  },
24829
+ ...refusalId !== void 0 ? { refusalId } : {},
23845
24830
  labels: {
23846
24831
  disarm: this.texts.text({ key: "button.alarm.disarm" }),
23847
- arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` })
24832
+ arm: (mode) => this.texts.text({ key: `button.alarm.arm.${mode}` }),
24833
+ armAnyway: this.texts.text({ key: "button.alarm.armAnyway" })
23848
24834
  },
23849
- mintUrl: ({ actionId, action, mode }) => {
24835
+ mintUrl: (button) => {
23850
24836
  const id = randomUUID();
23851
- const panelDeviceId = panel?.deviceId ?? 0;
24837
+ const common = {
24838
+ kind: "alarm",
24839
+ id,
24840
+ panelDeviceId: panel?.deviceId ?? 0,
24841
+ deviceId: input.deviceId,
24842
+ actionId: button.actionId,
24843
+ targetId: input.targetId
24844
+ };
23852
24845
  drafts.push({
23853
24846
  id,
23854
24847
  ttlMs: actionTtlMs({
23855
24848
  kind: "alarm",
23856
- action
24849
+ action: button.action
23857
24850
  }),
23858
- grant: (expiresAt) => ({
23859
- kind: "alarm",
23860
- id,
23861
- action,
23862
- ...mode !== void 0 ? { mode } : {},
23863
- panelDeviceId,
23864
- deviceId: input.deviceId,
23865
- actionId,
23866
- targetId: input.targetId,
23867
- expiresAt
23868
- })
24851
+ grant: (expiresAt) => {
24852
+ switch (button.action) {
24853
+ case "arm-anyway": return {
24854
+ ...common,
24855
+ action: "arm-anyway",
24856
+ refusalId: button.refusalId,
24857
+ expiresAt
24858
+ };
24859
+ case "arm": return {
24860
+ ...common,
24861
+ action: "arm",
24862
+ mode: button.mode,
24863
+ expiresAt
24864
+ };
24865
+ case "disarm": return {
24866
+ ...common,
24867
+ action: "disarm",
24868
+ expiresAt
24869
+ };
24870
+ }
24871
+ }
23869
24872
  });
23870
24873
  return id;
23871
24874
  }
@@ -24047,10 +25050,11 @@ var NotificationCenter = class NotificationCenter {
24047
25050
  /** Arm or disarm the panel a button granted. Throws when the panel is gone —
24048
25051
  * the plane turns that into the page that says so. */
24049
25052
  async applyGrantedAlarm(grant) {
24050
- if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
24051
25053
  const panel = this.alarmPanel;
24052
25054
  if (panel === null) throw new Error("this node has no alarm panel");
24053
25055
  if (panel.deviceId !== grant.panelDeviceId) throw new Error("this button was minted for a different alarm panel");
25056
+ if (grant.action === "arm-anyway") return this.applyGrantedArmAnyway(panel, grant);
25057
+ if (this.deps.actuate === void 0) throw new Error("no actuator is wired on this node");
24054
25058
  await this.actuateWithAttribution({
24055
25059
  deviceId: panel.deviceId,
24056
25060
  cap: ALARM_PANEL_CAP,
@@ -24058,6 +25062,34 @@ var NotificationCenter = class NotificationCenter {
24058
25062
  args: grant.action === "arm" && grant.mode !== void 0 ? { mode: grant.mode } : {}
24059
25063
  });
24060
25064
  }
25065
+ /**
25066
+ * The Arm anyway on a refusal notification — IN-PROCESS, `armAnyway(refusalId)`,
25067
+ * never the cap: `alarm-panel.arm` has no field for a refusal, and a plain
25068
+ * arm would simply be refused again.
25069
+ *
25070
+ * The token claim is already atomic and single-use; the panel adds its own
25071
+ * one-shot on the refusal, so a second phone's copy of the same button is
25072
+ * `used`. Every way it does nothing throws a sentence the page shows.
25073
+ */
25074
+ async applyGrantedArmAnyway(panel, grant) {
25075
+ const outcome = await panel.armAnyway(grant.refusalId);
25076
+ switch (outcome.outcome) {
25077
+ case "rejected": throw new NcActionRefusedError(ARM_ANYWAY_REJECTION_COPY[outcome.reason]);
25078
+ case "refused": throw new NcActionRefusedError("Something else is open now — see the new notification");
25079
+ case "arming":
25080
+ case "armed":
25081
+ this.logger.info("alarm armed from a refusal notification (Arm anyway)", {
25082
+ tags: { deviceId: panel.deviceId },
25083
+ meta: {
25084
+ refusalId: grant.refusalId,
25085
+ outcome: outcome.outcome,
25086
+ excluded: outcome.excluded.length,
25087
+ targetId: grant.targetId ?? null
25088
+ }
25089
+ });
25090
+ return;
25091
+ }
25092
+ }
24061
25093
  /** The registry the action plane claims against. */
24062
25094
  get actionTokenRegistry() {
24063
25095
  return this.actionTokens;
@@ -25117,17 +26149,38 @@ var NotificationCenter = class NotificationCenter {
25117
26149
  * before it.
25118
26150
  */
25119
26151
  onSensorEventPersisted(event, markerTrackId) {
25120
- this.observeContactEvent(event);
26152
+ this.observeOpeningEvent(event);
25121
26153
  this.consumeEvent(incomingFromSensorEvent(event, markerTrackId));
25122
26154
  }
25123
- /** The contact reading a sensor row carries, handed to the panel. Nothing at
25124
- * all for any other sensor kind — see {@link observeAlarmContacts}. */
25125
- observeContactEvent(event) {
26155
+ /**
26156
+ * Called for a sensor state change no camera takes a timeline row for
26157
+ * (`ingestSensorStateChange`'s `onUnowned`, D630 §6): linked to no camera
26158
+ * (`linkedCameraIds` = []), or linked only by cameras that did not opt it
26159
+ * into tracked events (`linkedCameraIds` = those cameras). Nothing was
26160
+ * persisted, but the event is evaluated exactly like a persisted one, so a
26161
+ * contact rule fires and the contact reaches the alarm panel. The sensor is
26162
+ * its own device, and the notification is text-only (no marker).
26163
+ *
26164
+ * A separate door from {@link onSensorEventPersisted} because the SUBJECT
26165
+ * differs, not the path: both feed the panel first and then the same
26166
+ * evaluation. Same lossy boundary: no row, so no reconcile.
26167
+ */
26168
+ onUnownedSensorEvent(event, linkedCameraIds) {
26169
+ this.observeOpeningEvent(event);
26170
+ this.consumeEvent(incomingFromUnownedSensorEvent(event, linkedCameraIds));
26171
+ }
26172
+ /**
26173
+ * The opening reading a sensor row carries — a contact, a cover or a lock,
26174
+ * read with D630's table (`alarmOpeningFromSensorEvent`) — handed to the
26175
+ * panel. A row that is not an opening, or whose word is unknown, hands over
26176
+ * nothing: an unreadable state must never move an exclusion (D49).
26177
+ */
26178
+ observeOpeningEvent(event) {
25126
26179
  const panel = this.alarmPanel;
25127
- if (panel === null || event.kind !== "contact") return;
25128
- const entryOpen = event.value?.["entryOpen"];
25129
- if (typeof entryOpen !== "boolean") return;
25130
- panel.observeContact(event.sourceDeviceId, entryOpen ? "open" : "closed", event.timestamp);
26180
+ if (panel === null) return;
26181
+ const opening = alarmOpeningFromSensorEvent(event.kind, event.value);
26182
+ if (opening.kind !== "reading") return;
26183
+ panel.observeOpening(event.sourceDeviceId, opening.reading, event.timestamp);
25131
26184
  }
25132
26185
  /**
25133
26186
  * Called at the AUDIO-event persist site (`eventStore.insertAudio`), in the
@@ -25479,22 +26532,7 @@ var NotificationCenter = class NotificationCenter {
25479
26532
  return { success: true };
25480
26533
  },
25481
26534
  getAlarmConfig: async () => this.alarmConfig(),
25482
- setAlarmConfig: async ({ patch }) => {
25483
- const panel = this.alarmPanel;
25484
- if (panel === null) throw new Error("this node has no alarm panel");
25485
- const settings = await panel.applySettings(patch);
25486
- this.logger.info("alarm settings changed", {
25487
- tags: { deviceId: panel.deviceId },
25488
- meta: {
25489
- exitDelaySec: settings.exitDelaySec,
25490
- entryDelaySec: settings.entryDelaySec,
25491
- triggeredDurationSec: settings.triggeredDurationSec,
25492
- announceArm: settings.announceArm,
25493
- announceTargets: settings.announceTargets.length
25494
- }
25495
- });
25496
- return this.alarmConfig();
25497
- }
26535
+ setAlarmConfig: async ({ patch }) => this.setAlarmConfig(patch)
25498
26536
  };
25499
26537
  }
25500
26538
  /**
@@ -25504,66 +26542,96 @@ var NotificationCenter = class NotificationCenter {
25504
26542
  */
25505
26543
  setAlarmPanel(panel) {
25506
26544
  this.alarmPanel = panel;
26545
+ if (panel === null || this.panelGateWords?.deviceId !== panel.deviceId) this.panelGateWords = null;
26546
+ }
26547
+ /**
26548
+ * The `deviceState` lookup the engine gets — live path and dry-run alike, so
26549
+ * the tester predicts what the saved rule will do. Every device answers its
26550
+ * one word from `read`; the panel this node owns also answers the armed mode
26551
+ * its `pending`/`triggered` belongs to (D630).
26552
+ */
26553
+ deviceStateLookup(read) {
26554
+ return panelAwareLookup(read, this.panelGateWords);
25507
26555
  }
25508
26556
  /**
25509
26557
  * What the panel asks before it arms — see {@link NcAlarmArmGuardHook}.
25510
26558
  *
25511
26559
  * Handed to the panel by the same wiring that hands it the publish hook: the
25512
26560
  * panel owns the state machine, the centre owns the rules that say which
25513
- * contacts a mode covers, and neither can reach the other on its own.
26561
+ * devices a mode covers, and neither can reach the other on its own.
25514
26562
  */
25515
26563
  alarmArmGuard() {
25516
26564
  return {
25517
- openContacts: (mode) => this.alarmOpenContacts(mode),
25518
- announceRefusal: (input) => this.announceArmRefused(input)
26565
+ openings: (mode) => this.alarmOpenings(mode),
26566
+ announceRefusal: (input) => this.announceArmRefused(input),
26567
+ announceCheckFailed: (input) => this.announceArmCheckFailed(input)
25519
26568
  };
25520
26569
  }
25521
26570
  /**
25522
- * Which of the contacts this mode covers read OPEN, right now.
26571
+ * Which of the devices this mode covers are OPEN right now, and which could
26572
+ * not be read (D630).
25523
26573
  *
25524
- * A TARGETED read of at most the handful of sensors the mode names, not a
25525
- * refresh of the whole watched set: the arm is a person pressing a button and
25526
- * the answer has to be about this second, but it must not become a fan-out
25527
- * over every gated device on the hub.
26574
+ * A TARGETED read of at most the handful of devices the mode's rules name,
26575
+ * not a refresh of the whole watched set: the arm is a person pressing a
26576
+ * button and the answer has to be about this second, but it must not become
26577
+ * a fan-out over every gated device on the hub. Read through
26578
+ * `readDeviceOpenings`, so a cover or a lock that is not secure is open too,
26579
+ * and a camera an intrusion rule names is `not-opening` and takes no part.
25528
26580
  *
25529
- * Three fail-open rules, and all three exist because refusing an arm on
25530
- * ignorance is worse than arming:
26581
+ * Never a guess, in either direction (D49):
25531
26582
  *
25532
- * - a sensor the read could not answer for falls back to the mirrored state
25533
- * (bounded staleness, the same contract the `deviceState` gate lives with);
25534
- * - a sensor NEITHER can answer for is not blocking, and says so in a line;
25535
- * - a mode whose contact rule names no devices blocks nothing, and says so —
25536
- * an unscoped contact rule covers every contact on the hub, and a refusal
25537
- * naming devices the operator never associated with the alarm is one they
25538
- * cannot act on.
26583
+ * - a device the read could not answer for (absent), or whose opening slice
26584
+ * carries a word its cap does not define (`unknown`), is UNKNOWN — it
26585
+ * never blocks, is never counted secure, and says so in one line each;
26586
+ * - a mode whose rules name no devices blocks nothing — an unscoped rule
26587
+ * covers every device on the hub, and a refusal naming devices the
26588
+ * operator never associated with the alarm is one they cannot act on;
26589
+ * - when NOTHING could be read (no reader, the read threw, or every id came
26590
+ * back absent) this THROWS, and the panel arms and announces that it
26591
+ * armed without the check.
25539
26592
  */
25540
- async alarmOpenContacts(mode) {
26593
+ async alarmOpenings(mode) {
25541
26594
  const panel = this.alarmPanel;
25542
- if (panel === null) return [];
25543
- const candidates = alarmBlockingCandidates(this.rules.list(), panel.deviceId, mode);
25544
- if (candidates.length === 0) return [];
25545
- const fresh = await this.readContactStates(candidates);
26595
+ if (panel === null) return {
26596
+ open: [],
26597
+ unknown: []
26598
+ };
26599
+ const candidates = alarmCoveredDevices(this.rules.list(), panel.deviceId, mode);
26600
+ if (candidates.length === 0) return {
26601
+ open: [],
26602
+ unknown: []
26603
+ };
26604
+ const read = this.deps.readDeviceOpenings;
26605
+ if (read === void 0) throw new Error("no openings reader is wired on this node");
26606
+ const openings = await read(candidates);
26607
+ if (openings.size === 0) throw new Error(`none of the ${String(candidates.length)} covered devices could be read`);
25546
26608
  const name = this.deviceNameFromDirectory();
25547
26609
  const open = [];
25548
26610
  const unknown = [];
25549
26611
  for (const deviceId of candidates) {
25550
- const state = fresh.get(deviceId) ?? this.deviceStates.get(deviceId);
25551
- if (state === void 0) {
26612
+ const verdict = classifyCoveredOpening(openings.get(deviceId));
26613
+ if (verdict.kind === "unknown") {
25552
26614
  unknown.push(deviceId);
26615
+ this.logger.info("alarm arm check: device state unreadable — it cannot block this arm", {
26616
+ tags: { deviceId },
26617
+ meta: {
26618
+ panelDeviceId: panel.deviceId,
26619
+ mode,
26620
+ why: verdict.why,
26621
+ ...verdict.why === "unknown-word" ? {
26622
+ cap: verdict.cap,
26623
+ raw: rawForLog(verdict.raw)
26624
+ } : {}
26625
+ }
26626
+ });
25553
26627
  continue;
25554
26628
  }
25555
- if (state === "open") open.push({
26629
+ if (verdict.kind === "open") open.push({
25556
26630
  deviceId,
25557
- name: name(deviceId)
26631
+ name: name(deviceId),
26632
+ state: verdict.state
25558
26633
  });
25559
26634
  }
25560
- for (const deviceId of unknown) this.logger.info("alarm arm check: no contact state — the sensor cannot block this arm", {
25561
- tags: { deviceId },
25562
- meta: {
25563
- panelDeviceId: panel.deviceId,
25564
- mode
25565
- }
25566
- });
25567
26635
  this.logger.info("alarm arm check", {
25568
26636
  tags: { deviceId: panel.deviceId },
25569
26637
  meta: {
@@ -25571,55 +26639,198 @@ var NotificationCenter = class NotificationCenter {
25571
26639
  candidates: candidates.length,
25572
26640
  open: open.length,
25573
26641
  unknown: unknown.length,
25574
- openIds: open.map((c) => c.deviceId).join(",")
26642
+ openIds: open.map((d) => d.deviceId).join(",")
25575
26643
  }
25576
26644
  });
25577
- return open;
25578
- }
25579
- /** One bounded read of the named contacts. Never throws — an unreadable
25580
- * sensor is simply absent, exactly as `NcDeviceStateCache` contracts. */
25581
- async readContactStates(ids) {
25582
- const read = this.deps.readDeviceStates;
25583
- if (read === void 0) return /* @__PURE__ */ new Map();
25584
- try {
25585
- return await read(ids);
25586
- } catch (err) {
25587
- this.logger.warn("alarm arm check: contact read failed — falling back to the mirror", { meta: {
25588
- devices: ids.length,
25589
- error: String(err)
25590
- } });
25591
- return /* @__PURE__ */ new Map();
25592
- }
26645
+ return {
26646
+ open,
26647
+ unknown
26648
+ };
25593
26649
  }
25594
26650
  /**
25595
26651
  * The refusal, as an ordinary notification.
25596
26652
  *
25597
- * Same path the arm announcement takes (D126: the alarm speaks through the
25598
- * ordinary notification path), so it is durable, retried, in history and
26653
+ * Same path every alarm announcement takes (D126: the alarm speaks through
26654
+ * the ordinary notification path), so it is durable, retried, in history and
25599
26655
  * worded in the reader's language at send time. It is the operator's ONLY
25600
26656
  * copy: the thrown error reaches whoever called the cap, and when that caller
25601
- * is a notification button on a phone, nobody ever sees it.
26657
+ * is a notification button on a phone, nobody ever sees it. The refusal id
26658
+ * rides the transition onto the ROW, where the Arm anyway button is minted
26659
+ * from (D630).
25602
26660
  */
25603
26661
  announceArmRefused(input) {
25604
26662
  const panel = this.alarmPanel;
25605
26663
  if (panel === null) return;
25606
- const text = buildArmRefusedAnnouncement(input.mode, input.blocking.map((c) => c.name), input.retrySeconds);
25607
- const transition = {
26664
+ this.scheduleAlarmTransition(panel, {
25608
26665
  kind: "alarm-arm-refused",
25609
- state: "disarmed",
25610
- previous: "disarmed",
26666
+ state: input.state,
26667
+ previous: input.state,
26668
+ mode: input.refusal.mode,
26669
+ at: input.at,
26670
+ refusalId: input.refusal.id,
26671
+ blocking: input.blocking.map((d) => ({
26672
+ deviceId: d.deviceId,
26673
+ name: d.name,
26674
+ state: d.state
26675
+ }))
26676
+ });
26677
+ }
26678
+ /**
26679
+ * The panel armed WITHOUT the openings check — nothing could be read. A
26680
+ * line on the panel AND an `alarm-check-failed` notification to its
26681
+ * recipients: "armed, but I could not check the windows" is exactly what the
26682
+ * operator has to hear before leaving.
26683
+ */
26684
+ announceArmCheckFailed(input) {
26685
+ const panel = this.alarmPanel;
26686
+ if (panel === null) return;
26687
+ this.logger.warn("alarm armed WITHOUT the openings check — nothing could be read", {
26688
+ tags: { deviceId: panel.deviceId },
26689
+ meta: {
26690
+ mode: input.mode,
26691
+ at: input.at,
26692
+ error: input.error
26693
+ }
26694
+ });
26695
+ this.scheduleAlarmTransition(panel, {
26696
+ kind: "alarm-check-failed",
26697
+ state: input.state,
26698
+ previous: input.state,
25611
26699
  mode: input.mode,
25612
26700
  at: input.at
26701
+ });
26702
+ }
26703
+ /**
26704
+ * The alarm, for the `nc.*` bridge — the viewer's and the admin tab's
26705
+ * surface (`nc-actions.ts` decides who may call what). The same paths the cap
26706
+ * and the notification buttons take: `armWith`, `armAnyway`, `disarm` on the
26707
+ * panel, and the one config reader/writer below.
26708
+ */
26709
+ alarmActions() {
26710
+ const requirePanel = () => {
26711
+ const panel = this.alarmPanel;
26712
+ if (panel === null) throw new Error("this node has no alarm panel");
26713
+ return panel;
25613
26714
  };
25614
- const settings = panel.settings();
25615
- const targetIds = settings.announceArm ? settings.announceTargets : [];
25616
- this.scheduleAlarmTransition(panel, transition, {
25617
- text,
25618
- targetIds
26715
+ return {
26716
+ status: async () => {
26717
+ const panel = this.alarmPanel;
26718
+ if (panel === null) return {
26719
+ deviceId: null,
26720
+ live: null
26721
+ };
26722
+ return {
26723
+ deviceId: panel.deviceId,
26724
+ live: await this.alarmLive(panel)
26725
+ };
26726
+ },
26727
+ arm: async (mode, userId) => {
26728
+ const panel = requirePanel();
26729
+ const outcome = await panel.armWith(mode);
26730
+ this.logger.info("alarm arm requested through the bridge", {
26731
+ tags: { deviceId: panel.deviceId },
26732
+ meta: {
26733
+ userId,
26734
+ mode,
26735
+ outcome: outcome.outcome
26736
+ }
26737
+ });
26738
+ return outcome;
26739
+ },
26740
+ armAnyway: async (refusalId, userId) => {
26741
+ const panel = requirePanel();
26742
+ const outcome = await panel.armAnyway(refusalId);
26743
+ this.logger.info("alarm Arm anyway requested through the bridge", {
26744
+ tags: { deviceId: panel.deviceId },
26745
+ meta: {
26746
+ userId,
26747
+ refusalId,
26748
+ outcome: outcome.outcome
26749
+ }
26750
+ });
26751
+ return outcome;
26752
+ },
26753
+ disarm: async (userId) => {
26754
+ const panel = requirePanel();
26755
+ await panel.disarm();
26756
+ this.logger.info("alarm disarm requested through the bridge", {
26757
+ tags: { deviceId: panel.deviceId },
26758
+ meta: { userId }
26759
+ });
26760
+ },
26761
+ getConfig: () => this.alarmConfig(),
26762
+ setConfig: (patch) => this.setAlarmConfig(patch)
26763
+ };
26764
+ }
26765
+ async setAlarmConfig(patch) {
26766
+ const panel = this.alarmPanel;
26767
+ if (panel === null) throw new Error("this node has no alarm panel");
26768
+ const settings = await panel.applySettings(patch);
26769
+ this.logger.info("alarm settings changed", {
26770
+ tags: { deviceId: panel.deviceId },
26771
+ meta: {
26772
+ exitDelaySec: settings.exitDelaySec,
26773
+ entryDelaySec: settings.entryDelaySec,
26774
+ triggeredDurationSec: settings.triggeredDurationSec,
26775
+ announceTargets: settings.announceTargets.length
26776
+ }
26777
+ });
26778
+ return this.alarmConfig();
26779
+ }
26780
+ /**
26781
+ * The panel as it is NOW — see `alarm-live.ts`.
26782
+ *
26783
+ * ONE read of the union of every mode's covered devices, so each is read
26784
+ * once however many modes cover it. A read that fails is not an error here:
26785
+ * every covered device is then `unknown` (D49) and the line says why — a
26786
+ * status the UI polls must not fail because a contact is asleep.
26787
+ */
26788
+ async alarmLive(panel) {
26789
+ const modes = panel.availableModes();
26790
+ const rules = this.rules.list();
26791
+ const covered = new Map(modes.map((mode) => [mode, alarmCoveredDevices(rules, panel.deviceId, mode)]));
26792
+ const ids = alarmLiveReadIds(covered);
26793
+ let openings = /* @__PURE__ */ new Map();
26794
+ const read = this.deps.readDeviceOpenings;
26795
+ let readThrew = false;
26796
+ if (ids.length > 0 && read === void 0) this.logger.warn("alarm live view: no openings reader is wired — every opening is unknown", {
26797
+ tags: { deviceId: panel.deviceId },
26798
+ meta: { covered: ids.length }
26799
+ });
26800
+ else if (ids.length > 0 && read !== void 0) try {
26801
+ openings = await read(ids);
26802
+ } catch (err) {
26803
+ readThrew = true;
26804
+ this.logger.warn("alarm live view: openings read failed — every opening is unknown", {
26805
+ tags: { deviceId: panel.deviceId },
26806
+ meta: {
26807
+ covered: ids.length,
26808
+ error: err instanceof Error ? err.message : String(err)
26809
+ }
26810
+ });
26811
+ }
26812
+ const unanswered = ids.filter((id) => !openings.has(id));
26813
+ if (read !== void 0 && !readThrew && unanswered.length > 0) this.logger.warn("alarm live view: covered devices did not answer — shown as unknown", {
26814
+ tags: { deviceId: panel.deviceId },
26815
+ meta: {
26816
+ covered: ids.length,
26817
+ unanswered: unanswered.length,
26818
+ unansweredIds: unanswered.join(",")
26819
+ }
26820
+ });
26821
+ return buildAlarmLive({
26822
+ state: panel.currentState(),
26823
+ target: panel.currentTarget(),
26824
+ modes,
26825
+ covered,
26826
+ openings,
26827
+ nonBlocking: panel.settings().nonBlocking,
26828
+ exclusions: panel.exclusionPhases(),
26829
+ name: this.deviceNameFromDirectory()
25619
26830
  });
25620
26831
  }
25621
- /** Settings + the DERIVED coverage. Read together — see the cap doc. */
25622
- alarmConfig() {
26832
+ /** Settings + the DERIVED coverage + the live panel. Read together — see the cap doc. */
26833
+ async alarmConfig() {
25623
26834
  const panel = this.alarmPanel;
25624
26835
  if (panel === null) return {
25625
26836
  deviceId: null,
@@ -25629,6 +26840,7 @@ var NotificationCenter = class NotificationCenter {
25629
26840
  return {
25630
26841
  deviceId: panel.deviceId,
25631
26842
  settings: panel.settings(),
26843
+ live: await this.alarmLive(panel),
25632
26844
  coverage: this.coverageFor(panel).map((c) => ({
25633
26845
  ...c,
25634
26846
  deviceIds: [...c.deviceIds],
@@ -25772,6 +26984,10 @@ var NotificationCenter = class NotificationCenter {
25772
26984
  const panel = this.alarmPanel;
25773
26985
  if (panel === null) return;
25774
26986
  this.deviceStates.note(panel.deviceId, report.state);
26987
+ this.panelGateWords = {
26988
+ deviceId: panel.deviceId,
26989
+ words: alarmGateWords(report.state, report.target)
26990
+ };
25775
26991
  const transition = report.transition;
25776
26992
  if (transition === null) return;
25777
26993
  this.scheduleAlarmTransition(panel, transition);
@@ -25783,28 +26999,29 @@ var NotificationCenter = class NotificationCenter {
25783
26999
  * On the SAME serialized chain as every other evaluation, because both of
25784
27000
  * those follow-ups read what the evaluation just enqueued:
25785
27001
  *
25786
- * - an ARM is also announced to the panel's own `announceTargets`, which no
25787
- * rule addresses — but only where a rule has not already delivered it, or
25788
- * a target that is both would get the same sentence twice;
27002
+ * - EVERY alarm transition is also announced to the panel's own
27003
+ * `announceTargets`, which no rule addresses (D630) — but only where a
27004
+ * rule has not already delivered it, or a target that is both would get
27005
+ * the same sentence twice;
25789
27006
  * - a TRIGGERED with an attribution supersedes the triggering rule's own
25790
27007
  * still-pending rows, for exactly the targets the alarm reached. That is
25791
27008
  * the combined notification: one row carrying the alarm's words and that
25792
27009
  * rule's pictures, instead of two pushes about one incident.
25793
27010
  */
25794
- scheduleAlarmTransition(panel, transition, prepared) {
25795
- const arm = prepared ?? this.armAnnouncement(panel, transition);
27011
+ scheduleAlarmTransition(panel, transition) {
27012
+ const announcement = this.alarmAnnouncement(panel, transition);
25796
27013
  const incoming = incomingFromAlarmTransition({
25797
27014
  transition,
25798
27015
  panelDeviceId: panel.deviceId,
25799
27016
  lookup: this.deviceDirectory.lookup(),
25800
- ...arm !== null ? { body: arm.text } : {}
27017
+ ...announcement.text !== null ? { body: announcement.text } : {}
25801
27018
  });
25802
27019
  this.evalChain = this.evalChain.then(async () => {
25803
27020
  try {
25804
27021
  const report = await this.evaluateAndEnqueue(incoming);
25805
27022
  const reached = new Set(report.outcomes.flatMap((o) => o.targetIds));
25806
- if (arm !== null) await this.enqueueArmAnnouncement(panel, incoming, arm, reached);
25807
- await this.settleCombinedAlarm(transition, incoming, report, reached);
27023
+ const announced = await this.enqueueAlarmAnnouncement(panel, incoming, announcement, reached);
27024
+ await this.settleCombinedAlarm(transition, incoming, report, reached, announced);
25808
27025
  } catch (err) {
25809
27026
  this.logger.warn("alarm transition evaluation failed", {
25810
27027
  tags: { deviceId: panel.deviceId },
@@ -25818,28 +27035,41 @@ var NotificationCenter = class NotificationCenter {
25818
27035
  });
25819
27036
  }
25820
27037
  /**
25821
- * The arm/arming sentence and the targets it goes to, or null when the
25822
- * transition is not one of those two.
27038
+ * The sentence an alarm transition is said with, and the targets it goes to
27039
+ * — for EVERY alarm kind, always the panel's `announceTargets` (D630: the
27040
+ * `announceArm` switch is gone; rules may add more recipients, never fewer).
25823
27041
  *
25824
- * Both kinds compose from the SAME coverage — "what does this mode actually
25825
- * watch" — because they are the same answer given thirty seconds apart. The
25826
- * arming one is the useful one: it reaches the operator while the exit delay
25827
- * is still running, so a mode that protects nothing can still be fixed.
27042
+ * `text` is a composed body only where the words depend on the rule set:
25828
27043
  *
25829
- * Every way of NOT announcing produces a line: an announcement that silently
25830
- * did not arrive is indistinguishable from the alarm not having armed, which
25831
- * is the exact confusion this feature exists to remove. A COVERAGE GAP no
25832
- * longer suppresses the sentence — it changes it. Suppressing it left the
25833
- * operator with a title and an empty line (the fallback body key existed in
25834
- * neither locale) and no way to learn that the mode was gated on by nothing.
27044
+ * - ARM and ARMING compose from the SAME coverage — "what does this mode
27045
+ * actually watch" — because they are the same answer given thirty seconds
27046
+ * apart. A COVERAGE GAP does not suppress the sentence, it changes it.
27047
+ * - a REFUSAL names the devices that refused it, and — since its row
27048
+ * carries the Arm anyway — where the button is.
27049
+ *
27050
+ * Every other kind is `null`: the intake picks its catalog body and params.
25835
27051
  */
25836
- armAnnouncement(panel, transition) {
27052
+ alarmAnnouncement(panel, transition) {
27053
+ const targetIds = panel.settings().announceTargets;
27054
+ if (targetIds.length === 0) this.logger.info("alarm transition has no announcement target", {
27055
+ tags: { deviceId: panel.deviceId },
27056
+ meta: { kind: transition.kind }
27057
+ });
27058
+ return {
27059
+ text: this.alarmAnnouncementText(panel, transition),
27060
+ targetIds
27061
+ };
27062
+ }
27063
+ /** The composed body of {@link alarmAnnouncement}, or null for a catalog body. */
27064
+ alarmAnnouncementText(panel, transition) {
25837
27065
  const kind = transition.kind;
25838
- if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
25839
27066
  const mode = transition.mode;
27067
+ if (kind === "alarm-arm-refused") {
27068
+ if (mode === void 0) return null;
27069
+ return buildArmRefusedAnnouncement(mode, (transition.blocking ?? []).map((d) => d.name), transition.refusalId !== void 0);
27070
+ }
27071
+ if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
25840
27072
  if (mode === void 0) return null;
25841
- const settings = panel.settings();
25842
- const tags = { deviceId: panel.deviceId };
25843
27073
  const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
25844
27074
  if (coverage === void 0) return null;
25845
27075
  const name = this.deviceNameFromDirectory();
@@ -25847,7 +27077,7 @@ var NotificationCenter = class NotificationCenter {
25847
27077
  const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
25848
27078
  const gap = alarmCoverageGap(coverage);
25849
27079
  if (gap !== null) this.logger.warn("alarm armed into a mode that watches nothing", {
25850
- tags,
27080
+ tags: { deviceId: panel.deviceId },
25851
27081
  meta: {
25852
27082
  mode,
25853
27083
  kind,
@@ -25857,41 +27087,27 @@ var NotificationCenter = class NotificationCenter {
25857
27087
  skipped: coverage.skippedDevices.length
25858
27088
  }
25859
27089
  });
25860
- if (!settings.announceArm) return {
25861
- text,
25862
- targetIds: []
25863
- };
25864
- if (settings.announceTargets.length === 0) {
25865
- this.logger.info("alarm armed but no announcement target is configured", {
25866
- tags,
25867
- meta: { mode }
25868
- });
25869
- return {
25870
- text,
25871
- targetIds: []
25872
- };
25873
- }
25874
- return {
25875
- text,
25876
- targetIds: settings.announceTargets
25877
- };
27090
+ return text;
25878
27091
  }
25879
27092
  /**
25880
- * Put the arm announcement in the outbox, under {@link NC_ALARM_ANNOUNCE_RULE_ID}.
27093
+ * Put the alarm announcement in the outbox, under {@link NC_ALARM_ANNOUNCE_RULE_ID}.
25881
27094
  *
25882
27095
  * The row is what makes this a notification like any other: durable, retried,
25883
27096
  * visible in history, and rendered in the reader's language at send time
25884
- * rather than frozen in the hub's at arm time. `recordId` is the transition's
25885
- * own id, so every arm is a new row (cooldown 0) and a replay of the same one
25886
- * is deduped.
27097
+ * rather than frozen in the hub's at transition time. `recordId` is the
27098
+ * transition's own id, so every transition is a new row (cooldown 0) and a
27099
+ * replay of the same one is deduped.
25887
27100
  *
25888
- * Targets a RULE already reached are skipped — one arm, one sentence, one
25889
- * push per person, whichever path addressed them.
27101
+ * Targets a RULE already reached are skipped — one transition, one sentence,
27102
+ * one push per person, whichever path addressed them.
25890
27103
  */
25891
- async enqueueArmAnnouncement(panel, incoming, arm, reached) {
25892
- const targets = arm.targetIds.filter((id) => !reached.has(id));
25893
- if (targets.length === 0) return;
27104
+ async enqueueAlarmAnnouncement(panel, incoming, announcement, reached) {
27105
+ const targets = announcement.targetIds.filter((id) => !reached.has(id));
27106
+ if (targets.length === 0) return [];
25894
27107
  const subject = incoming.subject;
27108
+ const alarmSource = subject.systemEvent?.alarmSource;
27109
+ const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
27110
+ const media = sourceRule !== null ? mediaPolicyFields(sourceRule, void 0) : { media: "none" };
25895
27111
  const entries = targets.map((targetId) => ({
25896
27112
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
25897
27113
  targetId,
@@ -25902,9 +27118,11 @@ var NotificationCenter = class NotificationCenter {
25902
27118
  ruleName: "alarm",
25903
27119
  delivery: "system-event",
25904
27120
  priority: 2,
25905
- media: "none",
27121
+ ...media,
25906
27122
  subject: {
25907
27123
  deviceId: panel.deviceId,
27124
+ ...alarmSource?.trackId !== void 0 ? { trackId: alarmSource.trackId } : {},
27125
+ ...alarmSource?.eventId !== void 0 ? { eventId: alarmSource.eventId } : {},
25908
27126
  className: "alarm",
25909
27127
  zones: [],
25910
27128
  timestamp: subject.timestamp,
@@ -25913,14 +27131,17 @@ var NotificationCenter = class NotificationCenter {
25913
27131
  }
25914
27132
  }));
25915
27133
  const inserted = await this.outbox.enqueue(entries);
25916
- this.logger.info("alarm arm announcement enqueued", {
27134
+ this.logger.info("alarm announcement enqueued", {
25917
27135
  tags: { deviceId: panel.deviceId },
25918
27136
  meta: {
27137
+ kind: subject.systemEvent?.kind,
25919
27138
  targets: targets.length,
25920
27139
  inserted,
25921
- bodyKey: arm.text.bodyKey
27140
+ bodyKey: subject.systemEvent?.bodyKey,
27141
+ media: media.media
25922
27142
  }
25923
27143
  });
27144
+ return targets;
25924
27145
  }
25925
27146
  /**
25926
27147
  * Deliver to targets the producer named (`controls.directTargetIds`).
@@ -25979,12 +27200,12 @@ var NotificationCenter = class NotificationCenter {
25979
27200
  * went out twenty seconds ago — that is not a duplicate, it is the
25980
27201
  * escalation, and this says so rather than silently doing nothing.
25981
27202
  */
25982
- async settleCombinedAlarm(transition, incoming, report, reached) {
27203
+ async settleCombinedAlarm(transition, incoming, report, reached, announced) {
25983
27204
  if (transition.kind !== "alarm-triggered") return;
25984
27205
  const tags = { deviceId: incoming.subject.deviceId };
25985
27206
  const source = transition.source;
25986
- if (reached.size === 0) {
25987
- this.logger.warn("alarm TRIGGERED but no enabled rule is gated on alarm-triggered", {
27207
+ if (reached.size === 0 && announced.length === 0) {
27208
+ this.logger.warn("alarm TRIGGERED but neither a rule nor an announce target heard it", {
25988
27209
  tags,
25989
27210
  meta: {
25990
27211
  candidates: report.candidates,
@@ -25994,19 +27215,21 @@ var NotificationCenter = class NotificationCenter {
25994
27215
  return;
25995
27216
  }
25996
27217
  if (source === void 0) return;
27218
+ const combined = new Set([...reached, ...announced]);
25997
27219
  const dropped = await this.outbox.supersede({
25998
27220
  ruleId: source.ruleId,
25999
27221
  recordId: source.recordId,
26000
- targetIds: [...reached]
27222
+ targetIds: [...combined]
26001
27223
  });
26002
27224
  this.logger.info("alarm notification combined with the rule that triggered it", {
26003
27225
  tags,
26004
27226
  meta: {
26005
27227
  sourceRuleId: source.ruleId,
26006
27228
  rule: source.ruleName,
26007
- targets: reached.size,
27229
+ targets: combined.size,
27230
+ announced: announced.length,
26008
27231
  superseded: dropped,
26009
- alreadyDelivered: reached.size - dropped
27232
+ alreadyDelivered: combined.size - dropped
26010
27233
  }
26011
27234
  });
26012
27235
  }
@@ -26106,7 +27329,7 @@ var NotificationCenter = class NotificationCenter {
26106
27329
  let noOwnerReported = false;
26107
27330
  const rejections = [];
26108
27331
  for (const rule of candidates) {
26109
- const evaluation = evaluateRule(rule, subject, (id) => this.deviceStates.get(id), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
27332
+ const evaluation = evaluateRule(rule, subject, this.deviceStateLookup((id) => this.deviceStates.get(id)), (id) => this.zoneOwners.get(id), (sceneId) => this.sceneStates.get(sceneId));
26110
27333
  if (!evaluation.matched) {
26111
27334
  if (evaluation.failedCondition === "scene") this.reportSceneSuppression(rule, subject);
26112
27335
  if (evaluation.excludedBySystemEventFilter !== void 0) this.reportSystemFilterExclusion(rule, subject, evaluation.excludedBySystemEventFilter);
@@ -26128,6 +27351,7 @@ var NotificationCenter = class NotificationCenter {
26128
27351
  kind,
26129
27352
  failed: evaluation.failedCondition,
26130
27353
  classes: subject.classNames,
27354
+ ...subject.sensorEdge !== void 0 ? { sensorEdge: subject.sensorEdge } : {},
26131
27355
  eventId: subject.recordId,
26132
27356
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {}
26133
27357
  }
@@ -26251,15 +27475,24 @@ var NotificationCenter = class NotificationCenter {
26251
27475
  });
26252
27476
  if (!noOwnerReported && kind === "device-event" && subject.trackId === void 0 && rule.media.attach !== "none") {
26253
27477
  noOwnerReported = true;
26254
- this.logger.warn("device event has no marker track — this notification ships text-only", {
27478
+ const linked = subject.linkedCameraIds;
27479
+ const meta = {
27480
+ recordId: subject.recordId,
27481
+ ...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
27482
+ ruleId: rule.id,
27483
+ rule: rule.name
27484
+ };
27485
+ 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`, {
26255
27486
  tags: { deviceId: subject.deviceId },
26256
27487
  meta: {
26257
- recordId: subject.recordId,
26258
- ...subject.sensorKind !== void 0 ? { sensorKind: subject.sensorKind } : {},
26259
- ruleId: rule.id,
26260
- rule: rule.name
27488
+ ...meta,
27489
+ linkedCameraIds: [...linked]
26261
27490
  }
26262
27491
  });
27492
+ else this.logger.warn("device event has no marker track — this notification ships text-only", {
27493
+ tags: { deviceId: subject.deviceId },
27494
+ meta
27495
+ });
26263
27496
  }
26264
27497
  if (!noOwnerReported && (kind === "audio-window" || kind === "audio-event") && subject.trackId === void 0 && rule.media.attach !== "none") {
26265
27498
  noOwnerReported = true;
@@ -26587,20 +27820,11 @@ var NotificationCenter = class NotificationCenter {
26587
27820
  ...upgrade !== void 0 ? { enhancement: true } : {},
26588
27821
  priority: rule.priority,
26589
27822
  ...rule.template !== void 0 ? { template: rule.template } : {},
26590
- media: noMedia ? "none" : mediaRule.media.attach,
26591
- ...!noMedia && mediaRule.media.zoneCrop === true && cropZoneIds !== void 0 ? { mediaZoneIds: [...cropZoneIds] } : {},
26592
- ...!noMedia && cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
27823
+ ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds),
26593
27824
  ...confirm !== void 0 ? {
26594
27825
  confirm,
26595
27826
  ...cooldown !== void 0 ? { cooldown } : {}
26596
27827
  } : {},
26597
- ...!noMedia && mediaRule.media.frame !== void 0 ? { mediaFrame: mediaRule.media.frame } : {},
26598
- ...!noMedia && mediaRule.media.profile !== void 0 ? { mediaProfile: mediaRule.media.profile } : {},
26599
- ...!noMedia && mediaRule.media.gif === true ? { mediaGif: true } : {},
26600
- ...!noMedia && mediaRule.media.clip === true ? { mediaClip: true } : {},
26601
- ...!noMedia && mediaRule.media.clipPreRollSec !== void 0 ? { mediaClipPreRollSec: mediaRule.media.clipPreRollSec } : {},
26602
- ...!noMedia && mediaRule.media?.clipSpeed !== void 0 ? { mediaClipSpeed: mediaRule.media.clipSpeed } : {},
26603
- ...!noMedia && mediaRule.media.clipPostRollSec !== void 0 ? { mediaClipPostRollSec: mediaRule.media.clipPostRollSec } : {},
26604
27828
  ...matchedOn !== void 0 && matchedOn.length > 0 ? { matchedOn } : {},
26605
27829
  ...group !== void 0 && group.members.length > 1 ? { group: {
26606
27830
  key: group.key,
@@ -26831,8 +28055,6 @@ var NotificationCenter = class NotificationCenter {
26831
28055
  const devices = rule.conditions.devices;
26832
28056
  if (zones !== void 0 && zones.ids.length > 0 && devices !== void 0) zoneScoped.push(...devices);
26833
28057
  }
26834
- const panel = this.alarmPanel;
26835
- if (panel !== null) gated.push(...alarmContactWatchList(this.rules.list(), panel.deviceId, panel.availableModes()));
26836
28058
  this.deviceStates.setWatched(gated);
26837
28059
  this.zoneOwners.setWatched(zoneScoped);
26838
28060
  this.sceneStates.setWatched(sceneIds);
@@ -26846,21 +28068,41 @@ var NotificationCenter = class NotificationCenter {
26846
28068
  }
26847
28069
  }
26848
28070
  /**
26849
- * Feed every excluded sensor's mirrored state back to the panel.
28071
+ * Read every excluded device's opening and feed it back to the panel.
26850
28072
  *
26851
28073
  * The reconcile half of the two-phase lift. The EVENT half
26852
- * ({@link onSensorEventPersisted}) is the fast one, but it only exists for a
26853
- * sensor linked to a camera — the seven contacts on this hub are linked to
26854
- * none, so without this an exclusion would never lift at all and the sensor
26855
- * would stay out of the alarm until a disarm.
26856
- */
26857
- observeAlarmContacts() {
28074
+ * ({@link observeOpeningEvent}) is the fast one, but an event can be dropped
28075
+ * (D8), so the tick re-reads the handful of excluded devices — through the
28076
+ * same `readDeviceOpenings` table the arm check uses, so a cover lifts on
28077
+ * `closed` and a lock on `locked` exactly as on the event path. A device the
28078
+ * read cannot answer for, or that reads unknown, changes nothing (D49).
28079
+ * Never throws into the tick.
28080
+ */
28081
+ async observeAlarmOpenings() {
26858
28082
  const panel = this.alarmPanel;
26859
- if (panel === null) return;
28083
+ const read = this.deps.readDeviceOpenings;
28084
+ if (panel === null || read === void 0) return;
26860
28085
  const excluded = panel.excludedDevices();
26861
28086
  if (excluded.length === 0) return;
28087
+ let openings;
28088
+ try {
28089
+ openings = await read(excluded);
28090
+ } catch (err) {
28091
+ this.logger.warn("alarm exclusion reconcile: the openings read failed — nothing lifted", {
28092
+ tags: { deviceId: panel.deviceId },
28093
+ meta: {
28094
+ excluded: excluded.length,
28095
+ error: String(err)
28096
+ }
28097
+ });
28098
+ return;
28099
+ }
26862
28100
  const now = this.now();
26863
- for (const deviceId of excluded) panel.observeContact(deviceId, contactReadingOf(this.deviceStates.get(deviceId)), now);
28101
+ for (const deviceId of excluded) {
28102
+ const opening = openings.get(deviceId);
28103
+ if (opening?.kind !== "reading") continue;
28104
+ panel.observeOpening(deviceId, opening.reading, now);
28105
+ }
26864
28106
  }
26865
28107
  /**
26866
28108
  * Log the watched occupancy key set, on CHANGE only.
@@ -26999,7 +28241,7 @@ var NotificationCenter = class NotificationCenter {
26999
28241
  await this.deviceDirectory.refresh();
27000
28242
  await this.zoneOwners.refresh();
27001
28243
  this.sceneStates.refresh();
27002
- this.observeAlarmContacts();
28244
+ await this.observeAlarmOpenings();
27003
28245
  await this.occupancyStore.pruneExcept(this.activeOccupancyKeys());
27004
28246
  }
27005
28247
  /**
@@ -27301,11 +28543,11 @@ var NotificationCenter = class NotificationCenter {
27301
28543
  const tracks = await this.deps.listRecentTracks(since, TEST_RULE_MAX_RESULTS);
27302
28544
  for (const track of tracks) subjects.push(incomingFromTrack(track).subject);
27303
28545
  }
27304
- let lookup = (id) => this.deviceStates.get(id);
28546
+ let lookup = this.deviceStateLookup((id) => this.deviceStates.get(id));
27305
28547
  const gated = rule.conditions.deviceState;
27306
28548
  if (gated !== void 0 && this.deps.readDeviceStates !== void 0) try {
27307
28549
  const fresh = await this.deps.readDeviceStates([gated.deviceId]);
27308
- lookup = (id) => fresh.get(id) ?? this.deviceStates.get(id);
28550
+ lookup = this.deviceStateLookup((id) => fresh.get(id) ?? this.deviceStates.get(id));
27309
28551
  } catch {}
27310
28552
  let zoneLookup = this.zoneOwners.lookup();
27311
28553
  const zoneScope = rule.conditions.devices;
@@ -27569,15 +28811,28 @@ function systemEventKeys(kind) {
27569
28811
  titleKey: `system.${kind}.title`,
27570
28812
  bodyKey: `system.${kind}.body.agent`
27571
28813
  };
27572
- if (kind === "alarm-armed" || kind === "alarm-arming" || kind === "alarm-arm-refused") return {
28814
+ if (PER_MODE_TITLE_KINDS.has(kind)) return {
27573
28815
  titleKey: `system.${kind}.title.${TEST_ALARM_MODE}`,
27574
28816
  bodyKey: `system.${kind}.body`
27575
28817
  };
28818
+ if (kind === "alarm-sensor-bypass") return {
28819
+ titleKey: `system.${kind}.title`,
28820
+ bodyKey: `system.${kind}.body.ignored`
28821
+ };
27576
28822
  return {
27577
28823
  titleKey: `system.${kind}.title`,
27578
28824
  bodyKey: `system.${kind}.body`
27579
28825
  };
27580
28826
  }
28827
+ /** The alarm kinds whose TITLE key is per arm mode (`….title.<mode>`). */
28828
+ var PER_MODE_TITLE_KINDS = new Set([
28829
+ "alarm-arm-refused",
28830
+ "alarm-check-failed",
28831
+ "alarm-arming",
28832
+ "alarm-armed",
28833
+ "alarm-pending",
28834
+ "alarm-rearmed"
28835
+ ]);
27581
28836
  /** The arm mode a synthetic alarm event stands in for — see {@link systemEventKeys}. */
27582
28837
  var TEST_ALARM_MODE = "away";
27583
28838
  /** The runner a synthetic `addon-crash-loop` stands in for. A test fire has no
@@ -27812,6 +29067,17 @@ var NcTrackGroupSchema = object({
27812
29067
  sealed: boolean()
27813
29068
  });
27814
29069
  /**
29070
+ * `nc.getAlarmStatus` — the panel as it is now (admin-only for now; see the
29071
+ * envelope's comment). `deviceId: null`
29072
+ * with `live: null` is a node with no panel, which a client must not read as
29073
+ * "disarmed, nothing open". `live` is the published `NcAlarmConfig.live`
29074
+ * schema, taken from it rather than re-declared.
29075
+ */
29076
+ var NcAlarmStatusSchema = object({
29077
+ deviceId: number().int().nullable(),
29078
+ live: NcAlarmConfigSchema.shape.live.unwrap().nullable()
29079
+ });
29080
+ /**
27815
29081
  * The action catalog — the tRPC contract Group B (the viewer client) consumes
27816
29082
  * against `addonId: 'pipeline-analytics'`. Every entry that depends on the
27817
29083
  * caller identity is declared `caller: 'required'` so the dispatcher forwards
@@ -27877,6 +29143,76 @@ var ncActions = defineCustomActions({
27877
29143
  caller: "required"
27878
29144
  }),
27879
29145
  /**
29146
+ * The ALARM, for the viewer and the admin Alarm tab (D630).
29147
+ *
29148
+ * Bridge actions and not cap methods, for one reason each way:
29149
+ *
29150
+ * - A REFUSED ARM IS A RESULT HERE. `alarm-panel.arm` is validated by the
29151
+ * hub's generated router against the HOST's copy of the cap, which says
29152
+ * `z.void()` — so on the cap a refusal can only be a thrown string. The
29153
+ * response schema of these actions ({@link NcAlarmArmOutcomeSchema})
29154
+ * lives in the addon and is validated only here, so a refusal travels as
29155
+ * `{ outcome: 'refused', refusalId, blocking }` and the viewer can offer
29156
+ * the Arm anyway bound to exactly that refusal.
29157
+ * - The UIs never send a device id. There is one panel per hub and the
29158
+ * centre already holds it; an id on the wire would be one more thing a
29159
+ * client could get wrong.
29160
+ *
29161
+ * WHO may call what. The WRITES — arm, Arm anyway, disarm — are ADMIN-ONLY,
29162
+ * in the envelope and again in the handler. This is not the cap's authority
29163
+ * and must not pretend to be: `alarm-panel.arm` / `disarm` are DEVICE-scoped
29164
+ * (the host checks the caller's grant on THAT panel), while `addons.custom`
29165
+ * is SYSTEM-scoped and knows nothing about per-device grants. Opening these
29166
+ * to any authenticated user would widen who can disarm the house to anyone
29167
+ * with a login. A non-admin keeps arming and disarming through the
29168
+ * device-scoped cap, exactly as today, and gets past a refusal with the Arm
29169
+ * anyway button on the refusal notification (an HMAC grant bound to that one
29170
+ * refusal).
29171
+ *
29172
+ * `nc.getAlarmStatus` is READ-ONLY, and ADMIN-ONLY too until the viewer
29173
+ * work scopes it. It names every covered device, open or not, with its name
29174
+ * and state — a map of which windows of the house are open, served over the
29175
+ * system-scoped `addons.custom` to ANY login, regardless of which devices
29176
+ * that login may see. The device-scoped way to read a panel is the cap's
29177
+ * `getStatus`; this one opens up once it filters to the caller's grants.
29178
+ * The config pair is the admin's, as `getAlarmConfig` / `setAlarmConfig` are
29179
+ * on the cap: it carries the delays, the recipients and the non-blocking
29180
+ * lists.
29181
+ */
29182
+ "nc.getAlarmStatus": customAction(object({}), NcAlarmStatusSchema, {
29183
+ auth: "admin",
29184
+ caller: "required"
29185
+ }),
29186
+ "nc.armAlarm": customAction(object({ mode: AlarmArmModeSchema }), NcAlarmArmOutcomeSchema, {
29187
+ kind: "mutation",
29188
+ auth: "admin",
29189
+ caller: "required"
29190
+ }),
29191
+ /**
29192
+ * `panel.armAnyway(refusalId)` — the same bound, one-shot path the refusal
29193
+ * notification's button takes: a second press is `used`, a panel that moved
29194
+ * since is `stale`, and a device that opened since is a NEW refusal.
29195
+ */
29196
+ "nc.armAlarmAnyway": customAction(object({ refusalId: string().min(1) }), NcAlarmArmOutcomeSchema, {
29197
+ kind: "mutation",
29198
+ auth: "admin",
29199
+ caller: "required"
29200
+ }),
29201
+ "nc.disarmAlarm": customAction(object({}), object({ success: literal(true) }), {
29202
+ kind: "mutation",
29203
+ auth: "admin",
29204
+ caller: "required"
29205
+ }),
29206
+ "nc.getAlarmConfig": customAction(object({}), NcAlarmConfigSchema, {
29207
+ auth: "admin",
29208
+ caller: "required"
29209
+ }),
29210
+ "nc.setAlarmConfig": customAction(object({ patch: NcAlarmSettingsPatchSchema }), NcAlarmConfigSchema, {
29211
+ kind: "mutation",
29212
+ auth: "admin",
29213
+ caller: "required"
29214
+ }),
29215
+ /**
27880
29216
  * The profiles a rule's AI section may be PINNED to — the picker's options.
27881
29217
  *
27882
29218
  * A bridge action and not `llm.listProfiles` straight from the client, for
@@ -28224,7 +29560,22 @@ function requireCaller$1(caller) {
28224
29560
  if (!caller || typeof caller.userId !== "string" || caller.userId.length === 0) throw new Error("forbidden: authenticated caller required");
28225
29561
  return caller;
28226
29562
  }
29563
+ /**
29564
+ * An ADMIN caller, or a refusal. The dispatcher already gates an
29565
+ * `auth: 'admin'` action; this is the same check on this side of the bridge,
29566
+ * so a dispatcher that did not forward the flag cannot open it.
29567
+ */
29568
+ function requireAdmin(caller) {
29569
+ const c = requireCaller$1(caller);
29570
+ if (!c.isAdmin) throw new Error("forbidden: admin required");
29571
+ return c;
29572
+ }
28227
29573
  function makeNcActionHandlers(deps) {
29574
+ /** The alarm half, or a refusal by name — never a made-up "disarmed". */
29575
+ const requireAlarm = (action) => {
29576
+ if (deps.alarm === void 0) throw new Error(`${action}: the alarm is not wired on this node`);
29577
+ return deps.alarm;
29578
+ };
28228
29579
  /** A rule the caller may EDIT — must exist and be owned by the caller. */
28229
29580
  const assertOwnsRule = (ruleId) => {
28230
29581
  const rule = deps.ruleStore.get(ruleId);
@@ -28445,6 +29796,31 @@ function makeNcActionHandlers(deps) {
28445
29796
  } });
28446
29797
  return { success: true };
28447
29798
  },
29799
+ "nc.getAlarmStatus": async (_input, caller) => {
29800
+ requireAdmin(caller);
29801
+ return requireAlarm("nc.getAlarmStatus").status();
29802
+ },
29803
+ "nc.armAlarm": async (input, caller) => {
29804
+ const c = requireAdmin(caller);
29805
+ return requireAlarm("nc.armAlarm").arm(input.mode, c.userId);
29806
+ },
29807
+ "nc.armAlarmAnyway": async (input, caller) => {
29808
+ const c = requireAdmin(caller);
29809
+ return requireAlarm("nc.armAlarmAnyway").armAnyway(input.refusalId, c.userId);
29810
+ },
29811
+ "nc.disarmAlarm": async (_input, caller) => {
29812
+ const c = requireAdmin(caller);
29813
+ await requireAlarm("nc.disarmAlarm").disarm(c.userId);
29814
+ return { success: true };
29815
+ },
29816
+ "nc.getAlarmConfig": async (_input, caller) => {
29817
+ requireAdmin(caller);
29818
+ return requireAlarm("nc.getAlarmConfig").getConfig();
29819
+ },
29820
+ "nc.setAlarmConfig": async (input, caller) => {
29821
+ requireAdmin(caller);
29822
+ return requireAlarm("nc.setAlarmConfig").setConfig(input.patch);
29823
+ },
28448
29824
  "nc.listAiProfiles": async (_input, caller) => {
28449
29825
  requireCaller$1(caller);
28450
29826
  const list = deps.listAiProfiles;
@@ -70649,8 +72025,27 @@ var LinkedCamerasCache = class {
70649
72025
  deps;
70650
72026
  ttlMs;
70651
72027
  index = /* @__PURE__ */ new Map();
72028
+ /**
72029
+ * source deviceId → EVERY camera that has it among its linked devices,
72030
+ * ignoring the tracked-events opt-in. Built in the same loop as
72031
+ * {@link index}; the opt-in governs timeline rows only.
72032
+ */
72033
+ allIndex = /* @__PURE__ */ new Map();
70652
72034
  /** Every camera id seen by the last successful build. */
70653
72035
  cameras = /* @__PURE__ */ new Set();
72036
+ /**
72037
+ * Each camera's links as last READ successfully. A later rebuild that cannot
72038
+ * read a camera's links carries these forward rather than dropping them: the
72039
+ * mirror stays authoritative until something contradicts it (D49).
72040
+ */
72041
+ linksByCamera = /* @__PURE__ */ new Map();
72042
+ /** False until a camera list has been read once. A later failed list keeps
72043
+ * the previous index, and this stays true. */
72044
+ cameraListRead = false;
72045
+ /** Listed cameras whose links have NEVER been read. Non-empty (or no camera
72046
+ * list yet) ⇒ the index has not answered: a device absent from it is
72047
+ * UNKNOWN, not unlinked (D49). */
72048
+ unanswered = [];
70654
72049
  /** Ms timestamp of the last build; null = never built / invalidated. */
70655
72050
  builtAt = null;
70656
72051
  building = null;
@@ -70664,6 +72059,32 @@ var LinkedCamerasCache = class {
70664
72059
  return this.index.get(sourceDeviceId) ?? [];
70665
72060
  }
70666
72061
  /**
72062
+ * EVERY camera that links `sourceDeviceId`, whether or not it opted the
72063
+ * device into tracked events ([] when none). The notification side asks this
72064
+ * one; timeline rows ask {@link camerasFor}.
72065
+ */
72066
+ async allCamerasFor(sourceDeviceId, nowMs = Date.now()) {
72067
+ await this.ensureFresh(nowMs);
72068
+ return this.allIndex.get(sourceDeviceId) ?? [];
72069
+ }
72070
+ /**
72071
+ * True when the index ANSWERED: a camera list has been read, and every listed
72072
+ * camera's links have been read at least once (a camera whose read failed
72073
+ * later answers with its last good links). False means an empty answer from the other methods
72074
+ * is not knowledge and must not be acted on as "linked to nothing".
72075
+ */
72076
+ async answered(nowMs = Date.now()) {
72077
+ await this.ensureFresh(nowMs);
72078
+ return this.cameraListRead && this.unanswered.length === 0;
72079
+ }
72080
+ /** What the index could not answer, as of the last build — no refresh. */
72081
+ gap() {
72082
+ return {
72083
+ cameraListRead: this.cameraListRead,
72084
+ unansweredCameraIds: [...this.unanswered]
72085
+ };
72086
+ }
72087
+ /**
70667
72088
  * True when `deviceId` is ITSELF a camera. Answered off the same build as
70668
72089
  * {@link camerasFor} — a sensor cap bound on a camera needs both facts in
70669
72090
  * the same event, and a second enumeration would double the cost of the
@@ -70678,10 +72099,13 @@ var LinkedCamerasCache = class {
70678
72099
  this.builtAt = null;
70679
72100
  }
70680
72101
  /** Test/maintenance hook: replace the index directly. */
70681
- seed(index, builtAt, cameraIds = []) {
72102
+ seed(index, builtAt, cameraIds = [], allIndex = index) {
70682
72103
  this.index = new Map(index);
72104
+ this.allIndex = new Map(allIndex);
70683
72105
  this.cameras = new Set(cameraIds);
70684
72106
  this.builtAt = builtAt;
72107
+ this.cameraListRead = true;
72108
+ this.unanswered = [];
70685
72109
  }
70686
72110
  /** Rebuild (single-flight) when never built or past the TTL. */
70687
72111
  async ensureFresh(nowMs) {
@@ -70703,7 +72127,8 @@ var LinkedCamerasCache = class {
70703
72127
  *
70704
72128
  * A batch that REJECTS falls back rather than emptying the index: a stale
70705
72129
  * reverse index still routes sensor events, an empty one silently routes
70706
- * none.
72130
+ * none. A camera whose links could not be read is simply ABSENT from the
72131
+ * returned map — the caller carries its previous links forward.
70707
72132
  */
70708
72133
  async resolveLinks(cameraIds) {
70709
72134
  const batch = this.deps.linkedDevicesBatch;
@@ -70718,23 +72143,38 @@ var LinkedCamerasCache = class {
70718
72143
  const { devices } = await this.deps.linkedDevices.getLinkedDevices({ deviceId: cameraId });
70719
72144
  out.set(cameraId, devices);
70720
72145
  } catch (err) {
70721
- this.deps.onError?.("getLinkedDevices", err);
72146
+ this.deps.onError?.("getLinkedDevices", err, cameraId);
70722
72147
  }
70723
72148
  return out;
70724
72149
  }
70725
72150
  async rebuild(nowMs) {
70726
72151
  try {
70727
72152
  const cameraIds = await this.deps.cameras.listCameraIds();
70728
- const linksByCamera = await this.resolveLinks(cameraIds);
72153
+ const fresh = await this.resolveLinks(cameraIds);
72154
+ const linksByCamera = /* @__PURE__ */ new Map();
72155
+ const unanswered = [];
72156
+ for (const cameraId of cameraIds) {
72157
+ const links = fresh.get(cameraId) ?? this.linksByCamera.get(cameraId);
72158
+ if (links === void 0) unanswered.push(cameraId);
72159
+ else linksByCamera.set(cameraId, links);
72160
+ }
70729
72161
  const next = /* @__PURE__ */ new Map();
72162
+ const nextAll = /* @__PURE__ */ new Map();
70730
72163
  for (const cameraId of cameraIds) for (const d of linksByCamera.get(cameraId) ?? []) {
72164
+ const all = nextAll.get(d.deviceId);
72165
+ if (all === void 0) nextAll.set(d.deviceId, [cameraId]);
72166
+ else if (!all.includes(cameraId)) all.push(cameraId);
70731
72167
  if (d.producesTrackedEvents === false) continue;
70732
72168
  const list = next.get(d.deviceId);
70733
72169
  if (list === void 0) next.set(d.deviceId, [cameraId]);
70734
72170
  else if (!list.includes(cameraId)) list.push(cameraId);
70735
72171
  }
70736
72172
  this.index = next;
72173
+ this.allIndex = nextAll;
70737
72174
  this.cameras = new Set(cameraIds);
72175
+ this.linksByCamera = linksByCamera;
72176
+ this.unanswered = unanswered;
72177
+ this.cameraListRead = true;
70738
72178
  this.builtAt = nowMs;
70739
72179
  } catch (err) {
70740
72180
  this.deps.onError?.("listCameraIds", err);
@@ -70758,13 +72198,25 @@ async function resolveSensorEventCameras(cache, data) {
70758
72198
  const linkedCameraIds = await cache.camerasFor(data.deviceId);
70759
72199
  const isCamera = await cache.isCamera(data.deviceId);
70760
72200
  const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
70761
- if (cameraIds.length === 0) return {
72201
+ if (cameraIds.length > 0) return {
72202
+ cameraIds,
72203
+ dropReason: null,
72204
+ linkedCameraIds: []
72205
+ };
72206
+ if (isCamera) return {
72207
+ cameraIds,
72208
+ dropReason: "camera-native-cap",
72209
+ linkedCameraIds: []
72210
+ };
72211
+ if (!await cache.answered()) return {
70762
72212
  cameraIds,
70763
- dropReason: isCamera ? "camera-native-cap" : "no-camera"
72213
+ dropReason: "camera-index-unknown",
72214
+ linkedCameraIds: []
70764
72215
  };
70765
72216
  return {
70766
72217
  cameraIds,
70767
- dropReason: null
72218
+ dropReason: "no-camera",
72219
+ linkedCameraIds: await cache.allCamerasFor(data.deviceId)
70768
72220
  };
70769
72221
  }
70770
72222
  /**
@@ -70774,20 +72226,37 @@ async function resolveSensorEventCameras(cache, data) {
70774
72226
  * camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
70775
72227
  * self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
70776
72228
  * churn costs one map lookup. Returns the number of rows inserted (0 when
70777
- * unmapped, or when the device belongs to no camera at all).
72229
+ * unmapped, or when no camera takes a row for the device — a non-camera
72230
+ * sensor then goes to `onUnowned` instead, evaluated without a row, once the
72231
+ * camera index answered).
70778
72232
  * Telemetry-lossy by design (D8) — inserts are best-effort.
70779
72233
  */
70780
72234
  async function ingestSensorStateChange(deps, data, timestamp) {
70781
72235
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
70782
72236
  if (descriptor === void 0) return 0;
70783
- const { cameraIds, dropReason } = await resolveSensorEventCameras(deps.cache, data);
72237
+ const { cameraIds, dropReason, linkedCameraIds } = await resolveSensorEventCameras(deps.cache, data);
72238
+ const slice = data.slice;
72239
+ const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
72240
+ const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
72241
+ if (dropReason === "no-camera" && deps.onUnowned !== void 0) {
72242
+ const ev = {
72243
+ id: makeId(),
72244
+ deviceId: data.deviceId,
72245
+ sourceDeviceId: data.deviceId,
72246
+ kind: descriptor.kind,
72247
+ value,
72248
+ timestamp
72249
+ };
72250
+ deps.onUnowned({
72251
+ event: ev,
72252
+ linkedCameraIds
72253
+ });
72254
+ return 0;
72255
+ }
70784
72256
  if (dropReason !== null) {
70785
72257
  deps.onDropped?.(dropReason, descriptor.kind);
70786
72258
  return 0;
70787
72259
  }
70788
- const slice = data.slice;
70789
- const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
70790
- const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
70791
72260
  let inserted = 0;
70792
72261
  for (const cameraId of cameraIds) {
70793
72262
  const ev = {
@@ -71857,12 +73326,12 @@ async function projectSensorMarkers(deps, data, timestamp) {
71857
73326
  *
71858
73327
  * `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
71859
73328
  * ten that have a boolean whose edge means something. `doorbell`, `button`,
71860
- * `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
71861
- * — their ARRIVAL is the event, and three presses in a row are three markers.
73329
+ * `presence`, `enum-sensor` and `event-emitter` are NOT levels — their ARRIVAL
73330
+ * is the event, and three presses in a row are three markers.
71862
73331
  * Gating them would re-ship the failure of 2026-08-08, when camera 615's press
71863
73332
  * produced a row, a ring, a matched rule and no marker.
71864
73333
  *
71865
- * ## A cover is a level whose level is a WORD
73334
+ * ## A cover — and a lock — is a level whose level is a WORD
71866
73335
  *
71867
73336
  * `cover` is the sixteenth cap, and it is neither of the above. Its level is
71868
73337
  * the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
@@ -71890,6 +73359,39 @@ async function projectSensorMarkers(deps, data, timestamp) {
71890
73359
  * is reported once as if the word had just changed. The alarm needs covers
71891
73360
  * to be eventful to see one open (D630).
71892
73361
  *
73362
+ * `lock-control` follows the same rule on its `state` word, with `locked` as
73363
+ * the resting word. It used to pass through as "arrival is the event", and a
73364
+ * lock is exactly what the alarm watches: a placeholder `unlocked` written by
73365
+ * a provider constructor after a respawn was an unlock, and the armed panel
73366
+ * could fire on it (D630).
73367
+ *
73368
+ * ## A placeholder slice is not a state
73369
+ *
73370
+ * Provider constructors write a cold slice before they have read the device —
73371
+ * `{ entryOpen: false, lastChangedAt: 0 }` for an HA binary sensor, `unlocked`
73372
+ * for an HA or Homematic lock, `closed` for an HA cover. `lastChangedAt: 0`
73373
+ * is the one field that says "nobody has read this yet", and a slice carrying
73374
+ * it is dropped BEFORE any cap-specific rule (`cold-slice`) and writes no
73375
+ * memory. Remembered, it became a baseline the real state then "changed"
73376
+ * from: a placeholder `closed` followed by the real `open` was an opening,
73377
+ * and while the panel was armed that opening could lift an exclusion and fire
73378
+ * the siren. Dropped, the real slice is a first sighting and goes through the
73379
+ * freshness floor like any other.
73380
+ *
73381
+ * The change stamp is a three-way convention every provider writes and this
73382
+ * gate and the alarm (`alarm-openings.ts`) both read:
73383
+ *
73384
+ * - `0` — a placeholder: the VALUE is unknown. Dropped here, `unknown` there.
73385
+ * - `> 0` but below the floor — a KNOWN value whose change time is old or
73386
+ * unknown. A provider that REPLAYS a cached value after a respawn and has
73387
+ * no upstream change time (Homematic: XML-RPC `getParamset` carries none)
73388
+ * stamps it `1`, epoch + 1 ms (`HM_SEEDED_CHANGED_AT`). Here it seeds a
73389
+ * baseline and is never a first-sighting event; there it is a real reading,
73390
+ * so an arm check still blocks on a lock that is really unlocked. Stamping
73391
+ * such a replay `now` is the laundering this rules out: a lock unlocked for
73392
+ * hours cleared the floor as a fresh unlock after a hub restart.
73393
+ * - at or above the floor — a change that happened then.
73394
+ *
71893
73395
  * ## Nothing here logs, and no drop is silent
71894
73396
  *
71895
73397
  * The gate is pure towards the world (no bus, no logger, no clock of its own —
@@ -71904,9 +73406,15 @@ async function projectSensorMarkers(deps, data, timestamp) {
71904
73406
  * Caps whose level is a string word rather than a boolean: cap → slice field.
71905
73407
  * See "A cover is a level whose level is a WORD" above.
71906
73408
  */
71907
- var WORD_LEVEL_FIELD = { cover: "state" };
73409
+ var WORD_LEVEL_FIELD = {
73410
+ cover: "state",
73411
+ "lock-control": "state"
73412
+ };
71908
73413
  /** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
71909
- var WORD_LEVEL_INACTIVE = { cover: "closed" };
73414
+ var WORD_LEVEL_INACTIVE = {
73415
+ cover: "closed",
73416
+ "lock-control": "locked"
73417
+ };
71910
73418
  var SensorTransitionGate = class {
71911
73419
  /** Last observed boolean, keyed by `${deviceId}:${capName}`. */
71912
73420
  lastValues = /* @__PURE__ */ new Map();
@@ -71929,11 +73437,13 @@ var SensorTransitionGate = class {
71929
73437
  /**
71930
73438
  * Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
71931
73439
  *
71932
- * A cap with no level passes through unconditionally. A cap that HAS one
71933
- * passes only when its level moved — a boolean in either direction, or a
71934
- * word-level cap's word changing.
73440
+ * A placeholder slice (`lastChangedAt === 0`) is dropped first, for every
73441
+ * cap, and remembers nothing. Otherwise a cap with no level passes through
73442
+ * unconditionally, and a cap that HAS one passes only when its level moved —
73443
+ * a boolean in either direction, or a word-level cap's word changing.
71935
73444
  */
71936
73445
  observe(deviceId, capName, slice) {
73446
+ if (slice["lastChangedAt"] === 0) return this.drop(deviceId, capName, "cold-slice");
71937
73447
  const wordField = WORD_LEVEL_FIELD[capName];
71938
73448
  if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
71939
73449
  if (!isSourceCap(capName)) return { changed: true };
@@ -72018,6 +73528,36 @@ var SensorTransitionGate = class {
72018
73528
  });
72019
73529
  }
72020
73530
  };
73531
+ var CameraIndexUnknownDrops = class {
73532
+ drops = /* @__PURE__ */ new Map();
73533
+ windowStartedAt;
73534
+ windowMs;
73535
+ constructor(options) {
73536
+ this.windowStartedAt = options.nowMs;
73537
+ this.windowMs = options.windowMs ?? 6e4;
73538
+ }
73539
+ record(deviceId, capName) {
73540
+ const key = `${deviceId}:${capName}`;
73541
+ const prior = this.drops.get(key);
73542
+ this.drops.set(key, {
73543
+ deviceId,
73544
+ capName,
73545
+ count: (prior?.count ?? 0) + 1
73546
+ });
73547
+ }
73548
+ /**
73549
+ * The window's drops, sorted by `(deviceId, capName)`, once the window has
73550
+ * elapsed; [] before that or when nothing dropped. Taking resets the window.
73551
+ */
73552
+ takeSummary(nowMs) {
73553
+ if (nowMs - this.windowStartedAt < this.windowMs) return [];
73554
+ this.windowStartedAt = nowMs;
73555
+ if (this.drops.size === 0) return [];
73556
+ const out = [...this.drops.values()].sort((a, b) => a.deviceId - b.deviceId || a.capName.localeCompare(b.capName));
73557
+ this.drops.clear();
73558
+ return out;
73559
+ }
73560
+ };
72021
73561
  //#endregion
72022
73562
  //#region src/pipeline-analytics/services/synthetic-track.ts
72023
73563
  /**
@@ -77174,6 +78714,8 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
77174
78714
  startedAtMs: Date.now(),
77175
78715
  now: () => Date.now()
77176
78716
  });
78717
+ /** `camera-index-unknown` drops, summarised per window — see the file. */
78718
+ cameraIndexUnknownDrops = new CameraIndexUnknownDrops({ nowMs: Date.now() });
77177
78719
  /** The ONE projector of non-spatial events into synthetic snapshot-carrying
77178
78720
  * tracks — linked sensor/control state changes AND audio markers. */
77179
78721
  syntheticTracks = null;
@@ -78596,6 +80138,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78596
80138
  previewTexts: center.textCatalog,
78597
80139
  ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
78598
80140
  artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
80141
+ alarm: center.alarmActions(),
78599
80142
  ruleStatus: {
78600
80143
  forRule: (rule) => center.ruleStatusFor(rule),
78601
80144
  forTimelapseRule: (rule) => center.timelapseStatusFor(rule, async ({ ruleId, deviceId }) => {
@@ -78724,19 +80267,12 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78724
80267
  const panel = this.alarmPanelDevice;
78725
80268
  if (panel === null || this.alarmPanelWired) return;
78726
80269
  this.alarmPanelWired = true;
78727
- center.setAlarmPanel({
78728
- deviceId: panel.id,
78729
- settings: () => panel.settings(),
78730
- applySettings: (patch) => panel.applySettings(patch),
78731
- availableModes: () => NC_ALARM_MODES,
78732
- noteRuleTrigger: (source) => panel.noteRuleTrigger(source),
78733
- excludedDevices: () => panel.excludedDevices(),
78734
- observeContact: (deviceId, reading, now) => panel.observeContact(deviceId, reading, now)
78735
- });
80270
+ center.setAlarmPanel(ncAlarmPanelPort(panel));
78736
80271
  panel.setPublishHook((report) => center.onAlarmPublish(report));
78737
80272
  panel.setArmGuard(center.alarmArmGuard());
78738
80273
  center.onAlarmPublish({
78739
80274
  state: panel.currentState(),
80275
+ target: panel.currentTarget(),
78740
80276
  transition: null
78741
80277
  });
78742
80278
  this.ctx.logger.info("alarm panel wired to the notification center", { tags: { deviceId: panel.id } });
@@ -79179,10 +80715,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79179
80715
  } },
79180
80716
  linkedDevices: { getLinkedDevices: (input) => api.deviceManager.getLinkedDevices.query(input) },
79181
80717
  linkedDevicesBatch: { getLinkedDevicesBatch: (input) => api.deviceManager.getLinkedDevicesBatch.query(input) },
79182
- onError: (scope, err) => logger.warn("linked-cameras cache refresh failed", { meta: {
79183
- scope,
79184
- error: errMsg$1(err)
79185
- } })
80718
+ onError: (scope, err, cameraId) => logger.warn("linked-cameras cache refresh failed", {
80719
+ ...cameraId !== void 0 ? { tags: { deviceId: cameraId } } : {},
80720
+ meta: {
80721
+ scope,
80722
+ error: errMsg$1(err)
80723
+ }
80724
+ })
79186
80725
  });
79187
80726
  const syntheticTracks = new SyntheticTrackMaterializer({
79188
80727
  snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
@@ -79635,6 +81174,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
79635
81174
  }
79636
81175
  return out;
79637
81176
  },
81177
+ readDeviceOpenings: (ids) => readAlarmOpenings(ids, (deviceId) => api.deviceManager.loadRuntimeState.query({ deviceId }), this.ctx.logger),
79638
81178
  readSceneStates: () => (this.sceneProvider?.listAllEnabled() ?? []).map(({ deviceId, monitor }) => ({
79639
81179
  sceneId: monitor.id,
79640
81180
  deviceId,
@@ -85366,11 +86906,13 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85366
86906
  if (store === null || cache === null) return;
85367
86907
  if (!this.sensorSliceCarriedATransition(data)) return;
85368
86908
  const persisted = [];
86909
+ const unowned = [];
85369
86910
  try {
85370
86911
  await ingestSensorStateChange({
85371
86912
  sink: store,
85372
86913
  cache,
85373
86914
  onPersisted: (ev) => persisted.push(ev),
86915
+ onUnowned: (u) => unowned.push(u),
85374
86916
  onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
85375
86917
  }, data, timestamp);
85376
86918
  } catch (err) {
@@ -85382,6 +86924,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85382
86924
  }
85383
86925
  });
85384
86926
  }
86927
+ this.flushCameraIndexUnknownDrops();
85385
86928
  const markerByCamera = /* @__PURE__ */ new Map();
85386
86929
  const materializer = this.syntheticTracks;
85387
86930
  if (materializer !== null) try {
@@ -85425,7 +86968,39 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85425
86968
  }
85426
86969
  });
85427
86970
  }
85428
- for (const ev of persisted) this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
86971
+ for (const ev of persisted) try {
86972
+ this.notificationCenter?.onSensorEventPersisted(ev, markerByCamera.get(ev.deviceId)?.trackId);
86973
+ } catch (err) {
86974
+ this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
86975
+ tags: { deviceId: ev.deviceId },
86976
+ meta: {
86977
+ kind: ev.kind,
86978
+ sourceDeviceId: ev.sourceDeviceId,
86979
+ error: errMsg$1(err)
86980
+ }
86981
+ });
86982
+ }
86983
+ for (const { event: ev, linkedCameraIds } of unowned) {
86984
+ 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`, {
86985
+ tags: { deviceId: ev.sourceDeviceId },
86986
+ meta: {
86987
+ kind: ev.kind,
86988
+ linkedCameraIds: [...linkedCameraIds]
86989
+ }
86990
+ });
86991
+ try {
86992
+ this.notificationCenter?.onUnownedSensorEvent(ev, linkedCameraIds);
86993
+ } catch (err) {
86994
+ this.ctx.logger.warn("sensor event hand-off to the notification centre failed", {
86995
+ tags: { deviceId: ev.sourceDeviceId },
86996
+ meta: {
86997
+ kind: ev.kind,
86998
+ linkedCameraIds: [...linkedCameraIds],
86999
+ error: errMsg$1(err)
87000
+ }
87001
+ });
87002
+ }
87003
+ }
85429
87004
  }
85430
87005
  /**
85431
87006
  * True when this slice carried a real transition of its sensor.
@@ -85479,6 +87054,29 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85479
87054
  });
85480
87055
  }
85481
87056
  /**
87057
+ * One `info` line per (device, cap) per window for the evaluations refused
87058
+ * because the camera index has not answered, or none. It drops evaluations
87059
+ * (a contact that might trigger the alarm), so it is `info`; it is a COUNT
87060
+ * so a cold-start outage cannot log at bus rate. `unansweredCameraIds` names
87061
+ * the cameras whose links were never read (`cameraListRead: false` = not
87062
+ * even the camera list).
87063
+ */
87064
+ flushCameraIndexUnknownDrops() {
87065
+ const summary = this.cameraIndexUnknownDrops.takeSummary(Date.now());
87066
+ if (summary.length === 0) return;
87067
+ const gap = this.linkedCamerasCache?.gap();
87068
+ 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", {
87069
+ tags: { deviceId: drop.deviceId },
87070
+ meta: {
87071
+ capName: drop.capName,
87072
+ count: drop.count,
87073
+ reason: "camera-index-unknown",
87074
+ cameraListRead: gap?.cameraListRead ?? false,
87075
+ unansweredCameraIds: [...gap?.unansweredCameraIds ?? []]
87076
+ }
87077
+ });
87078
+ }
87079
+ /**
85482
87080
  * Say out loud that a MAPPED sensor cap ingested nothing, and why.
85483
87081
  *
85484
87082
  * BOTH branches are `debug`, deliberately. After the self-camera fix neither
@@ -85489,7 +87087,10 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85489
87087
  * binds a native `motion` cap on the camera device and motion churns
85490
87088
  * continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
85491
87089
  * log flood measured in millions of lines a day — its own outage.
85492
- * - `no-camera` is every sensor in the fleet that no camera links.
87090
+ * - `no-camera` is every sensor in the fleet that no camera links. Since
87091
+ * D630 §6 it is still EVALUATED by the notification rules (through
87092
+ * `onUnowned`) — this line only says no timeline row was written, and it
87093
+ * fires only when no one took the camera-less event.
85493
87094
  *
85494
87095
  * What must never happen again is the branch being INVISIBLE: a doorbell
85495
87096
  * press that ingested nothing produced not one line for months. A debug line
@@ -85497,7 +87098,11 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85497
87098
  * dark" the moment anyone asks it.
85498
87099
  */
85499
87100
  logSensorIngestDrop(data, reason, kind) {
85500
- 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";
87101
+ if (reason === "camera-index-unknown") {
87102
+ this.cameraIndexUnknownDrops.record(data.deviceId, data.capName);
87103
+ return;
87104
+ }
87105
+ 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";
85501
87106
  this.ctx.logger.debug(message, {
85502
87107
  tags: { deviceId: data.deviceId },
85503
87108
  meta: {