@camstack/addon-post-analysis 1.2.70 → 1.2.71

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-DnpVHUcH.js");
5
+ const require_dist = require("../dist-DjPaVd2L.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -555,7 +555,14 @@ function alarmCoverageGap(coverage) {
555
555
  return "all-skipped";
556
556
  }
557
557
  /**
558
- * "Away armed — 3 cameras: Front door, Garage, Gate."
558
+ * "Attivi: Cancello, Garage — Esclusi: Porta retro."
559
+ *
560
+ * A LIST, not a sentence. The mode is in the title and the body is one
561
+ * `Label: names` line per device group, because this is read on a lock screen
562
+ * and the question is always "which one" — the prose version ("3 dispositivi
563
+ * attivi: …. Esclusi: ….") made the operator parse a paragraph to find a
564
+ * window. Each variant still owns its own catalog key, so the Texts tab can
565
+ * re-word "nothing is armed" without touching "these are armed".
559
566
  *
560
567
  * Names, not ids: this is read by a person standing at a door. An id that has
561
568
  * no name falls back to a NAMED, translated placeholder rather than being
@@ -634,7 +641,7 @@ function buildArmAnnouncement(coverage, deviceName, bypassed = []) {
634
641
  };
635
642
  }
636
643
  /**
637
- * "Home armed — with the veranda window open, excluded."
644
+ * "Attivi: 2 — Bypassati: Finestra veranda."
638
645
  *
639
646
  * The bypassed sensors are named and the armed ones are COUNTED, not listed:
640
647
  * this sentence's subject is the exception, and burying it under thirteen
@@ -657,13 +664,13 @@ function armedWithBypass(coverage, bypassed, deviceName) {
657
664
  };
658
665
  }
659
666
  /**
660
- * "The alarm was NOT armed: the veranda window is open."
667
+ * "Aperti: Finestra veranda, Porta retro" + how to arm anyway.
661
668
  *
662
669
  * The one alarm sentence about something that did not happen, so it has to
663
670
  * carry both halves an operator needs to act: WHICH sensors are open, and that
664
671
  * pressing arm again arms without them. Naming them is the whole message — a
665
672
  * refusal that says only "cannot arm" is a panel the operator will conclude is
666
- * broken.
673
+ * broken — so the names are the FIRST line and the retry hint is the second.
667
674
  */
668
675
  function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
669
676
  return {
@@ -678,7 +685,7 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
678
685
  };
679
686
  }
680
687
  /**
681
- * The same coverage, said 30 seconds earlier: "Armed in 30 seconds."
688
+ * The EXCEPTIONS to the same coverage, said 30 seconds earlier.
682
689
  *
683
690
  * The exit-delay sentence is deliberately SHORTER than the arm one — it is read
684
691
  * on the way out of a door — and it carries exactly one thing the arm sentence
@@ -686,8 +693,15 @@ function buildArmRefusedAnnouncement(mode, names, retrySeconds) {
686
693
  * A mode that arms nothing is worth interrupting somebody for at second 0 and
687
694
  * merely worth recording at second 30.
688
695
  *
689
- * Pluralises on the SECONDS, not on the devices: this sentence's only number is
690
- * the countdown, and Italian needs "1 secondo" / "30 secondi".
696
+ * **It never repeats the device list.** The countdown lives in the TITLE
697
+ * (`system.alarm-arming.title.<mode>` renders `{{seconds}}`), and the armed
698
+ * list arrives thirty seconds later in the arm announcement — printing it
699
+ * twice is the verbosity that made the operator stop reading both. So the body
700
+ * is what is WRONG: what was bypassed, what is excluded, or that nothing is.
701
+ *
702
+ * `seconds` stays in the params because the title asks for it, and `textCount`
703
+ * stays with it: a row enqueued before this deploy still pluralises on the
704
+ * countdown it was written for.
691
705
  */
692
706
  function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
693
707
  const textParams = {
@@ -702,6 +716,7 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
702
716
  },
703
717
  textCount: seconds
704
718
  };
719
+ const skippedNames = coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ");
705
720
  const gap = alarmCoverageGap(coverage);
