@camstack/addon-post-analysis 1.2.330 → 1.2.332

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_clip_model_registry = require("../clip-model-registry-DQmmIYXS.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-CthiqMDB.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_clip_model_registry.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -370,6 +370,20 @@ function skippedFor(deviceIds, skipProbe) {
370
370
  return out;
371
371
  }
372
372
  /**
373
+ * `active` / `skipped` for the arm / arming TITLE — `(n attivi, m saltati)`.
374
+ *
375
+ * Named coverage only: an `allDevices` mode has no finite Active set, so the
376
+ * caller picks the `.all` title key instead of stuffing a fake number here.
377
+ */
378
+ function armCountParams(coverage, bypassed = []) {
379
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
380
+ const excluded = new Set(bypassed);
381
+ return {
382
+ active: `${coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id)).length}`,
383
+ skipped: `${skipped.size}`
384
+ };
385
+ }
386
+ /**
373
387
  * Whether this mode arms anything, and if not, which way it fails.
374
388
  *
375
389
  * An `allDevices` mode is never a gap: `skippedDevices` only ever probes the
@@ -384,28 +398,13 @@ function alarmCoverageGap(coverage) {
384
398
  if (coverage.skippedDevices.length < coverage.deviceIds.length) return null;
385
399
  return "all-skipped";
386
400
  }
401
+ /** Device names as a lock-screen bullet list (one name per line). */
402
+ function bulletDeviceNames(names) {
403
+ return names.map((n) => `• ${n}`).join("\n");
404
+ }
387
405
  /**
388
- * "Attivi: Cancello, Garage — Esclusi: Porta retro."
389
- *
390
- * A LIST, not a sentence. The mode is in the title and the body is one
391
- * `Label: names` line per device group, because this is read on a lock screen
392
- * and the question is always "which one" — the prose version ("3 dispositivi
393
- * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
394
- * window. Each variant still owns its own catalog key, so the Texts tab can
395
- * re-word "nothing is armed" without touching "these are armed".
396
- *
397
- * Names, not ids: this is read by a person standing at a door. An id that has
398
- * no name falls back to a NAMED, translated placeholder rather than being
399
- * dropped — a silently shorter list would understate what is armed, which is
400
- * the one error this message must not make. (It used to hard-code the English
401
- * `Device <id>` into both languages.)
402
- *
403
- * **Always a sentence.** It used to return null for a mode no rule is gated on,
404
- * on the grounds that "Night armed" followed by an unwatched night is a false
405
- * assurance — true, but the caller then emitted `alarm-armed` anyway with a
406
- * fallback body key that existed in NEITHER locale, so the operator received a
407
- * title and an empty line. Silence is not the third option here: SAYING that
408
- * the mode arms nothing is.
406
+ * Skipped / excluded devices FIRST, then active — each name on its own
407
+ * `•` line. The mode is in the title; the body answers "which ones".
409
408
  */
410
409
  function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
411
410
  const rules = `${coverage.ruleCount}`;
@@ -414,7 +413,11 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
414
413
  const gap = alarmCoverageGap(coverage);
415
414
  if (gap === "uncovered") return {
416
415
  bodyKey: "alarm.arm.body.uncovered",
417
- textParams: { mode },
416
+ textParams: {
417
+ mode,
418
+ active: "0",
419
+ skipped: "0"
420
+ },
418
421
  textCount: 0
419
422
  };
420
423
  if (gap === "all-skipped") {
@@ -425,7 +428,9 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
425
428
  rules,
426
429
  mode,
427
430
  devices: `${names.length}`,
428
- names: names.join(", ")
431
+ names: bulletDeviceNames(names),
432
+ active: "0",
433
+ skipped: `${names.length}`
429
434
  },
430
435
  textCount: names.length
431
436
  };
@@ -438,9 +443,10 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
438
443
  rules,
439
444
  mode,
440
445
  count: `${names.length}`,
441
- names: names.join(", ")
446
+ names: bulletDeviceNames(names)
442
447
  },
443
- textCount: names.length
448
+ textCount: names.length,
449
+ titleKeySuffix: "all"
444
450
  };
445
451
  }
446
452
  if (coverage.allDevices) return {
@@ -449,25 +455,30 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
449
455
  rules,
450
456
  mode
451
457
  },
452
- textCount: coverage.ruleCount
458
+ textCount: coverage.ruleCount,
459
+ titleKeySuffix: "all"
453
460
  };
454
461
  if (coverage.deviceIds.length === 0) return {
455
462
  bodyKey: "alarm.arm.body.noDevices",
456
463
  textParams: {
457
464
  rules,
458
- mode
465
+ mode,
466
+ active: "0",
467
+ skipped: "0"
459
468
  },
460
469
  textCount: coverage.ruleCount
461
470
  };
462
471
  const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
463
472
  const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
473
+ const counts = armCountParams(coverage);
464
474
  if (skipped.size === 0) return {
465
475
  bodyKey: "alarm.arm.body.devices",
466
476
  textParams: {
467
477
  rules,
468
478
  mode,
469
479
  devices: `${names.length}`,
470
- names: names.join(", ")
480
+ names: bulletDeviceNames(names),
481
+ ...counts
471
482
  },
472
483
  textCount: names.length
473
484
  };
@@ -477,8 +488,9 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
477
488
  rules,
478
489
  mode,
479
490
  devices: `${names.length}`,
480
- names: names.join(", "),
481
- skippedNames: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
491
+ names: bulletDeviceNames(names),
492
+ skippedNames: bulletDeviceNames(coverage.skippedDevices.map((s) => deviceName(s.deviceId))),
493
+ ...counts
482
494
  },
483
495
  textCount: names.length
484
496
  };
@@ -493,7 +505,7 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
493
505
  function armedWithBypass(coverage, bypassed, deviceName) {
494
506
  const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
495
507
  const excluded = new Set(bypassed);
496
- const bypassedNames = bypassed.map(deviceName).join(", ");
508
+ const bypassedNames = bulletDeviceNames(bypassed.map(deviceName));
497
509
  if (coverage.allDevices) return {
498
510
  bodyKey: "alarm.arm.body.bypassedAll",
499
511
  textParams: {
@@ -501,10 +513,12 @@ function armedWithBypass(coverage, bypassed, deviceName) {
501
513
  mode: coverage.mode,
502
514
  bypassedNames
503
515
  },
504
- textCount: coverage.ruleCount
516
+ textCount: coverage.ruleCount,
517
+ titleKeySuffix: "all"
505
518
  };
506
519
  const armedIds = coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id));
507
- const names = armedIds.map(deviceName).join(", ");
520
+ const names = bulletDeviceNames(armedIds.map(deviceName));
521
+ const counts = armCountParams(coverage, bypassed);
508
522
  return {
509
523
  bodyKey: "alarm.arm.body.bypassed",
510
524
  textParams: {
@@ -512,7 +526,8 @@ function armedWithBypass(coverage, bypassed, deviceName) {
512
526
  mode: coverage.mode,
513
527
  devices: `${armedIds.length}`,
514
528
  names,
515
- bypassedNames
529
+ bypassedNames,
530
+ ...counts
516
531
  },
517
532
  textCount: armedIds.length
518
533
  };
@@ -533,69 +548,159 @@ function buildArmRefusedAnnouncement(mode, names, withArmAnyway) {
533
548
  textParams: {
534
549
  mode,
535
550
  devices: `${names.length}`,
536
- names: names.join(", ")
551
+ names: bulletDeviceNames(names)
537
552
  },
538
553
  textCount: names.length
539
554
  };
540
555
  }
541
556
  /**
542
- * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
557
+ * The same coverage lists as {@link buildArmAnnouncement}, said at second 0.
543
558
  *
544
- * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
545
- * on the way out of a door — and it carries exactly one thing the arm sentence
546
- * cannot: the warning arrives while the operator can still disarm and fix it.
547
- * A mode that arms nothing is worth interrupting somebody for at second 0 and
548
- * merely worth recording at second 30.
549
- *
550
- * **It never repeats the device list.** The countdown lives in the TITLE
551
- * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
552
- * list arrives thirty seconds later in the arm announcement — printing it
553
- * twice is the verbosity that made the operator stop reading both. So the body
554
- * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
559
+ * The countdown lives in the TITLE (`system.alarm-arming.title.<mode>` renders
560
+ * `{{seconds}}`); the body is the labelled device lists — Active and Skipped
561
+ * (and Bypassed when the operator confirmed an open sensor). Saying the lists
562
+ * only at second 30 left the exit-delay push as "No exclusions" / a lone
563
+ * Esclusi line, which is exactly when the operator can still disarm and fix
564
+ * a mute or an offline camera.
555
565
  *
556
566
  * `seconds` stays in the params because the title asks for it, and `textCount`
557
567
  * stays with it: a row enqueued before this deploy still pluralises on the
558
568
  * countdown it was written for.
559
569
  */