706
721
  if (gap === "uncovered") return {
707
722
  bodyKey: "alarm.arming.body.uncovered",
@@ -712,10 +727,18 @@ function buildArmingAnnouncement(coverage, seconds, deviceName, bypassed = []) {
712
727
  bodyKey: "alarm.arming.body.allSkipped",
713
728
  textParams: {
714
729
  ...textParams,
715
- names: coverage.skippedDevices.map((s) => deviceName(s.deviceId)).join(", ")
730
+ names: skippedNames
716
731
  },
717
732
  textCount: seconds
718
733
  };
734
+ if (coverage.skippedDevices.length > 0) return {
735
+ bodyKey: "alarm.arming.body.skipped",
736
+ textParams: {
737
+ ...textParams,
738
+ names: skippedNames
739
+ },
740
+ textCount: coverage.skippedDevices.length
741
+ };
719
742
  return {
720
743
  bodyKey: "alarm.arming.body",
721
744
  textParams,
@@ -4342,69 +4365,38 @@ var en_default = {
4342
4365
  "snooze.scope.anyDevice": "A camera",
4343
4366
  "snooze.scope.anyRule": "A rule",
4344
4367
  "snooze.scope.rule": "“{{rule}}”",
4345
- "alarm.arm.body.allDevices": {
4346
- "one": "Every device is armed ({{rules}} rule).",
4347
- "other": "Every device is armed ({{rules}} rules)."
4348
- },
4349
- "alarm.arm.body.noDevices": {
4350
- "one": "{{rules}} rule, no device restriction.",
4351
- "other": "{{rules}} rules, no device restriction."
4352
- },
4353
- "alarm.arm.body.devices": {
4354
- "one": "{{devices}} device armed: {{names}}.",
4355
- "other": "{{devices}} devices armed: {{names}}."
4356
- },
4357
- "alarm.arm.body.devicesSkipped": {
4358
- "one": "{{devices}} device armed: {{names}}. Excluded: {{skippedNames}}.",
4359
- "other": "{{devices}} devices armed: {{names}}. Excluded: {{skippedNames}}."
4360
- },
4361
- "alarm.arm.body.uncovered": "Nothing is armed — no enabled rule is gated on this mode.",
4362
- "alarm.arm.body.allSkipped": {
4363
- "one": "Nothing is armed: the {{devices}} device this mode covers is excluded ({{names}}).",
4364
- "other": "Nothing is armed: all {{devices}} devices this mode covers are excluded ({{names}})."
4365
- },
4366
- "alarm.arming.body": {
4367
- "one": "Armed in {{seconds}} second.",
4368
- "other": "Armed in {{seconds}} seconds."
4369
- },
4370
- "alarm.arming.body.uncovered": {
4371
- "one": "Armed in {{seconds}} second — but no enabled rule is gated on this mode, so it will protect nothing.",
4372
- "other": "Armed in {{seconds}} seconds — but no enabled rule is gated on this mode, so it will protect nothing."
4373
- },
4374
- "alarm.arming.body.allSkipped": {
4375
- "one": "Armed in {{seconds}} second — but every device this mode covers is excluded ({{names}}), so it will protect nothing.",
4376
- "other": "Armed in {{seconds}} seconds — but every device this mode covers is excluded ({{names}}), so it will protect nothing."
4377
- },
4378
- "alarm.arm.body.bypassed": {
4379
- "one": "{{devices}} device armed — open and excluded: {{bypassedNames}}.",
4380
- "other": "{{devices}} devices armed — open and excluded: {{bypassedNames}}."
4381
- },
4382
- "alarm.arming.body.bypassed": {
4383
- "one": "Armed in {{seconds}} second, excluding what is open: {{bypassedNames}}.",
4384
- "other": "Armed in {{seconds}} seconds, excluding what is open: {{bypassedNames}}."
4385
- },
4386
- "alarm.armRefused.body": {
4387
- "one": "{{names}} is open. Press arm again within {{seconds}}s to arm without it.",
4388
- "other": "These are open: {{names}}. Press arm again within {{seconds}}s to arm without them."
4389
- },
4368
+ "alarm.arm.body.allDevices": "Active: all devices",
4369
+ "alarm.arm.body.noDevices": "Active: no device restriction",
4370
+ "alarm.arm.body.devices": "Active: {{names}}",
4371
+ "alarm.arm.body.devicesSkipped": "Active: {{names}}\nExcluded: {{skippedNames}}",
4372
+ "alarm.arm.body.uncovered": "Active: none — no rule uses this mode",
4373
+ "alarm.arm.body.allSkipped": "Active: none\nExcluded: {{names}}",
4374
+ "alarm.arming.body": "No exclusions",
4375
+ "alarm.arming.body.uncovered": "Active: none — no rule uses this mode",
4376
+ "alarm.arming.body.allSkipped": "Active: none\nExcluded: {{names}}",
4377
+ "alarm.arming.body.skipped": "Excluded: {{names}}",
4378
+ "alarm.arm.body.bypassed": "Active: {{devices}}\nBypassed: {{bypassedNames}}",
4379
+ "alarm.arming.body.bypassed": "Bypassed: {{bypassedNames}}",
4380
+ "alarm.armRefused.body": "Open: {{names}}\nRepeat within {{seconds}}s to arm with bypass.",
4390
4381
  "alarm.device.unnamed": "Device {{id}}",
4391
- "system.alarm-arm-refused.title.home": "Home NOT armed",
4392
- "system.alarm-arm-refused.title.away": "Away NOT armed",
4393
- "system.alarm-arm-refused.title.night": "Night NOT armed",
4394
- "system.alarm-arm-refused.body": "The alarm could not be armed — something it covers is open.",
4395
- "system.alarm-arming.title.home": "Arming home",
4396
- "system.alarm-arming.title.away": "Arming away",
4397
- "system.alarm-arming.title.night": "Arming night",
4398
- "system.alarm-arming.body": "The alarm is arming",
4399
- "system.alarm-armed.title.home": "Home armed",
4400
- "system.alarm-armed.title.away": "Away armed",
4401
- "system.alarm-armed.title.night": "Night armed",
4402
- "system.alarm-armed.body": "The alarm was armed",
4403
- "system.alarm-disarmed.title": "Alarm disarmed",
4404
- "system.alarm-disarmed.body": "The alarm was disarmed",
4382
+ "alarm.combined.title": "Alarm: {{deviceName}}",
4383
+ "system.alarm-arm-refused.title.home": "Arming home refused",
4384
+ "system.alarm-arm-refused.title.away": "Arming away refused",
4385
+ "system.alarm-arm-refused.title.night": "Arming night refused",
4386
+ "system.alarm-arm-refused.body": "Arming refused",
4387
+ "system.alarm-arming.title.home": "Arming home — {{seconds}}s",
4388
+ "system.alarm-arming.title.away": "Arming away — {{seconds}}s",
4389
+ "system.alarm-arming.title.night": "Arming night — {{seconds}}s",
4390
+ "system.alarm-arming.body": "Arming",
4391
+ "system.alarm-armed.title.home": "Armed: home",
4392
+ "system.alarm-armed.title.away": "Armed: away",
4393
+ "system.alarm-armed.title.night": "Armed: night",
4394
+ "system.alarm-armed.body": "Alarm armed",
4395
+ "system.alarm-disarmed.title": "Disarmed",
4396
+ "system.alarm-disarmed.body": "Alarm disarmed",
4405
4397
  "system.alarm-triggered.title": "ALARM",
4406
- "system.alarm-triggered.body": "The alarm was triggered",
4407
- "system.alarm-triggered.body.rule": "The alarm was triggered by {{rule}} on {{deviceName}}",
4398
+ "system.alarm-triggered.body": "Alarm triggered",
4399
+ "system.alarm-triggered.body.rule": "Triggered by {{rule}} on {{deviceName}}",
4408
4400
  "button.snooze.minutes": "Snooze {{minutes}} min",
4409
4401
  "button.snooze.hours": {
4410
4402
  "one": "Snooze {{hours}} hour",
@@ -4536,69 +4528,38 @@ var it_default = {
4536
4528
  "snooze.scope.anyDevice": "Una camera",
4537
4529
  "snooze.scope.anyRule": "Una regola",
4538
4530
  "snooze.scope.rule": "“{{rule}}”",
4539
- "alarm.arm.body.allDevices": {
4540
- "one": "Ogni dispositivo è attivo ({{rules}} regola).",
4541
- "other": "Ogni dispositivo è attivo ({{rules}} regole)."
4542
- },
4543
- "alarm.arm.body.noDevices": {
4544
- "one": "{{rules}} regola, nessuna restrizione sui dispositivi.",
4545
- "other": "{{rules}} regole, nessuna restrizione sui dispositivi."
4546
- },
4547
- "alarm.arm.body.devices": {
4548
- "one": "{{devices}} dispositivo attivo: {{names}}.",
4549
- "other": "{{devices}} dispositivi attivi: {{names}}."
4550
- },
4551
- "alarm.arm.body.devicesSkipped": {
4552
- "one": "{{devices}} dispositivo attivo: {{names}}. Esclusi: {{skippedNames}}.",
4553
- "other": "{{devices}} dispositivi attivi: {{names}}. Esclusi: {{skippedNames}}."
4554
- },
4555
- "alarm.arm.body.uncovered": "Non è attivo nulla — nessuna regola abilitata è legata a questa modalità.",
4556
- "alarm.arm.body.allSkipped": {
4557
- "one": "Non è attivo nulla: l'unico dispositivo ({{devices}}) coperto da questa modalità è escluso ({{names}}).",
4558
- "other": "Non è attivo nulla: tutti i {{devices}} dispositivi coperti da questa modalità sono esclusi ({{names}})."
4559
- },
4560
- "alarm.arming.body": {
4561
- "one": "Attivo tra {{seconds}} secondo.",
4562
- "other": "Attivo tra {{seconds}} secondi."
4563
- },
4564
- "alarm.arming.body.uncovered": {
4565
- "one": "Attivo tra {{seconds}} secondo — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla.",
4566
- "other": "Attivo tra {{seconds}} secondi — ma nessuna regola abilitata è legata a questa modalità: non proteggerà nulla."
4567
- },
4568
- "alarm.arming.body.allSkipped": {
4569
- "one": "Attivo tra {{seconds}} secondo — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla.",
4570
- "other": "Attivo tra {{seconds}} secondi — ma ogni dispositivo coperto da questa modalità è escluso ({{names}}): non proteggerà nulla."
4571
- },
4572
- "alarm.arm.body.bypassed": {
4573
- "one": "{{devices}} dispositivo attivo — aperto ed escluso: {{bypassedNames}}.",
4574
- "other": "{{devices}} dispositivi attivi — aperti ed esclusi: {{bypassedNames}}."
4575
- },
4576
- "alarm.arming.body.bypassed": {
4577
- "one": "Attivo tra {{seconds}} secondo, escludendo ciò che è aperto: {{bypassedNames}}.",
4578
- "other": "Attivo tra {{seconds}} secondi, escludendo ciò che è aperto: {{bypassedNames}}."
4579
- },
4580
- "alarm.armRefused.body": {
4581
- "one": "{{names}} è aperto. Premi di nuovo entro {{seconds}}s per inserire senza di esso.",
4582
- "other": "Sono aperti: {{names}}. Premi di nuovo entro {{seconds}}s per inserire senza di essi."
4583
- },
4531
+ "alarm.arm.body.allDevices": "Attivi: tutti i dispositivi",
4532
+ "alarm.arm.body.noDevices": "Attivi: nessuna restrizione",
4533
+ "alarm.arm.body.devices": "Attivi: {{names}}",
4534
+ "alarm.arm.body.devicesSkipped": "Attivi: {{names}}\nEsclusi: {{skippedNames}}",
4535
+ "alarm.arm.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
4536
+ "alarm.arm.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
4537
+ "alarm.arming.body": "Nessuna esclusione",
4538
+ "alarm.arming.body.uncovered": "Attivi: nessuno — nessuna regola usa questa modalità",
4539
+ "alarm.arming.body.allSkipped": "Attivi: nessuno\nEsclusi: {{names}}",
4540
+ "alarm.arming.body.skipped": "Esclusi: {{names}}",
4541
+ "alarm.arm.body.bypassed": "Attivi: {{devices}}\nBypassati: {{bypassedNames}}",
4542
+ "alarm.arming.body.bypassed": "Bypassati: {{bypassedNames}}",
4543
+ "alarm.armRefused.body": "Aperti: {{names}}\nRipeti entro {{seconds}}s per inserire con bypass.",
4584
4544
  "alarm.device.unnamed": "Dispositivo {{id}}",
4545
+ "alarm.combined.title": "Allarme: {{deviceName}}",
4585
4546
  "system.alarm-arm-refused.title.home": "Inserimento Casa rifiutato",
4586
4547
  "system.alarm-arm-refused.title.away": "Inserimento Fuori rifiutato",
4587
4548
  "system.alarm-arm-refused.title.night": "Inserimento Notte rifiutato",
4588
- "system.alarm-arm-refused.body": "L'allarme non è stato inserito — qualcosa che copre è aperto.",
4589
- "system.alarm-arming.title.home": "Inserimento Casa",
4590
- "system.alarm-arming.title.away": "Inserimento Fuori",
4591
- "system.alarm-arming.title.night": "Inserimento Notte",
4592
- "system.alarm-arming.body": "L'allarme si sta inserendo",
4593
- "system.alarm-armed.title.home": "Casa attivato",
4594
- "system.alarm-armed.title.away": "Fuori attivato",
4595
- "system.alarm-armed.title.night": "Notte attivato",
4596
- "system.alarm-armed.body": "L'allarme è stato inserito",
4597
- "system.alarm-disarmed.title": "Allarme disattivato",
4598
- "system.alarm-disarmed.body": "L'allarme è stato disattivato",
4549
+ "system.alarm-arm-refused.body": "Inserimento rifiutato",
4550
+ "system.alarm-arming.title.home": "Inserimento Casa — {{seconds}}s",
4551
+ "system.alarm-arming.title.away": "Inserimento Fuori — {{seconds}}s",
4552
+ "system.alarm-arming.title.night": "Inserimento Notte — {{seconds}}s",
4553
+ "system.alarm-arming.body": "Inserimento in corso",
4554
+ "system.alarm-armed.title.home": "Inserito: Casa",
4555
+ "system.alarm-armed.title.away": "Inserito: Fuori",
4556
+ "system.alarm-armed.title.night": "Inserito: Notte",
4557
+ "system.alarm-armed.body": "Allarme inserito",
4558
+ "system.alarm-disarmed.title": "Disinserito",
4559
+ "system.alarm-disarmed.body": "Allarme disinserito",
4599
4560
  "system.alarm-triggered.title": "ALLARME",
4600
- "system.alarm-triggered.body": "L'allarme è scattato",
4601
- "system.alarm-triggered.body.rule": "L'allarme è scattato per {{rule}} su {{deviceName}}",
4561
+ "system.alarm-triggered.body": "Allarme scattato",
4562
+ "system.alarm-triggered.body.rule": "Scattato da {{rule}} su {{deviceName}}",
4602
4563
  "button.snooze.minutes": "Silenzia {{minutes}} min",
4603
4564
  "button.snooze.hours": {
4604
4565
  "one": "Silenzia {{hours}} ora",
@@ -5317,7 +5278,8 @@ var NcDispatcher = class {
5317
5278
  const systemEvent = subject.systemEvent;
5318
5279
  const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
5319
5280
  const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
5320
- const zoneLabels = systemEvent !== void 0 ? [] : await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones);
5281
+ const alarmSource = systemEvent?.alarmSource;
5282
+ const zoneLabels = systemEvent === void 0 ? await resolveZoneLabels(this.deps.getZoneNames, subject.deviceId, subject.zones) : alarmSource?.zones !== void 0 && alarmSource.zones.length > 0 ? await resolveZoneLabels(this.deps.getZoneNames, alarmSource.deviceId, alarmSource.zones) : [];
5321
5283
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
5322
5284
  const systemCount = systemEvent?.textCount !== void 0 ? { count: systemEvent.textCount } : {};
5323
5285
  const title = this.renderText({
@@ -5660,6 +5622,10 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
5660
5622
  return [...zoneIds];
5661
5623
  }
5662
5624
  }
5625
+ /** `{{confidence}}` as a whole percent, or empty when nothing measured one. */
5626
+ function percentOf(confidence) {
5627
+ return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
5628
+ }
5663
5629
  /**
5664
5630
  * The variable map ONE render pass sees: scalars, then the composites built
5665
5631
  * from them, then the params a system-event row froze at intake.
@@ -5675,19 +5641,23 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
5675
5641
  const subject = entry.payload.subject;
5676
5642
  const occupancy = subject.occupancy;
5677
5643
  const systemEvent = subject.systemEvent;
5644
+ const detection = systemEvent?.alarmSource?.className !== void 0 ? systemEvent.alarmSource : void 0;
5678
5645
  return {
5679
5646
  camera: deviceName,
5680
- class: subject.className,
5681
- label: subject.label ?? "",
5647
+ class: detection?.className ?? subject.className,
5648
+ label: detection?.label ?? subject.label ?? "",
5682
5649
  zones: zoneLabels.join(", "),
5683
- subject: systemEvent?.subject ?? ncSubjectText(texts, {
5650
+ subject: detection?.className !== void 0 ? ncSubjectText(texts, {
5651
+ className: detection.className,
5652
+ ...detection.label !== void 0 ? { label: detection.label } : {}
5653
+ }) : systemEvent?.subject ?? ncSubjectText(texts, {
5684
5654
  className: subject.className,
5685
5655
  ...subject.label !== void 0 ? { label: subject.label } : {}
5686
5656
  }),
5687
5657
  inZones: ncInZonesText(texts, zoneLabels),
5688
5658
  scope: occupancy?.zone ?? deviceName,
5689
5659
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
5690
- confidence: subject.confidence !== void 0 ? `${Math.round(subject.confidence * 100)}%` : "",
5660
+ confidence: percentOf(detection?.confidence ?? subject.confidence),
5691
5661
  time: new Date(subject.timestamp).toLocaleTimeString(),
5692
5662
  rule: entry.payload.ruleName,
5693
5663
  count: occupancy !== void 0 ? `${occupancy.count}` : "",
@@ -6227,6 +6197,35 @@ function incomingFromSystemEvent(event, lookup) {
6227
6197
  }
6228
6198
  }
6229
6199
  /**
6200
+ * The COMBINED row's two keys, or null when this transition is not one.
6201
+ *
6202
+ * A rule that trips the alarm produces ONE notification, and the operator has
6203
+ * to be able to read it: the title is the severity marker over the camera
6204
+ * (`alarm.combined.title`, shipped as `Alarm: {{deviceName}}` and re-wordable
6205
+ * per rule or in the Texts tab), and the body is `detection.body` — the exact
6206
+ * sentence that rule's ordinary notification would have carried, with its own
6207
+ * class, identity and zones.
6208
+ *
6209
+ * The alarm used to write its own prose here ("The alarm was triggered by
6210
+ * Person at gate on Ingresso"), which made the one notification that matters
6211
+ * the only one in the system that did not look like the rule's others: no
6212
+ * identity, no zone, a different subject. The prefix carries the severity; the
6213
+ * familiar line carries the meaning.
6214
+ *
6215
+ * **Gated on the detection vocabulary, not on the source.** `detection.body`
6216
+ * renders `{{subject}}`, and a fire ref with no class composes it from the
6217
+ * system event's dedup slot — "alarm:42 su Ingresso". A source without a class
6218
+ * therefore keeps the alarm's own sentence, which is also what every row
6219
+ * enqueued before this deploy carries.
6220
+ */
6221
+ function combinedAlarmText(source) {
6222
+ if (source?.className === void 0) return null;
6223
+ return {
6224
+ titleKey: "alarm.combined.title",
6225
+ bodyKey: "detection.body"
6226
+ };
6227
+ }
6228
+ /**
6230
6229
  * The panel's own transition, as a system-event subject.
6231
6230
  *
6232
6231
  * IN-PROCESS, not off the bus. The alarm and the Notification Center live in
@@ -6249,6 +6248,7 @@ function incomingFromAlarmTransition(input) {
6249
6248
  const deviceId = source?.deviceId ?? panelDeviceId;
6250
6249
  const identity = input.lookup(deviceId);
6251
6250
  const kind = transition.kind;
6251
+ const combined = combinedAlarmText(source);
6252
6252
  return systemSubject({
6253
6253
  facts: {
6254
6254
  eventId: `alarm:${kind}:${String(transition.at)}`,
@@ -6264,12 +6264,13 @@ function incomingFromAlarmTransition(input) {
6264
6264
  },
6265
6265
  kind,
6266
6266
  subject: `alarm:${String(panelDeviceId)}`,
6267
- titleKey: transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6268
- bodyKey: input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6267
+ titleKey: combined !== null ? combined.titleKey : transition.mode !== void 0 ? `system.${kind}.title.${transition.mode}` : `system.${kind}.title`,
6268
+ bodyKey: combined?.bodyKey ?? input.body?.bodyKey ?? (source !== void 0 ? `system.${kind}.body.rule` : `system.${kind}.body`),
6269
6269
  textParams: {
6270
6270
  ...deviceTextParams(deviceId, identity),
6271
6271
  ...transition.mode !== void 0 ? { mode: transition.mode } : {},
6272
6272
  ...source !== void 0 ? { rule: source.ruleName } : {},
6273
+ ...kind === "alarm-arming" ? { seconds: `${transition.seconds ?? 0}` } : {},
6273
6274
  ...input.body?.textParams
6274
6275
  },
6275
6276
  deviceId,
@@ -12584,6 +12585,7 @@ function frozenOccupancy(rule, occ) {
12584
12585
  */
12585
12586
  function fireRefFor(rule, subject, kind) {
12586
12587
  const hasEventMedia = kind === "object-event" || kind === "package-event";
12588
+ const className = subject.classNames[0];
12587
12589
  return {
12588
12590
  ruleId: rule.id,
12589
12591
  ruleName: rule.name,
@@ -12592,7 +12594,11 @@ function fireRefFor(rule, subject, kind) {
12592
12594
  recordId: subject.recordId,
12593
12595
  ...subject.trackId !== void 0 ? { trackId: subject.trackId } : {},
12594
12596
  ...hasEventMedia ? { eventId: subject.recordId } : {},
12595
- timestamp: subject.timestamp
12597
+ timestamp: subject.timestamp,
12598
+ ...className !== void 0 ? { className } : {},
12599
+ ...subject.label !== void 0 ? { label: subject.label } : {},
12600
+ zones: notificationZones(rule, subject),
12601
+ ...subject.confidence !== void 0 ? { confidence: subject.confidence } : {}
12596
12602
  };
12597
12603
  }
12598
12604
  function notificationZones(rule, subject) {
@@ -36070,6 +36076,138 @@ var BindingCache = class {
36070
36076
  }
36071
36077
  };
36072
36078
  //#endregion
36079
+ //#region src/pipeline-analytics/runtime/detection-coverage-cache.ts
36080
+ /**
36081
+ * DetectionCoverageCache — fleet-seeded, event-invalidated mirror of WHICH
36082
+ * devices carry a WRAPPED `detection-pipeline` binding. This is the alarm's
36083
+ * coverage authority ("Away armed, 12 cameras" is false if three of them are
36084
+ * offline or no longer wrapped), read by the notification centre's device
36085
+ * directory (`listDeviceIdentities`).
36086
+ *
36087
+ * WHY a mirror, not a per-tick sweep. The directory refreshes on the NC's 30 s
36088
+ * rule-reload tick. Answering "who still has detection?" with a fleet-wide
36089
+ * `getAllBindings` on EVERY tick cost 903 ms / 373 KB / ~505 `getBindings:
36090
+ * default wrappers skipped` log lines on the live hub (524 devices), forever —
36091
+ * ~3% of hub-main's main thread plus a permanent log-spam baseline. Same defect
36092
+ * and fix as `SystemMirror.refreshBinding` (639073e8): ONE fleet read to warm,
36093
+ * then per-device updates driven by `DeviceBindingsChanged` events. A wrapper
36094
+ * toggle emits that event carrying the device's id and `capName`
36095
+ * (`detection-pipeline`), so a single `getBindings(deviceId)` — 21 ms / 613 B —
36096
+ * keeps the mirror current without ever touching the fleet again.
36097
+ *
36098
+ * ── Destructive-direction discipline (D49) ──────────────────────────────────
36099
+ *
36100
+ * `detectionActive: false` reports a camera as UNCOVERED / switched-off (D62) —
36101
+ * the destructive answer, and the one an operator reads as "my alarm is not
36102
+ * watching that door". A transient `getBindings` blip must never flip a
36103
+ * known-covered camera to uncovered:
36104
+ * • a failed refresh KEEPS the previous value (a read that throws changes
36105
+ * nothing), and
36106
+ * • a single read that would flip a known `true → false` is confirmed by a
36107
+ * concordant SECOND read before it lands — an empty binding list is not
36108
+ * proof the wrapper is gone.
36109
+ * A COLD mirror (seed not yet landed, or a seed that failed) answers `undefined`
36110
+ * for every id — and unknown never reports a camera as excluded from coverage.
36111
+ */
36112
+ var DetectionCoverageCache = class {
36113
+ api;
36114
+ logger;
36115
+ /** deviceId → "has a WRAPPED detection-pipeline binding". Absent = unknown. */
36116
+ state = /* @__PURE__ */ new Map();
36117
+ warm = false;
36118
+ seeding = null;
36119
+ constructor(deps) {
36120
+ this.api = deps.api;
36121
+ this.logger = deps.logger;
36122
+ }
36123
+ /** Has the ONE fleet seed ever landed? A cold mirror knows nothing. */
36124
+ isWarm() {
36125
+ return this.warm;
36126
+ }
36127
+ /** Do we have an answer for this id at all? (`false` ⇒ absent ⇒ unknown.) */
36128
+ has(deviceId) {
36129
+ return this.state.has(deviceId);
36130
+ }
36131
+ /** The coverage answer, or `undefined` when this id is unknown. No I/O. */
36132
+ get(deviceId) {
36133
+ return this.state.get(deviceId);
36134
+ }
36135
+ /**
36136
+ * Seed the whole fleet ONCE with a single `getAllBindings`. Idempotent: a
36137
+ * warm cache is a no-op, and concurrent callers coalesce onto one read. A
36138
+ * failed seed leaves the mirror COLD so the next tick retries — during that
36139
+ * window every id answers unknown, which never reports a camera as excluded.
36140
+ */
36141
+ async ensureWarm() {
36142
+ if (this.warm) return;
36143
+ if (this.seeding) return this.seeding;
36144
+ this.seeding = this.seed();
36145
+ try {
36146
+ await this.seeding;
36147
+ } finally {
36148
+ this.seeding = null;
36149
+ }
36150
+ }
36151
+ async seed() {
36152
+ try {
36153
+ const rows = await this.api.deviceManager.getAllBindings.query({});
36154
+ for (const row of rows) {
36155
+ if (this.state.has(row.deviceId)) continue;
36156
+ this.state.set(row.deviceId, hasWrappedDetection(row.entries));
36157
+ }
36158
+ this.warm = true;
36159
+ this.logger.info("detection-coverage mirror warm", { meta: { devices: this.state.size } });
36160
+ } catch (err) {
36161
+ this.logger.warn("detection-coverage seed failed — coverage unknown until it lands", { meta: { error: String(err) } });
36162
+ }
36163
+ }
36164
+ /**
36165
+ * A `DeviceBindingsChanged` for the detection cap re-reads THAT ONE device
36166
+ * (never the fleet). Fire-and-forget from the synchronous event handler.
36167
+ */
36168
+ onBindingsChanged(event) {
36169
+ if (event.capName !== "detection-pipeline") return Promise.resolve();
36170
+ return this.refreshDevice(event.deviceId);
36171
+ }
36172
+ /** Drop a device the fleet no longer has (`device.unregistered`). */
36173
+ forget(deviceId) {
36174
+ this.state.delete(deviceId);
36175
+ }
36176
+ /** Return to cold — used on addon shutdown. */
36177
+ clearAll() {
36178
+ this.state.clear();
36179
+ this.warm = false;
36180
+ this.seeding = null;
36181
+ }
36182
+ async refreshDevice(deviceId) {
36183
+ try {
36184
+ const active = await this.readWrappedDetection(deviceId);
36185
+ const prev = this.state.get(deviceId);
36186
+ if (!active && prev === true) {
36187
+ if (await this.readWrappedDetection(deviceId)) {
36188
+ this.state.set(deviceId, true);
36189
+ return;
36190
+ }
36191
+ }
36192
+ this.state.set(deviceId, active);
36193
+ } catch (err) {
36194
+ this.logger.debug("detection-coverage refresh failed — keeping the previous value", {
36195
+ tags: { deviceId },
36196
+ meta: { error: String(err) }
36197
+ });
36198
+ }
36199
+ }
36200
+ async readWrappedDetection(deviceId) {
36201
+ return hasWrappedDetection((await this.api.deviceManager.getBindings.query({ deviceId })).entries);
36202
+ }
36203
+ };
36204
+ /** The one authority: a WRAPPED `detection-pipeline` binding is present. A
36205
+ * native binding of the same cap is NOT coverage (D62: absence of the WRAPPED
36206
+ * binding is how a deactivated wrapper is represented). */
36207
+ function hasWrappedDetection(entries) {
36208
+ return entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped");
36209
+ }
36210
+ //#endregion
36073
36211
  //#region src/pipeline-analytics/runtime/capture-scheduler.ts
36074
36212
  /**
36075
36213
  * Kind-derived default priorities (0 = highest). Explicit `priority` wins.
@@ -41452,6 +41590,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
41452
41590
  * path). Null on non-post-processing nodes or when disabled. */
41453
41591
  embeddingDispatcher = null;
41454
41592
  bindingCache = null;
41593
+ /** Alarm-coverage mirror the NC device directory reads — WHICH devices carry
41594
+ * a wrapped `detection-pipeline` binding. Fleet-seeded ONCE, then per-device
41595
+ * updates off `DeviceBindingsChanged`, so the 30 s rule-reload tick stops
41596
+ * sweeping `getAllBindings` over the whole fleet. See
41597
+ * `runtime/detection-coverage-cache.ts`. */
41598
+ detectionCoverage = null;
41455
41599
  /** Per-device throttle for the inactive-binding frame-drop warn (5 min). */
41456
41600
  inactiveBindingDropWarnAt = /* @__PURE__ */ new Map();
41457
41601
  zoneAnalytics = null;
@@ -42635,6 +42779,10 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42635
42779
  api,
42636
42780
  logger: logger.child("BindingCache")
42637
42781
  });
42782
+ this.detectionCoverage = new DetectionCoverageCache({
42783
+ api,
42784
+ logger: logger.child("DetectionCoverage")
42785
+ });
42638
42786
  const zoneAnalytics = new ZoneAnalyticsProvider({
42639
42787
  logger: logger.child("ZoneAnalytics"),
42640
42788
  onOccupancySample: (deviceId, count, at, zoneId) => this.lts?.note("occupancy", deviceId, count, at, zoneId ?? ""),
@@ -42716,17 +42864,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
42716
42864
  },
42717
42865
  listDeviceIdentities: async () => {
42718
42866
  const all = await api.deviceManager.listAll.query({ projection: "slim" });
42719
- const detection = await api.deviceManager.getAllBindings.query({}).then((rows) => new Map(rows.map((row) => [row.deviceId, row.entries.some((e) => e.capName === "detection-pipeline" && e.kind === "wrapped")]))).catch((err) => {
42720
- this.ctx.logger.debug("bindings read failed — alarm coverage cannot report skips", { meta: { error: String(err) } });
42721
- return /* @__PURE__ */ new Map();
42722
- });
42867
+ const coverage = this.detectionCoverage;
42868
+ if (coverage) await coverage.ensureWarm();
42723
42869
  return all.map((d) => ({
42724
42870
  deviceId: d.id,
42725
42871
  type: d.type,
42726
42872
  name: d.name,
42727
42873
  isCamera: d.isCamera,
42728
42874
  online: d.online && !d.disabled,
42729
- ...detection.has(d.id) ? { detectionActive: detection.get(d.id) === true } : {}
42875
+ ...coverage?.has(d.id) ? { detectionActive: coverage.get(d.id) === true } : {}
42730
42876
  }));
42731
42877
  },
42732
42878
  readDeviceZoneIds: async (ids) => {
@@ -43434,6 +43580,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43434
43580
  * `runtime/wire-subscriptions.ts`. */
43435
43581
  handleBindingsChangedEvent(data) {
43436
43582
  this.bindingCache?.onBindingsChanged(data);
43583
+ this.detectionCoverage?.onBindingsChanged(data);
43437
43584
  if (data.capName === "pipeline-analytics" && data.reason === "wrapper-deactivated") {
43438
43585
  this.trackStore?.clearDevice(data.deviceId);
43439
43586
  this.residents.closeDevice(data.deviceId);
@@ -43459,6 +43606,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43459
43606
  this.audioMarkers?.forgetDevice(deviceId);
43460
43607
  this.settingsCaches.clearDevice(deviceId);
43461
43608
  this.bindingCache?.invalidate(deviceId);
43609
+ this.detectionCoverage?.forget(deviceId);
43462
43610
  this.zoneAnalytics?.forgetDevice(deviceId);
43463
43611
  this.audioMetrics?.forgetDevice(deviceId);
43464
43612
  this.releaseProxy(deviceId);
@@ -43664,6 +43812,7 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
43664
43812
  this.trackStore?.clearAll();
43665
43813
  this.stationaryRegistry = null;
43666
43814
  this.bindingCache?.clearAll();
43815
+ this.detectionCoverage?.clearAll();
43667
43816
  await this.eventMediaDataPlane?.dispose();
43668
43817
  this.eventMediaDataPlane = null;
43669
43818
  this.eventMediaBaseUrl = null;