560
570
  function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
571
+ const mode = coverage.mode;
572
+ const rules = `${coverage.ruleCount}`;
561
573
  const textParams = {
562
- mode: coverage.mode,
574
+ mode,
563
575
  seconds: `${seconds}`
564
576
  };
565
- if (bypassed.length > 0) return {
566
- bodyKey: "alarm.arming.body.bypassed",
577
+ if (bypassed.length > 0) return armingWithBypass(coverage, seconds, bypassed, deviceName);
578
+ const gap = alarmCoverageGap(coverage);
579
+ if (gap === "uncovered") return {
580
+ bodyKey: "alarm.arming.body.uncovered",
567
581
  textParams: {
568
582
  ...textParams,
569
- bypassedNames: bypassed.map(deviceName).join(", ")
583
+ active: "0",
584
+ skipped: "0"
570
585
  },
571
586
  textCount: seconds
572
587
  };
573
- const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
574
- const gap = alarmCoverageGap(coverage);
575
- if (gap === "uncovered") return {
576
- bodyKey: "alarm.arming.body.uncovered",
577
- textParams,
578
- textCount: seconds
588
+ if (gap === "all-skipped") {
589
+ const names = coverage.skippedDevices.map((s) => deviceName(s.deviceId));
590
+ return {
591
+ bodyKey: "alarm.arming.body.allSkipped",
592
+ textParams: {
593
+ ...textParams,
594
+ rules,
595
+ devices: `${names.length}`,
596
+ names: bulletDeviceNames(names),
597
+ active: "0",
598
+ skipped: `${names.length}`
599
+ },
600
+ textCount: seconds
601
+ };
602
+ }
603
+ if (coverage.allDevices && coverage.allDevicesExcept.length > 0) {
604
+ const names = coverage.allDevicesExcept.map(deviceName);
605
+ return {
606
+ bodyKey: "alarm.arming.body.allDevicesExcept",
607
+ textParams: {
608
+ ...textParams,
609
+ rules,
610
+ count: `${names.length}`,
611
+ names: bulletDeviceNames(names)
612
+ },
613
+ textCount: seconds,
614
+ titleKeySuffix: "all"
615
+ };
616
+ }
617
+ if (coverage.allDevices) return {
618
+ bodyKey: "alarm.arming.body.allDevices",
619
+ textParams: {
620
+ ...textParams,
621
+ rules
622
+ },
623
+ textCount: seconds,
624
+ titleKeySuffix: "all"
579
625
  };
580
- if (gap === "all-skipped") return {
581
- bodyKey: "alarm.arming.body.allSkipped",
626
+ if (coverage.deviceIds.length === 0) return {
627
+ bodyKey: "alarm.arming.body.noDevices",
582
628
  textParams: {
583
629
  ...textParams,
584
- names: skippedNames
630
+ rules,
631
+ active: "0",
632
+ skipped: "0"
585
633
  },
586
634
  textCount: seconds
587
635
  };
588
- if (coverage.skippedDevices.length > 0) return {
589
- bodyKey: "alarm.arming.body.skipped",
636
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
637
+ const names = coverage.deviceIds.filter((id) => !skipped.has(id)).map(deviceName);
638
+ const counts = armCountParams(coverage);
639
+ if (skipped.size === 0) return {
640
+ bodyKey: "alarm.arming.body.devices",
590
641
  textParams: {
591
642
  ...textParams,
592
- names: skippedNames
643
+ rules,
644
+ devices: `${names.length}`,
645
+ names: bulletDeviceNames(names),
646
+ ...counts
593
647
  },
594
- textCount: coverage.skippedDevices.length
648
+ textCount: seconds
595
649
  };
596
650
  return {
597
- bodyKey: "alarm.arming.body",
651
+ bodyKey: "alarm.arming.body.devicesSkipped",
652
+ textParams: {
653
+ ...textParams,
654
+ rules,
655
+ devices: `${names.length}`,
656
+ names: bulletDeviceNames(names),
657
+ skippedNames: bulletDeviceNames(coverage.skippedDevices.map((s) => deviceName(s.deviceId))),
658
+ ...counts
659
+ },
660
+ textCount: seconds
661
+ };
662
+ }
663
+ /**
664
+ * "Attivi: Porta retro, Cancello — Bypassati: Finestra veranda" while the exit
665
+ * delay still runs — same halves as {@link armedWithBypass}, with `seconds`
666
+ * frozen for the title.
667
+ */
668
+ function armingWithBypass(coverage, seconds, bypassed, deviceName) {
669
+ const skipped = new Set(coverage.skippedDevices.map((s) => s.deviceId));
670
+ const excluded = new Set(bypassed);
671
+ const bypassedNames = bulletDeviceNames(bypassed.map(deviceName));
672
+ const textParams = {
673
+ mode: coverage.mode,
674
+ seconds: `${seconds}`,
675
+ rules: `${coverage.ruleCount}`,
676
+ bypassedNames
677
+ };
678
+ if (coverage.allDevices) return {
679
+ bodyKey: "alarm.arming.body.bypassedAll",
598
680
  textParams,
681
+ textCount: seconds,
682
+ titleKeySuffix: "all"
683
+ };
684
+ const armedIds = coverage.deviceIds.filter((id) => !skipped.has(id) && !excluded.has(id));
685
+ const counts = armCountParams(coverage, bypassed);
686
+ if (armedIds.length === 0) return {
687
+ bodyKey: "alarm.arming.body.bypassedOnly",
688
+ textParams: {
689
+ mode: coverage.mode,
690
+ seconds: `${seconds}`,
691
+ bypassedNames,
692
+ ...counts
693
+ },
694
+ textCount: seconds
695
+ };
696
+ return {
697
+ bodyKey: "alarm.arming.body.bypassed",
698
+ textParams: {
699
+ ...textParams,
700
+ devices: `${armedIds.length}`,
701
+ names: bulletDeviceNames(armedIds.map(deviceName)),
702
+ ...counts
703
+ },
599
704
  textCount: seconds
600
705
  };
601
706
  }
@@ -3218,12 +3323,23 @@ var NcAlarmPanelDevice = class extends require_clip_model_registry.BaseDevice {
3218
3323
  * dropped it anyway, so the exclusion is not what ignored it.
3219
3324
  */
3220
3325
  announceIgnoredTrigger(deviceId, now) {
3326
+ const state = stateAt(this.machine, now);
3221
3327
  if (!isArmed(this.machine, now)) {
3222
3328
  this.ctx.logger.debug("alarm bypass not announced — the panel is not armed yet", {
3223
3329
  tags: { deviceId },
3224
3330
  meta: {
3225
3331
  panelDeviceId: this.id,
3226
- state: stateAt(this.machine, now)
3332
+ state
3333
+ }
3334
+ });
3335
+ return;
3336
+ }
3337
+ if (state === "triggered") {
3338
+ this.ctx.logger.debug("alarm bypass not announced — the siren is already sounding", {
3339
+ tags: { deviceId },
3340
+ meta: {
3341
+ panelDeviceId: this.id,
3342
+ state
3227
3343
  }
3228
3344
  });
3229
3345
  return;
@@ -5929,7 +6045,7 @@ function isAdoptable(record, nowMs) {
5929
6045
  * what `localDayMinute` does for the same input — one behaviour for a bad
5930
6046
  * zone, not two.
5931
6047
  */
5932
- function clockOf$7(atMs, timeZone) {
6048
+ function clockOf$6(atMs, timeZone) {
5933
6049
  if (timeZone !== void 0) try {
5934
6050
  return new Intl.DateTimeFormat("en-GB", {
5935
6051
  timeZone,
@@ -6065,7 +6181,7 @@ var TimelapseRenderer = class {
6065
6181
  includeAudio: false,
6066
6182
  maxLifeMs: await this.ports.exportLifeMs(),
6067
6183
  deleteAfterDownload: false,
6068
- title: `${request.ruleName} ${clockOf$7(window.startMs, request.timezone)}→${clockOf$7(window.endMs, request.timezone)}`,
6184
+ title: `${request.ruleName} ${clockOf$6(window.startMs, request.timezone)}→${clockOf$6(window.endMs, request.timezone)}`,
6069
6185
  timelapse: {
6070
6186
  everyMs,
6071
6187
  outputFps: request.framerate,
@@ -6396,7 +6512,7 @@ var TimelapseRenderer = class {
6396
6512
  ...meta,
6397
6513
  unservedWindowCount: verdict.unservedWindows.length,
6398
6514
  unservedWindows: verdict.unservedWindows.slice(0, 12).map((w) => ({
6399
- at: clockOf$7(request.window.startMs + w.fromSec * 1e3, request.timezone),
6515
+ at: clockOf$6(request.window.startMs + w.fromSec * 1e3, request.timezone),
6400
6516
  fromSec: Math.round(w.fromSec),
6401
6517
  toSec: Math.round(w.toSec),
6402
6518
  expectedDenseFrames: w.expectedDenseFrames,
@@ -13064,7 +13180,8 @@ function alarmTextKeys(transition, body) {
13064
13180
  const { kind, source } = transition;
13065
13181
  const combined = combinedAlarmText(kind, source);
13066
13182
  if (combined !== null) return combined;
13067
- const titleKey = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
13183
+ const baseTitle = transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`;
13184
+ const titleKey = body?.titleKeySuffix !== void 0 ? `${baseTitle}.${body.titleKeySuffix}` : baseTitle;
13068
13185
  if (body !== void 0) return {
13069
13186
  titleKey,
13070
13187
  bodyKey: body.bodyKey
@@ -14771,34 +14888,47 @@ var en_default = {
14771
14888
  "snooze.scope.anyRule": "A rule",
14772
14889
  "snooze.scope.rule": "“{{rule}}”",
14773
14890
  "alarm.arm.body.allDevices": "Active: all devices",
14774
- "alarm.arm.body.allDevicesExcept": "Active: all devices except {{names}}",
14891
+ "alarm.arm.body.allDevicesExcept": "Active: all devices except\n{{names}}",
14775
14892
  "alarm.arm.body.noDevices": "Active: no device restriction",
14776
- "alarm.arm.body.devices": "Active: {{names}}",
14777
- "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
14893
+ "alarm.arm.body.devices": "Active:\n{{names}}",
14894
+ "alarm.arm.body.devicesSkipped": "Excluded:\n{{skippedNames}}\nActive:\n{{names}}",
14778
14895
  "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
14779
- "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
14896
+ "alarm.arm.body.allSkipped": "Excluded:\n{{names}}\nActive: none",
14780
14897
  "alarm.arming.body": "No exclusions",
14898
+ "alarm.arming.body.allDevices": "Active: all devices",
14899
+ "alarm.arming.body.allDevicesExcept": "Active: all devices except\n{{names}}",
14900
+ "alarm.arming.body.noDevices": "Active: no device restriction",
14901
+ "alarm.arming.body.devices": "Active:\n{{names}}",
14902
+ "alarm.arming.body.devicesSkipped": "Excluded:\n{{skippedNames}}\nActive:\n{{names}}",
14781
14903
  "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
14782
- "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
14783
- "alarm.arming.body.skipped": "Excluded: {{names}}",
14784
- "alarm.arm.body.bypassed": "Active: {{names}}\nBypassed: {{bypassedNames}}",
14785
- "alarm.arm.body.bypassedAll": "Active: all devices\nBypassed: {{bypassedNames}}",
14786
- "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
14787
- "alarm.armRefused.body": "Open: {{names}}",
14788
- "alarm.armRefused.body.hint": "Open: {{names}}\nTap “Arm anyway” to arm with them excluded.",
14904
+ "alarm.arming.body.allSkipped": "Excluded:\n{{names}}\nActive: none",
14905
+ "alarm.arming.body.skipped": "Excluded:\n{{names}}",
14906
+ "alarm.arm.body.bypassed": "Bypassed:\n{{bypassedNames}}\nActive:\n{{names}}",
14907
+ "alarm.arm.body.bypassedAll": "Bypassed:\n{{bypassedNames}}\nActive: all devices",
14908
+ "alarm.arming.body.bypassed": "Bypassed:\n{{bypassedNames}}\nActive:\n{{names}}",
14909
+ "alarm.arming.body.bypassedAll": "Bypassed:\n{{bypassedNames}}\nActive: all devices",
14910
+ "alarm.arming.body.bypassedOnly": "Bypassed:\n{{bypassedNames}}",
14911
+ "alarm.armRefused.body": "Open:\n{{names}}",
14912
+ "alarm.armRefused.body.hint": "Open:\n{{names}}\nTap “Arm anyway” to arm with them excluded.",
14789
14913
  "alarm.device.unnamed": "Device {{id}}",
14790
14914
  "alarm.combined.title": "Alarm: {{deviceName}}",
14791
14915
  "system.alarm-arm-refused.title.home": "Arming home refused",
14792
14916
  "system.alarm-arm-refused.title.away": "Arming away refused",
14793
14917
  "system.alarm-arm-refused.title.night": "Arming night refused",
14794
14918
  "system.alarm-arm-refused.body": "Arming refused",
14795
- "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
14796
- "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
14797
- "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
14919
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s ({{active}} active, {{skipped}} skipped)",
14920
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s ({{active}} active, {{skipped}} skipped)",
14921
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s ({{active}} active, {{skipped}} skipped)",
14922
+ "system.alarm-arming.title.home.all": "Arming home — {{seconds}}s",
14923
+ "system.alarm-arming.title.away.all": "Arming away — {{seconds}}s",
14924
+ "system.alarm-arming.title.night.all": "Arming night — {{seconds}}s",
14798
14925
  "system.alarm-arming.body": "Arming",
14799
- "system.alarm-armed.title.home": "Alarm armed: home",
14800
- "system.alarm-armed.title.away": "Alarm armed: away",
14801
- "system.alarm-armed.title.night": "Alarm armed: night",
14926
+ "system.alarm-armed.title.home": "Alarm active: home ({{active}} active, {{skipped}} skipped)",
14927
+ "system.alarm-armed.title.away": "Alarm active: away ({{active}} active, {{skipped}} skipped)",
14928
+ "system.alarm-armed.title.night": "Alarm active: night ({{active}} active, {{skipped}} skipped)",
14929
+ "system.alarm-armed.title.home.all": "Alarm active: home (all)",
14930
+ "system.alarm-armed.title.away.all": "Alarm active: away (all)",
14931
+ "system.alarm-armed.title.night.all": "Alarm active: night (all)",
14802
14932
  "system.alarm-armed.body": "Alarm armed",
14803
14933
  "system.alarm-disarmed.title": "Disarmed",
14804
14934
  "system.alarm-disarmed.body": "Alarm disarmed",
@@ -14896,7 +15026,16 @@ var en_default = {
14896
15026
  "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
14897
15027
  "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} on {{nodeId}}",
14898
15028
  "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
14899
- "summary.line.tank-unread": "{{deviceName}} — the {{tank}} tank needs attention"
15029
+ "summary.line.tank-unread": "{{deviceName}} — the {{tank}} tank needs attention",
15030
+ "calendar-events.title": "{{rule}}: {{count}} upcoming",
15031
+ "calendar-events.empty": "No events in the next {{horizonDays}} day(s).",
15032
+ "calendar-events.day.today": "Today — {{date}}",
15033
+ "calendar-events.day.tomorrow": "★ Tomorrow — {{date}}",
15034
+ "calendar-events.day.other": "{{date}}",
15035
+ "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
15036
+ "calendar-events.line.allDay": "• All day {{title}}{{where}}",
15037
+ "calendar-events.where": " @ {{location}}",
15038
+ "calendar-events.more": "…and {{count}} more"
14900
15039
  };
14901
15040
  //#endregion
14902
15041
  //#region src/notification-center/texts/it.json
@@ -15100,34 +15239,47 @@ var it_default = {
15100
15239
  "snooze.scope.anyRule": "Una regola",
15101
15240
  "snooze.scope.rule": "“{{rule}}”",
15102
15241
  "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
15103
- "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne {{names}}",
15242
+ "alarm.arm.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne\n{{names}}",
15104
15243
  "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
15105
- "alarm.arm.body.devices": "Attivi: {{names}}",
15106
- "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
15244
+ "alarm.arm.body.devices": "Attivi:\n{{names}}",
15245
+ "alarm.arm.body.devicesSkipped": "Esclusi:\n{{skippedNames}}\nAttivi:\n{{names}}",
15107
15246
  "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
15108
- "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
15247
+ "alarm.arm.body.allSkipped": "Esclusi:\n{{names}}\nAttivi: nessuno",
15109
15248
  "alarm.arming.body": "Nessuna esclusione",
15249
+ "alarm.arming.body.allDevices": "Attivi: tutti i dispositivi",
15250
+ "alarm.arming.body.allDevicesExcept": "Attivi: tutti i dispositivi tranne\n{{names}}",
15251
+ "alarm.arming.body.noDevices": "Attivi: nessuna restrizione",
15252
+ "alarm.arming.body.devices": "Attivi:\n{{names}}",
15253
+ "alarm.arming.body.devicesSkipped": "Esclusi:\n{{skippedNames}}\nAttivi:\n{{names}}",
15110
15254
  "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
15111
- "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
15112
- "alarm.arming.body.skipped": "Esclusi: {{names}}",
15113
- "alarm.arm.body.bypassed": "Attivi: {{names}}\nBypassati: {{bypassedNames}}",
15114
- "alarm.arm.body.bypassedAll": "Attivi: tutti i dispositivi\nBypassati: {{bypassedNames}}",
15115
- "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
15116
- "alarm.armRefused.body": "Aperti: {{names}}",
15117
- "alarm.armRefused.body.hint": "Aperti: {{names}}\nTocca “Arma comunque” per armare escludendoli.",
15255
+ "alarm.arming.body.allSkipped": "Esclusi:\n{{names}}\nAttivi: nessuno",
15256
+ "alarm.arming.body.skipped": "Esclusi:\n{{names}}",
15257
+ "alarm.arm.body.bypassed": "Bypassati:\n{{bypassedNames}}\nAttivi:\n{{names}}",
15258
+ "alarm.arm.body.bypassedAll": "Bypassati:\n{{bypassedNames}}\nAttivi: tutti i dispositivi",
15259
+ "alarm.arming.body.bypassed": "Bypassati:\n{{bypassedNames}}\nAttivi:\n{{names}}",
15260
+ "alarm.arming.body.bypassedAll": "Bypassati:\n{{bypassedNames}}\nAttivi: tutti i dispositivi",
15261
+ "alarm.arming.body.bypassedOnly": "Bypassati:\n{{bypassedNames}}",
15262
+ "alarm.armRefused.body": "Aperti:\n{{names}}",
15263
+ "alarm.armRefused.body.hint": "Aperti:\n{{names}}\nTocca “Arma comunque” per armare escludendoli.",
15118
15264
  "alarm.device.unnamed": "Dispositivo {{id}}",
15119
15265
  "alarm.combined.title": "Allarme: {{deviceName}}",
15120
15266
  "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
15121
15267
  "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
15122
15268
  "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
15123
15269
  "system.alarm-arm-refused.body": "Inserimento rifiutato",
15124
- "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
15125
- "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
15126
- "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
15270
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s ({{active}} attivi, {{skipped}} saltati)",
15271
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s ({{active}} attivi, {{skipped}} saltati)",
15272
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s ({{active}} attivi, {{skipped}} saltati)",
15273
+ "system.alarm-arming.title.home.all": "Inserimento Casa — {{seconds}}s",
15274
+ "system.alarm-arming.title.away.all": "Inserimento Fuori — {{seconds}}s",
15275
+ "system.alarm-arming.title.night.all": "Inserimento Notte — {{seconds}}s",
15127
15276
  "system.alarm-arming.body": "Inserimento in corso",
15128
- "system.alarm-armed.title.home": "Allarme inserito: Casa",
15129
- "system.alarm-armed.title.away": "Allarme inserito: Fuori",
15130
- "system.alarm-armed.title.night": "Allarme inserito: Notte",
15277
+ "system.alarm-armed.title.home": "Allarme attivo: Casa ({{active}} attivi, {{skipped}} saltati)",
15278
+ "system.alarm-armed.title.away": "Allarme attivo: Fuori ({{active}} attivi, {{skipped}} saltati)",
15279
+ "system.alarm-armed.title.night": "Allarme attivo: Notte ({{active}} attivi, {{skipped}} saltati)",
15280
+ "system.alarm-armed.title.home.all": "Allarme attivo: Casa (tutti)",
15281
+ "system.alarm-armed.title.away.all": "Allarme attivo: Fuori (tutti)",
15282
+ "system.alarm-armed.title.night.all": "Allarme attivo: Notte (tutti)",
15131
15283
  "system.alarm-armed.body": "Allarme inserito",
15132
15284
  "system.alarm-disarmed.title": "Disinserito",
15133
15285
  "system.alarm-disarmed.body": "Allarme disinserito",
@@ -15225,7 +15377,16 @@ var it_default = {
15225
15377
  "summary.line.update": "{{packageName}} {{currentVersion}} → {{latestVersion}}",
15226
15378
  "summary.line.update.onNode": "{{packageName}} {{currentVersion}} → {{latestVersion}} su {{nodeId}}",
15227
15379
  "summary.line.wrapper-update": "Container {{currentVersion}} → {{latestVersion}}",
15228
- "summary.line.tank-unread": "{{deviceName}} — il serbatoio {{tank}} richiede attenzione"
15380
+ "summary.line.tank-unread": "{{deviceName}} — il serbatoio {{tank}} richiede attenzione",
15381
+ "calendar-events.title": "{{rule}}: {{count}} in arrivo",
15382
+ "calendar-events.empty": "Nessun evento nei prossimi {{horizonDays}} giorno/i.",
15383
+ "calendar-events.day.today": "Oggi — {{date}}",
15384
+ "calendar-events.day.tomorrow": "★ Domani — {{date}}",
15385
+ "calendar-events.day.other": "{{date}}",
15386
+ "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
15387
+ "calendar-events.line.allDay": "• Tutto il giorno {{title}}{{where}}",
15388
+ "calendar-events.where": " @ {{location}}",
15389
+ "calendar-events.more": "…e altri {{count}}"
15229
15390
  };
15230
15391
  //#endregion
15231
15392
  //#region src/notification-center/text-catalog.ts
@@ -15479,12 +15640,12 @@ var NC_RULE_AI_WORDS_FALLBACK = 24;
15479
15640
  *
15480
15641
  * | kind (declared `bodyMaxLen`) | words |
15481
15642
  * | --- | --- |
15482
- * | zentik (500) | 12 |
15643
+ * | (legacy 500-char push) | 12 |
15483
15644
  * | pushover (1024) | 24 |
15484
15645
  * | discord (2000) | 36 |
15485
15646
  * | home-assistant (3500) | 46 |
15486
15647
  * | ntfy · gotify · telegram · whatsapp (4096) | 48 |
15487
- * | webhook (1 000 000) | 120 |
15648
+ * | webhook · zentik (1 000 000) | 120 |
15488
15649
  *
15489
15650
  * Which is the operator's sentence, arrived at from a declared number: a push
15490
15651
  * notification is truncated by the screen and wants few words; a webhook or a
@@ -29844,7 +30005,7 @@ function buildRuleAiSystemPrompt(shape, language, maxWords, grouped = false) {
29844
30005
  ].join("");
29845
30006
  }
29846
30007
  /** `HH:MM` in the host timezone. */
29847
- function clockOf$6(atMs) {
30008
+ function clockOf$5(atMs) {
29848
30009
  const d = new Date(atMs);
29849
30010
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
29850
30011
  }
@@ -29882,7 +30043,7 @@ function buildRuleAiPrompt(input) {
29882
30043
  if (shape === "detection-track") {
29883
30044
  lines.push(`Camera: ${camera}.${located} Subject: ${subject}. The stills, in order:`);
29884
30045
  for (const [i, frame] of input.frames.entries()) {
29885
- const when = frame.atMs !== void 0 ? ` · ${clockOf$6(frame.atMs)}` : "";
30046
+ const when = frame.atMs !== void 0 ? ` · ${clockOf$5(frame.atMs)}` : "";
29886
30047
  lines.push(`${String(i + 1)}.${when}`);
29887
30048
  }
29888
30049
  return lines.join("\n");
@@ -31653,7 +31814,7 @@ var DEFAULT_PROMPT_EN = "What happened during this period? Summarise it for a ho
31653
31814
  */
31654
31815
  var SummaryAnswerSchema = require_clip_model_registry.object({ summary: require_clip_model_registry.string() });
31655
31816
  /** `HH:MM` in the host timezone — the two numbers a frame label needs. */
31656
- function clockOf$5(atMs) {
31817
+ function clockOf$4(atMs) {
31657
31818
  const d = new Date(atMs);
31658
31819
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
31659
31820
  }
@@ -31676,7 +31837,7 @@ function buildSummaryAiPrompt(input, frames) {
31676
31837
  const roster = frames.map((f, i) => {
31677
31838
  const location = plainLabel(f.location ?? "");
31678
31839
  const where = location.length > 0 ? ` (${location})` : "";
31679
- return `${String(i + 1)}. ${plainLabel(f.deviceName)}${where} · ${clockOf$5(f.atMs)} · ${plainVocabulary(f.className) || "subject"}`;
31840
+ return `${String(i + 1)}. ${plainLabel(f.deviceName)}${where} · ${clockOf$4(f.atMs)} · ${plainVocabulary(f.className) || "subject"}`;
31680
31841
  }).join("\n");
31681
31842
  const described = /* @__PURE__ */ new Map();
31682
31843
  for (const f of frames) {
@@ -32279,7 +32440,7 @@ var NO_DETECTIONS$1 = {
32279
32440
  * has to check is a key somebody can get wrong. */
32280
32441
  var BODY_SEPARATOR$1 = " · ";
32281
32442
  /** `HH:MM` in the host timezone. */
32282
- function clockOf$4(atMs) {
32443
+ function clockOf$3(atMs) {
32283
32444
  const d = new Date(atMs);
32284
32445
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
32285
32446
  }
@@ -32293,8 +32454,8 @@ function clockOf$4(atMs) {
32293
32454
  function summaryTemplateVars(input) {
32294
32455
  return {
32295
32456
  rule: input.ruleName,
32296
- from: clockOf$4(input.window.startMs),
32297
- to: clockOf$4(input.window.endMs),
32457
+ from: clockOf$3(input.window.startMs),
32458
+ to: clockOf$3(input.window.endMs),
32298
32459
  time: ncClockText(input.texts.language, input.generatedAt),
32299
32460
  events: String(input.tileCount),
32300
32461
  cameras: String(input.cameraCount),
@@ -32389,11 +32550,11 @@ function summaryRecordId(ruleId, windowEndMs) {
32389
32550
  /** The caption burned onto the mosaic's title band, with the same `{{var}}`
32390
32551
  * vocabulary the title and body use. `''` is a real answer ("no caption"). */
32391
32552
  function summaryCaptionText(input) {
32392
- if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$4(input.window.startMs)}→${clockOf$4(input.window.endMs)}`;
32553
+ if (input.captionText === void 0) return `${input.ruleName} — ${clockOf$3(input.window.startMs)}→${clockOf$3(input.window.endMs)}`;
32393
32554
  return renderTemplate(input.captionText, {
32394
32555
  rule: input.ruleName,
32395
- from: clockOf$4(input.window.startMs),
32396
- to: clockOf$4(input.window.endMs)
32556
+ from: clockOf$3(input.window.startMs),
32557
+ to: clockOf$3(input.window.endMs)
32397
32558
  }) ?? "";
32398
32559
  }
32399
32560
  /**
@@ -33325,7 +33486,7 @@ var NcSummaryProducer = class {
33325
33486
  purpose: "summary",
33326
33487
  tiles: tiles.map(({ candidate, jpeg }) => ({
33327
33488
  jpeg,
33328
- ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$3(candidate.firstSeen)} · ${candidate.className}` } : {},
33489
+ ...plan.tileLabels ? { label: `${names.get(candidate.deviceId) ?? candidate.deviceId} · ${clockOf$2(candidate.firstSeen)} · ${candidate.className}` } : {},
33329
33490
  deviceId: candidate.deviceId,
33330
33491
  sourceId: candidate.trackId
33331
33492
  })),
@@ -33548,7 +33709,7 @@ var NcSummaryProducer = class {
33548
33709
  }
33549
33710
  };
33550
33711
  /** `HH:MM` in the host timezone — the two numbers a tile label needs. */
33551
- function clockOf$3(atMs) {
33712
+ function clockOf$2(atMs) {
33552
33713
  const d = new Date(atMs);
33553
33714
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
33554
33715
  }
@@ -34049,8 +34210,6 @@ var NcCalendarEventsStore = class {
34049
34210
  };
34050
34211
  //#endregion
34051
34212
  //#region src/notification-center/calendar-events/calendar-events-body.ts
34052
- /** Visual weight on the tomorrow header — Telegram/Zentik plain text. */
34053
- var CALENDAR_EVENTS_TOMORROW_MARK = "★";
34054
34213
  /** Separates the tomorrow block from the rest of the horizon. */
34055
34214
  var CALENDAR_EVENTS_DAY_DELIMITER = "────────";
34056
34215
  var MAX_EVENT_LINES = 40;
@@ -34071,26 +34230,50 @@ function addLocalDays(atMs, days) {
34071
34230
  d.setDate(d.getDate() + days);
34072
34231
  return d.getTime();
34073
34232
  }
34074
- function dayHeadingFor(dayStartMs, todayKey, tomorrowKey) {
34075
- const key = localDayKey(dayStartMs);
34076
- const label = new Date(dayStartMs).toLocaleDateString(void 0, {
34233
+ function dayDateLabel(texts, dayStartMs) {
34234
+ return new Intl.DateTimeFormat(texts.language, {
34077
34235
  weekday: "short",
34078
34236
  month: "short",
34079
34237
  day: "numeric"
34080
- });
34081
- if (key === todayKey) return `Today — ${label}`;
34082
- if (key === tomorrowKey) return `${CALENDAR_EVENTS_TOMORROW_MARK} Tomorrow — ${label}`;
34083
- return label;
34238
+ }).format(new Date(dayStartMs));
34084
34239
  }
34085
- /** `HH:MM` local, or `YYYY-MM-DD HH:MM` when the end falls on another day. */
34086
- function clockOf$2(atMs) {
34087
- const d = new Date(atMs);
34088
- return `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
34240
+ function dayHeadingFor(texts, dayStartMs, todayKey, tomorrowKey) {
34241
+ const key = localDayKey(dayStartMs);
34242
+ const date = dayDateLabel(texts, dayStartMs);
34243
+ if (key === todayKey) return texts.text({
34244
+ key: "calendar-events.day.today",
34245
+ vars: { date }
34246
+ });
34247
+ if (key === tomorrowKey) return texts.text({
34248
+ key: "calendar-events.day.tomorrow",
34249
+ vars: { date }
34250
+ });
34251
+ return texts.text({
34252
+ key: "calendar-events.day.other",
34253
+ vars: { date }
34254
+ });
34089
34255
  }
34090
- function formatEventLine(event) {
34091
- const when = event.allDay === true ? "All day" : `${clockOf$2(event.startMs)}–${clockOf$2(event.endMs)}`;
34092
- const where = event.location !== void 0 && event.location.length > 0 ? ` @ ${event.location}` : "";
34093
- return `• ${when} ${event.title}${where}`;
34256
+ function formatEventLine(texts, event) {
34257
+ const where = event.location !== void 0 && event.location.length > 0 ? texts.text({
34258
+ key: "calendar-events.where",
34259
+ vars: { location: event.location }
34260
+ }) : "";
34261
+ if (event.allDay === true) return texts.text({
34262
+ key: "calendar-events.line.allDay",
34263
+ vars: {
34264
+ title: event.title,
34265
+ where
34266
+ }
34267
+ });
34268
+ const when = `${ncClockText(texts.language, event.startMs)}–${ncClockText(texts.language, event.endMs)}`;
34269
+ return texts.text({
34270
+ key: "calendar-events.line.timed",
34271
+ vars: {
34272
+ when,
34273
+ title: event.title,
34274
+ where
34275
+ }
34276
+ });
34094
34277
  }
34095
34278
  function groupByLocalDay(events) {
34096
34279
  const map = /* @__PURE__ */ new Map();
@@ -34114,8 +34297,11 @@ function groupByLocalDay(events) {
34114
34297
  *
34115
34298
  * @param nowMs — fire time; tomorrow is the local calendar day after this.
34116
34299
  */
34117
- function formatCalendarEventsBody(events, nowMs, horizonDays) {
34118
- if (events.length === 0) return `No events in the next ${String(horizonDays)} day(s).`;
34300
+ function formatCalendarEventsBody(texts, events, nowMs, horizonDays) {
34301
+ if (events.length === 0) return texts.text({
34302
+ key: "calendar-events.empty",
34303
+ vars: { horizonDays: String(horizonDays) }
34304
+ });
34119
34305
  const todayKey = localDayKey(nowMs);
34120
34306
  const tomorrowKey = localDayKey(addLocalDays(startOfLocalDay(nowMs), 1));
34121
34307
  const buckets = groupByLocalDay(events);
@@ -34127,17 +34313,20 @@ function formatCalendarEventsBody(events, nowMs, horizonDays) {
34127
34313
  let emitted = 0;
34128
34314
  const pushBucket = (bucket) => {
34129
34315
  if (emitted >= MAX_EVENT_LINES) return;
34130
- lines.push(dayHeadingFor(bucket.dayStartMs, todayKey, tomorrowKey));
34316
+ lines.push(dayHeadingFor(texts, bucket.dayStartMs, todayKey, tomorrowKey));
34131
34317
  for (const ev of bucket.events) {
34132
34318
  if (emitted >= MAX_EVENT_LINES) break;
34133
- lines.push(formatEventLine(ev));
34319
+ lines.push(formatEventLine(texts, ev));
34134
34320
  emitted += 1;
34135
34321
  }
34136
34322
  };
34137
34323
  for (const bucket of lead) pushBucket(bucket);
34138
34324
  if (lead.length > 0 && later.length > 0) lines.push(CALENDAR_EVENTS_DAY_DELIMITER);
34139
34325
  for (const bucket of later) pushBucket(bucket);
34140
- if (events.length > MAX_EVENT_LINES) lines.push(`…and ${String(events.length - MAX_EVENT_LINES)} more`);
34326
+ if (events.length > MAX_EVENT_LINES) lines.push(texts.text({
34327
+ key: "calendar-events.more",
34328
+ vars: { count: String(events.length - MAX_EVENT_LINES) }
34329
+ }));
34141
34330
  return lines.join("\n");
34142
34331
  }
34143
34332
  //#endregion
@@ -34151,7 +34340,7 @@ function clockOf$1(atMs) {
34151
34340
  return `${`${String(d.getFullYear())}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`} ${`${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`}`;
34152
34341
  }
34153
34342
  function derivedBody$1(input) {
34154
- return formatCalendarEventsBody(input.events, input.eventWindow.startMs, input.horizonDays);
34343
+ return formatCalendarEventsBody(input.texts, input.events, input.eventWindow.startMs, input.horizonDays);
34155
34344
  }
34156
34345
  function templateVars(input) {
34157
34346
  return {
@@ -34177,7 +34366,11 @@ function paramsFor$1(target) {
34177
34366
  function buildCalendarEventsOutboxInputs(input) {
34178
34367
  const vars = templateVars(input);
34179
34368
  const optedOut = new Set(input.disabledTargetIds ?? []);
34180
- const title = renderTemplate(input.template?.title, vars) ?? `${input.ruleName}: ${String(input.events.length)} upcoming`;
34369
+ const title = input.texts.text({
34370
+ key: "calendar-events.title",
34371
+ template: input.template?.title,
34372
+ vars
34373
+ });
34181
34374
  const body = renderTemplate(input.template?.body, vars) ?? derivedBody$1(input);
34182
34375
  const recordId = calendarEventsRecordId(input.ruleId, input.scheduleWindow.endMs);
34183
34376
  return input.targets.filter((t) => !optedOut.has(t.targetId)).map((target) => {
@@ -34326,6 +34519,7 @@ var NcCalendarEventsProducer = class {
34326
34519
  }
34327
34520
  async enqueue(rule, scheduleWindow, eventWindow, calendarIds, events) {
34328
34521
  const inputs = buildCalendarEventsOutboxInputs({
34522
+ texts: this.deps.texts,
34329
34523
  ruleId: rule.id,
34330
34524
  ruleName: rule.name,
34331
34525
  scheduleWindow,
@@ -34340,7 +34534,7 @@ var NcCalendarEventsProducer = class {
34340
34534
  priority: rule.priority,
34341
34535
  ...rule.template !== void 0 ? { template: rule.template } : {}
34342
34536
  });
34343
- for (const input of inputs) await this.deps.outbox.enqueue(input);
34537
+ await this.deps.outbox.enqueue(inputs);
34344
34538
  this.deps.logger.info("calendar-events delivered", { meta: {
34345
34539
  ruleId: rule.id,
34346
34540
  events: events.length,
@@ -35699,16 +35893,25 @@ function stateItem(row, lookup) {
35699
35893
  /**
35700
35894
  * THE RECONCILE (D647 review I2, D8): a ledger row is a memory, never the
35701
35895
  * truth. A device item is listed only while the device EXISTS in the directory
35702
- * and is not an accessory — and an "offline" one only while the device
35703
- * manager does not call it online. A node item only while the node is known
35704
- * to a complete node directory (a forgotten node is gone), and an "offline"
35705
- * one only while the directory does not call it online.
35896
+ * — and an "offline" one only while the device manager does not call it
35897
+ * online. A node item only while the node is known to a complete node
35898
+ * directory (a forgotten node is gone), and an "offline" one only while the
35899
+ * directory does not call it online.
35900
+ *
35901
+ * Accessories are silenced only on the liveness / switched-off section
35902
+ * (`device-state`). Battery, consumable and tank alerts ride the SAME
35903
+ * reconcile, and a vacuum that is a child of its Dreame container
35904
+ * (`parentDeviceId` set) still owns those alerts — filtering every accessory
35905
+ * here dropped every tank line of the 2026-10-05 evening summary for
35906
+ * deviceId 4385 while the episode was open and the instant path had already
35907
+ * held `device-tank-low` for that rule.
35706
35908
  */
35707
35909
  function stillTrue(item, ports) {
35708
35910
  const deviceId = item.subject.deviceId;
35709
35911
  if (deviceId !== void 0) {
35710
35912
  const identity = ports.lookup(deviceId);
35711
- if (identity === void 0 || isAccessoryIdentity(identity)) return false;
35913
+ if (identity === void 0) return false;
35914
+ if (item.section === "device-state" && isAccessoryIdentity(identity)) return false;
35712
35915
  if (item.subject.kind === "device-offline" && identity.online === true) return false;
35713
35916
  }
35714
35917
  const nodeId = item.subject.nodeId;
@@ -38817,6 +39020,7 @@ var NotificationCenter = class {
38817
39020
  store: this.calendarEventsRules,
38818
39021
  outbox: this.outbox,
38819
39022
  calendars: calendarsPort,
39023
+ texts: this.texts,
38820
39024
  logger: this.logger.child("calendar-events"),
38821
39025
  now: this.now
38822
39026
  });
@@ -40987,8 +41191,14 @@ var NotificationCenter = class {
40987
41191
  }
40988
41192
  if (kind !== "alarm-armed" && kind !== "alarm-arming") return null;
40989
41193
  if (mode === void 0) return null;
40990
- const coverage = this.coverageFor(panel).find((c) => c.mode === mode);
40991
- if (coverage === void 0) return null;
41194
+ const coverage = this.coverageFor(panel).find((c) => c.mode === mode) ?? {
41195
+ mode,
41196
+ ruleCount: 0,
41197
+ allDevices: false,
41198
+ allDevicesExcept: [],
41199
+ deviceIds: [],
41200
+ skippedDevices: []
41201
+ };
40992
41202
  const name = this.deviceNameFromDirectory();
40993
41203
  const bypassed = panel.excludedDevices();
40994
41204
  const text = kind === "alarm-arming" ? buildArmingAnnouncement(coverage, transition.seconds ?? 0, name, bypassed) : buildArmAnnouncement(coverage, name, bypassed);
@@ -57660,36 +57870,68 @@ var DEFAULT_STATE_ANALYZER_CONFIG = {
57660
57870
  stillWindowMs: DEFAULT_STILL_WINDOW_MS,
57661
57871
  stillSpanFraction: DEFAULT_STILL_SPAN_FRACTION
57662
57872
  };
57873
+ function shortSideOf(bbox) {
57874
+ return Math.min(bbox.w, bbox.h);
57875
+ }
57663
57876
  var StateAnalyzer = class {
57664
57877
  config;
57665
57878
  states = /* @__PURE__ */ new Map();
57879
+ parked = /* @__PURE__ */ new Map();
57666
57880
  constructor(config = {}) {
57667
57881
  this.config = {
57668
57882
  ...DEFAULT_STATE_ANALYZER_CONFIG,
57669
57883
  ...config
57670
57884
  };
57671
57885
  }
57672
- /** D683: a fused continuation keeps its movement state under the survivor id. */
57886
+ /**
57887
+ * D683: a fused continuation keeps its movement state under the survivor id.
57888
+ *
57889
+ * The absorbed piece is the live continuation, but a still subject that L1
57890
+ * split must not lose the dwell the survivor already paid — `rename` MERGES
57891
+ * the stillness clocks when the geometry across the gap is still still, and
57892
+ * only takes the live piece alone when the subject actually moved.
57893
+ */
57673
57894
  rename(from, to) {
57674
- renameKey(this.states, from, to);
57895
+ const live = this.states.get(from) ?? this.parked.get(from)?.state;
57896
+ if (live === void 0) return;
57897
+ this.states.delete(from);
57898
+ this.parked.delete(from);
57899
+ const survivor = this.states.get(to) ?? this.parked.get(to)?.state;
57900
+ this.parked.delete(to);
57901
+ if (survivor === void 0) {
57902
+ this.states.set(to, live);
57903
+ return;
57904
+ }
57905
+ this.states.set(to, this.mergeContinuation(survivor, live));
57675
57906
  }
57676
57907
  analyze(tracks, timestamp) {
57908
+ this.pruneParked(timestamp);
57677
57909
  const currentIds = new Set(tracks.map((t) => t.trackId));
57678
57910
  const results = [];
57679
57911
  for (const track of tracks) {
57680
57912
  let ts = this.states.get(track.trackId);
57913
+ if (ts === void 0) {
57914
+ const parked = this.parked.get(track.trackId);
57915
+ if (parked !== void 0) {
57916
+ this.parked.delete(track.trackId);
57917
+ ts = parked.state;
57918
+ this.states.set(track.trackId, ts);
57919
+ }
57920
+ }
57681
57921
  const center = bboxCentroid({
57682
57922
  x: track.bbox.x,
57683
57923
  y: track.bbox.y,
57684
57924
  w: track.bbox.w,
57685
57925
  h: track.bbox.h
57686
57926
  });
57927
+ const shortSide = shortSideOf(track.bbox);
57687
57928
  if (!ts) {
57688
57929
  ts = {
57689
57930
  enteredAt: timestamp,
57690
57931
  stationarySince: void 0,
57691
57932
  totalDistancePx: 0,
57692
57933
  lastPosition: center,
57934
+ lastShortSide: shortSide,
57693
57935
  frameCount: 1,
57694
57936
  recent: [{
57695
57937
  x: center.x,
@@ -57704,14 +57946,14 @@ var StateAnalyzer = class {
57704
57946
  const dist = Math.sqrt(dx * dx + dy * dy);
57705
57947
  ts.totalDistancePx += dist;
57706
57948
  ts.lastPosition = center;
57949
+ ts.lastShortSide = shortSide;
57707
57950
  ts.frameCount++;
57708
57951
  ts.recent.push({
57709
57952
  x: center.x,
57710
57953
  y: center.y,
57711
57954
  at: timestamp
57712
57955
  });
57713
- const cutoff = timestamp - this.config.stillWindowMs;
57714
- while (ts.recent.length > 2 && ts.recent[1].at < cutoff) ts.recent.shift();
57956
+ this.trimRecent(ts, timestamp);
57715
57957
  }
57716
57958
  const velocity = track.velocity ? Math.sqrt(track.velocity.dx ** 2 + track.velocity.dy ** 2) : 0;
57717
57959
  const dwellTimeMs = timestamp - ts.enteredAt;
@@ -57747,10 +57989,62 @@ var StateAnalyzer = class {
57747
57989
  dwellTimeMs: timestamp - ts.enteredAt
57748
57990
  });
57749
57991
  this.states.delete(trackId);
57992
+ this.parked.set(trackId, {
57993
+ state: ts,
57994
+ parkedAt: timestamp
57995
+ });
57750
57996
  }
57751
57997
  return results;
57752
57998
  }
57753
57999
  /**
58000
+ * Fold a live continuation into its ended survivor.
58001
+ *
58002
+ * Stillness is what the parked-object ledger votes on (`idle` only). A gap
58003
+ * past L1's coast budget that L2 later heals must not restart that clock
58004
+ * when the centroid never left the still span. A continuation that DID move
58005
+ * drops the survivor's `stationarySince` so a walk after a pause is still
58006
+ * `moving`.
58007
+ */
58008
+ mergeContinuation(survivor, live) {
58009
+ const gapTravel = Math.hypot(live.lastPosition.x - survivor.lastPosition.x, live.lastPosition.y - survivor.lastPosition.y);
58010
+ const subject = Math.max(survivor.lastShortSide, live.lastShortSide);
58011
+ const stillAcrossGap = survivor.stationarySince !== void 0 && subject > 0 && gapTravel <= subject * this.config.stillSpanFraction;
58012
+ const lastAt = live.recent[live.recent.length - 1]?.at;
58013
+ if (stillAcrossGap && survivor.stationarySince !== void 0 && lastAt !== void 0) {
58014
+ const anchorAt = lastAt - this.config.stillWindowMs;
58015
+ const recent = [{
58016
+ x: live.lastPosition.x,
58017
+ y: live.lastPosition.y,
58018
+ at: anchorAt
58019
+ }, ...live.recent.filter((s) => s.at > anchorAt)];
58020
+ return {
58021
+ enteredAt: Math.min(survivor.enteredAt, live.enteredAt),
58022
+ stationarySince: live.stationarySince !== void 0 ? Math.min(survivor.stationarySince, live.stationarySince) : survivor.stationarySince,
58023
+ totalDistancePx: survivor.totalDistancePx + live.totalDistancePx,
58024
+ lastPosition: live.lastPosition,
58025
+ lastShortSide: live.lastShortSide,
58026
+ frameCount: Math.max(survivor.frameCount + live.frameCount, this.config.enteringFrames + 1),
58027
+ recent
58028
+ };
58029
+ }
58030
+ return {
58031
+ enteredAt: live.enteredAt,
58032
+ stationarySince: live.stationarySince,
58033
+ totalDistancePx: survivor.totalDistancePx + live.totalDistancePx,
58034
+ lastPosition: live.lastPosition,
58035
+ lastShortSide: live.lastShortSide,
58036
+ frameCount: live.frameCount,
58037
+ recent: [...live.recent]
58038
+ };
58039
+ }
58040
+ trimRecent(ts, timestamp) {
58041
+ const cutoff = timestamp - this.config.stillWindowMs;
58042
+ while (ts.recent.length > 2 && ts.recent[1].at < cutoff) ts.recent.shift();
58043
+ }
58044
+ pruneParked(timestamp) {
58045
+ for (const [id, parked] of this.parked) if (timestamp - parked.parkedAt > LINK_WINDOW_MS) this.parked.delete(id);
58046
+ }
58047
+ /**
57754
58048
  * The SPEED half of the settled test.
57755
58049
  *
57756
58050
  * Uncalibrated (`stillSpeedFracPerSec` undefined) this is the original line
@@ -57812,6 +58106,7 @@ var StateAnalyzer = class {
57812
58106
  }
57813
58107
  reset() {
57814
58108
  this.states.clear();
58109
+ this.parked.clear();
57815
58110
  }
57816
58111
  };
57817
58112
  //#endregion
@@ -87001,23 +87296,19 @@ function sweepOne(deps, deviceId) {
87001
87296
  }
87002
87297
  //#endregion
87003
87298
  //#region src/pipeline-analytics/pipeline/stationary/stationary-zones.ts
87299
+ /** Shared with every call — ZoneEngine is stateless for annotateDetection. */
87300
+ var ENGINE = new ZoneEngine();
87004
87301
  /**
87005
- * Zone ids whose 0–1 polygon contains the entry's normalised bbox centroid.
87006
- * Empty when the entry has no frame dims (can't normalise) or no zone matches.
87302
+ * Zone ids whose bbox overlap exceeds `minOverlap` (0–1 fraction of the
87303
+ * detection's own area) — identical to {@link ZoneEngine.annotateDetection}.
87304
+ * Empty when the entry has no frame dims or no zone meets the bar.
87305
+ *
87306
+ * `minOverlap` is required: callers pass `TrackingSettings.zoneMembershipMinOverlap`
87307
+ * so parked and live never disagree on the threshold.
87007
87308
  */
87008
- function computeStationaryEntryZones(entry, zones) {
87309
+ function computeStationaryEntryZones(entry, zones, minOverlap) {
87009
87310
  if (entry.frameWidth <= 0 || entry.frameHeight <= 0 || zones.length === 0) return [];
87010
- const centroidPx = bboxCentroid(entry.bbox);
87011
- const point = {
87012
- x: centroidPx.x / entry.frameWidth,
87013
- y: centroidPx.y / entry.frameHeight
87014
- };
87015
- const matched = [];
87016
- for (const zone of zones) {
87017
- if (zone.polygon.length < 3) continue;
87018
- if (pointInPolygon$1(point, zone.polygon)) matched.push(zone.id);
87019
- }
87020
- return matched;
87311
+ return ENGINE.annotateDetection(entry.bbox, zones, entry.frameWidth, entry.frameHeight, void 0, void 0, void 0, minOverlap).map((m) => m.zoneId);
87021
87312
  }
87022
87313
  //#endregion
87023
87314
  //#region src/pipeline-analytics/pipeline/package-drop-detector.ts
@@ -87117,15 +87408,17 @@ var PackageDropDetector = class {
87117
87408
  }
87118
87409
  const ruleZoneIds = new Set(rules.flatMap((r) => r.zoneIds));
87119
87410
  const zones = await this.deps.resolveZones(entry.deviceId);
87120
- const entryZones = computeStationaryEntryZones(entry, zones);
87411
+ const minOverlap = await this.deps.resolveZoneMembershipMinOverlap(entry.deviceId);
87412
+ const entryZones = computeStationaryEntryZones(entry, zones, minOverlap);
87121
87413
  const hitZones = entryZones.filter((z) => ruleZoneIds.has(z));
87122
87414
  if (hitZones.length === 0) {
87123
- this.deps.logger.info("package gate 2/5 zone — centroid outside every package zone", {
87415
+ this.deps.logger.info("package gate 2/5 zone — no package zone meets membership overlap", {
87124
87416
  tags,
87125
87417
  meta: {
87126
87418
  entryId: entry.id,
87127
87419
  bbox: entry.bbox,
87128
87420
  entryZones,
87421
+ minOverlap,
87129
87422
  ruleZoneIds: [...ruleZoneIds],
87130
87423
  zonesOnDevice: zones.length
87131
87424
  }
@@ -100897,7 +101190,7 @@ function buildDetectionSettingsSections() {
100897
101190
  type: "number",
100898
101191
  key: "zoneMembershipMinOverlap",
100899
101192
  label: "Zone membership minimum overlap",
100900
- description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
101193
+ description: "How much of a detection box must fall inside a zone for the detection to be STAMPED with it — as a fraction of the BOX area, not of the zone. Same bar for live tracks and parked (stationary) occupancy. 0 stamps on any touch, which is what let a plant brushing a zone by 6% fire a \"person on the doorstep\" rule. Only a stamped zone can match a zone-scoped notification rule. Read the \"zone membership stamped on event\" log line to choose the value: it prints the achieved percentage per zone.",
100901
101194
  min: 0,
100902
101195
  max: 1,
100903
101196
  step: .05,
@@ -104118,14 +104411,21 @@ var ZoneAnalyticsProvider = class {
104118
104411
  meta: { error: err instanceof Error ? err.message : String(err) }
104119
104412
  });
104120
104413
  }
104414
+ const minOverlap = await this.resolveMinOverlap(deviceId);
104121
104415
  const ts = mostRecentStationaryConfirmedAt(entries) || Date.now();
104122
104416
  return buildStationarySnapshot({
104123
104417
  deviceId,
104124
104418
  entries,
104125
104419
  zones,
104126
- timestamp: ts
104420
+ timestamp: ts,
104421
+ minOverlap
104127
104422
  });
104128
104423
  }
104424
+ /** Tracking membership bar — schema default when the host did not wire it. */
104425
+ async resolveMinOverlap(deviceId) {
104426
+ if (this.ctx.resolveZoneMembershipMinOverlap === void 0) return TRACKING_DEFAULTS.zoneMembershipMinOverlap;
104427
+ return this.ctx.resolveZoneMembershipMinOverlap(deviceId);
104428
+ }
104129
104429
  /**
104130
104430
  * Baseline sampler tick: for every device with parked objects, append a
104131
104431
  * hydrated sample to the history ring at the CURRENT time — but only when a
@@ -104143,11 +104443,13 @@ var ZoneAnalyticsProvider = class {
104143
104443
  try {
104144
104444
  zones = await this.ctx.resolveZones?.(deviceId) ?? [];
104145
104445
  } catch {}
104446
+ const minOverlap = await this.resolveMinOverlap(deviceId);
104146
104447
  const snapshot = buildStationarySnapshot({
104147
104448
  deviceId,
104148
104449
  entries,
104149
104450
  zones,
104150
- timestamp: now
104451
+ timestamp: now,
104452
+ minOverlap
104151
104453
  });
104152
104454
  if (snapshot) {
104153
104455
  this.appendHistory(deviceId, snapshot);
@@ -104304,18 +104606,18 @@ function mostRecentStationaryConfirmedAt(entries) {
104304
104606
  /**
104305
104607
  * Build an occupancy snapshot from parked-object registry entries alone —
104306
104608
  * used when no live inference frame is available (fresh respawn, camera
104307
- * detached). Each entry is folded into the frame aggregate AND attributed to
104308
- * the zones its normalised bbox centroid falls inside (via
104309
- * {@link computeStationaryEntryZones}), so a zone drawn over a parked car
104310
- * reports a count of 1. Returns `null` for an empty entry list. Reuses
104311
- * {@link computeSnapshot} — the SAME aggregation the live frame path runs.
104609
+ * detached). Each entry is folded into the frame aggregate AND attributed via
104610
+ * {@link computeStationaryEntryZones} (same `ZoneEngine` + `minOverlap` as
104611
+ * live stamps), so a zone drawn over a parked car reports a count of 1.
104612
+ * Returns `null` for an empty entry list. Reuses {@link computeSnapshot} —
104613
+ * the SAME aggregation the live frame path runs.
104312
104614
  */
104313
104615
  function buildStationarySnapshot(input) {
104314
104616
  if (input.entries.length === 0) return null;
104315
104617
  const tracked = input.entries.map((e) => ({
104316
104618
  trackId: `stationary:${e.id}`,
104317
104619
  className: e.className,
104318
- zones: computeStationaryEntryZones(e, input.zones),
104620
+ zones: computeStationaryEntryZones(e, input.zones, input.minOverlap),
104319
104621
  standing: true
104320
104622
  }));
104321
104623
  const first = input.entries[0];
@@ -107138,6 +107440,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
107138
107440
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
107139
107441
  resolvePackageRules: (deviceId) => this.resolveDevicePackageRules(deviceId),
107140
107442
  resolveSettings: (deviceId) => this.resolveDevicePackageDropSettings(deviceId),
107443
+ resolveZoneMembershipMinOverlap: async (deviceId) => (await this.resolveDeviceTrackingSettings(deviceId)).zoneMembershipMinOverlap,
107141
107444
  onPersisted: (ev, phase) => this.notificationCenter?.onPackageEventPersisted(ev, phase),
107142
107445
  onError: (scope, err) => logger.warn("package-drop detector error", { meta: {
107143
107446
  scope,
@@ -107433,6 +107736,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
107433
107736
  listStationaryObjects: (deviceId) => this.stationaryRegistry?.listViews(deviceId) ?? [],
107434
107737
  listStationaryDeviceIds: () => this.stationaryRegistry?.deviceIds() ?? [],
107435
107738
  resolveZones: (deviceId) => this.resolveDeviceZones(deviceId),
107739
+ resolveZoneMembershipMinOverlap: async (deviceId) => (await this.resolveDeviceTrackingSettings(deviceId)).zoneMembershipMinOverlap,
107436
107740
  onSnapshot: (deviceId, snapshot, evidence) => this.notificationCenter?.observeOccupancy(deviceId, snapshot, evidence)
107437
107741
  });
107438
107742
  this.zoneAnalytics = zoneAnalytics;
@@ -109769,7 +110073,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
109769
110073
  const stationaryAsTracked = stationaryViews.map((v) => ({
109770
110074
  trackId: `stationary:${v.id}`,
109771
110075
  className: v.className,
109772
- zones: computeStationaryEntryZones(v, liveZones),
110076
+ zones: computeStationaryEntryZones(v, liveZones, trk.zoneMembershipMinOverlap),
109773
110077
  standing: true
109774
110078
  }));
109775
110079
  this.zoneAnalytics?.recordFrame({