@camstack/addon-post-analysis 1.2.620 → 1.2.622

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-CcgzpELQ.js");
5
+ const require_clip_model_registry = require("../clip-model-registry-MHJ9BSUV.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");
@@ -5402,15 +5402,28 @@ async function notificationLinkEndpoints(api, logger) {
5402
5402
  */
5403
5403
  var NC_TRACKS_AT_INSTANT_LIMIT = 25;
5404
5404
  /**
5405
- * Every source track with a position within `toleranceMs` of `timestamp`, its
5406
- * nearest box normalised, deduplicated by track id (a live copy and a
5407
- * persisted copy of one track are one track). Pure.
5405
+ * How far outside its own life a track may still be drawn: one detection tick.
5406
+ *
5407
+ * The box TOLERANCE (`toleranceMs`, 750 ms) is about how sparse a trail is —
5408
+ * a live track keeps a position every few frames — and it must never stretch
5409
+ * a LIFE. A track born after the frame was not on it: on 2026-10-22 the
5410
+ * tracker split a dog on camera 615 and the second piece, born 676 ms after
5411
+ * the key frame, was drawn at its first box as a second box on the dog. One
5412
+ * tick (~110–150 ms at that camera's detection rate, rounded up) absorbs the
5413
+ * gap between a frame and the detection that confirmed it, and nothing more.
5414
+ */
5415
+ var NC_TRACK_LIFE_SLACK_MS = 200;
5416
+ /**
5417
+ * Every source track ALIVE at `timestamp` (± {@link NC_TRACK_LIFE_SLACK_MS})
5418
+ * with a position within `toleranceMs` of it, its nearest box normalised,
5419
+ * deduplicated by track id (a live copy and a persisted copy of one track are
5420
+ * one track). Pure.
5408
5421
  */
5409
5422
  function tracksAtInstant(sources, timestamp, toleranceMs, fallbackDims) {
5410
5423
  const out = /* @__PURE__ */ new Map();
5411
5424
  for (const track of sources) {
5412
5425
  if (out.has(track.trackId)) continue;
5413
- if (track.firstSeen > timestamp + toleranceMs || track.lastSeen < timestamp - toleranceMs) continue;
5426
+ if (track.firstSeen > timestamp + NC_TRACK_LIFE_SLACK_MS || track.lastSeen < timestamp - NC_TRACK_LIFE_SLACK_MS) continue;
5414
5427
  const w = track.frameWidth ?? fallbackDims?.w ?? 0;
5415
5428
  const h = track.frameHeight ?? fallbackDims?.h ?? 0;
5416
5429
  if (w <= 0 || h <= 0) continue;
@@ -20451,8 +20464,8 @@ var FACE_REF_COLUMNS = ["deviceId"];
20451
20464
  * D312) a predicate that silently matched nothing. The faces ledger therefore derives a
20452
20465
  * boolean `recognized` from every write that touches `recognizedIdentityId`
20453
20466
  * (`face-recognized.ts`), and the views are plain ranges over the `recognized_ts` /
20454
- * `assigned_recognized_ts` indexes. A row written before the field existed lacks it until
20455
- * `scripts/hub/backfill-faces-recognized.ts` has run.
20467
+ * `assigned_recognized_ts` indexes. Rows written before the field existed were
20468
+ * backfilled offline on the operator hubs on 2026-10-22 (the one-shot script is deleted).
20456
20469
  *
20457
20470
  * `assigned` is `BOOLEAN NOT NULL`; the backend serialises `false` to `0` at
20458
20471
  * the bind (`serializeColumnValue`) and decodes `0` back to `false` on read, so
@@ -28515,6 +28528,33 @@ function incomingFromSystemEvent(event, lookup) {
28515
28528
  ...nodeId !== void 0 ? { nodeId } : {}
28516
28529
  });
28517
28530
  }
28531
+ case "notification.rule-broken":
28532
+ case "notification.rule-repaired": {
28533
+ const ruleId = stringField(data, "ruleId");
28534
+ if (ruleId === void 0) return null;
28535
+ const broken = event.category === "notification.rule-broken";
28536
+ const kind = broken ? "rule-broken" : "rule-repaired";
28537
+ const ruleName = stringField(data, "ruleName") ?? ruleId;
28538
+ const reason = stringField(data, "reason") ?? "unknown";
28539
+ const ids = data["offendingIds"];
28540
+ const offendingIds = Array.isArray(ids) ? ids.filter((id) => typeof id === "string").join(", ") : "";
28541
+ return systemSubject({
28542
+ facts,
28543
+ kind,
28544
+ subject: `rule-health:${ruleId}`,
28545
+ titleKey: `system.${kind}.title`,
28546
+ bodyKey: broken ? `system.rule-broken.body.${reason}` : "system.rule-repaired.body",
28547
+ textParams: broken ? {
28548
+ ruleId,
28549
+ ruleName,
28550
+ brokenReason: reason,
28551
+ offendingIds
28552
+ } : {
28553
+ ruleId,
28554
+ ruleName
28555
+ }
28556
+ });
28557
+ }
28518
28558
  case "update.available": return incomingFromUpdateAvailable(facts, data);
28519
28559
  case "addon.updated": {
28520
28560
  const update = parseAddonUpdateComplete(data);
@@ -30520,8 +30560,8 @@ var en_default = {
30520
30560
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
30521
30561
  "occupancy.op.occupied": "occupied",
30522
30562
  "occupancy.op.free": "free",
30523
- "system.device-online.title": "Online — {{deviceName}}",
30524
- "system.device-online.body": "Device {{deviceName}} is online",
30563
+ "system.device-online.title": "Back online — {{deviceName}}",
30564
+ "system.device-online.body": "Device {{deviceName}} is back online",
30525
30565
  "system.device-offline.title": "Offline — {{deviceName}}",
30526
30566
  "system.device-offline.body": "Device {{deviceName}} is offline",
30527
30567
  "system.device-disabled.title": "Switched off — {{deviceName}}",
@@ -30534,14 +30574,14 @@ var en_default = {
30534
30574
  "system.device-battery-normal.title": "Battery back to normal — {{deviceName}}",
30535
30575
  "system.device-battery-normal.body": "{{deviceName}} is back to {{percentage}}% battery",
30536
30576
  "system.device-battery-normal.body.binary": "{{deviceName}} no longer reports a low battery",
30537
- "system.stream-online.title": "Stream online — {{deviceName}}",
30538
- "system.stream-online.body": "Stream for camera {{deviceName}} is online",
30539
- "system.stream-online.body.profiled": "{{profile}} stream for camera {{deviceName}} is online",
30577
+ "system.stream-online.title": "Stream back online — {{deviceName}}",
30578
+ "system.stream-online.body": "Stream for camera {{deviceName}} is back online",
30579
+ "system.stream-online.body.profiled": "{{profile}} stream for camera {{deviceName}} is back online",
30540
30580
  "system.stream-offline.title": "Stream offline — {{deviceName}}",
30541
30581
  "system.stream-offline.body": "Stream for camera {{deviceName}} is offline",
30542
30582
  "system.stream-offline.body.profiled": "{{profile}} stream for camera {{deviceName}} is offline",
30543
- "system.node-online.title": "Node online — {{nodeName}}",
30544
- "system.node-online.body.agent": "Agent {{nodeName}} is online",
30583
+ "system.node-online.title": "Node back online — {{nodeName}}",
30584
+ "system.node-online.body.agent": "Agent {{nodeName}} is back online",
30545
30585
  "system.node-online.body.agent.after": "Agent {{nodeName}} is back online after {{offlineFor}} offline",
30546
30586
  "system.node-offline.title": "Node offline — {{nodeName}}",
30547
30587
  "system.node-offline.body.agent": "Agent {{nodeName}} is offline",
@@ -30561,7 +30601,7 @@ var en_default = {
30561
30601
  "one": "Camera {{deviceName}} has not written a recording segment for {{minutes}} minute",
30562
30602
  "other": "Camera {{deviceName}} has not written a recording segment for {{minutes}} minutes"
30563
30603
  },
30564
- "system.recording-healthy.title": "Recording healthy — {{deviceName}}",
30604
+ "system.recording-healthy.title": "Recording again — {{deviceName}}",
30565
30605
  "system.recording-healthy.body": "Camera {{deviceName}} is writing recording segments again",
30566
30606
  "system.recording-degraded.title": "Recording degraded — {{deviceName}}",
30567
30607
  "system.recording-degraded.body": "Camera {{deviceName}} exhausted its writer restart budget ({{message}})",
@@ -30822,7 +30862,13 @@ var en_default = {
30822
30862
  "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
30823
30863
  "calendar-events.line.allDay": "• All day {{title}}{{where}}",
30824
30864
  "calendar-events.where": " @ {{location}}",
30825
- "calendar-events.more": "…and {{count}} more"
30865
+ "calendar-events.more": "…and {{count}} more",
30866
+ "system.rule-broken.title": "Notification rule broken — {{ruleName}}",
30867
+ "system.rule-broken.body.unparseable": "The rule \"{{ruleName}}\" cannot be loaded and sends nothing. Open it and save it again.",
30868
+ "system.rule-broken.body.device-deleted": "The rule \"{{ruleName}}\" still names a deleted device ({{offendingIds}}) and was switched off. Pick the device again.",
30869
+ "system.rule-broken.body.target-deleted": "The rule \"{{ruleName}}\" still sends to a deleted destination ({{offendingIds}}) and was switched off. Pick a destination again.",
30870
+ "system.rule-repaired.title": "Notification rule works again — {{ruleName}}",
30871
+ "system.rule-repaired.body": "The rule \"{{ruleName}}\" loads and names nothing deleted any more."
30826
30872
  };
30827
30873
  //#endregion
30828
30874
  //#region src/notification-center/texts/it.json
@@ -30907,8 +30953,8 @@ var it_default = {
30907
30953
  "occupancy.body": "{{scope}} {{op}} ({{count}}/{{capacity}})",
30908
30954
  "occupancy.op.occupied": "occupata",
30909
30955
  "occupancy.op.free": "libera",
30910
- "system.device-online.title": "Online — {{deviceName}}",
30911
- "system.device-online.body": "Il dispositivo {{deviceName}} è online",
30956
+ "system.device-online.title": "Di nuovo online — {{deviceName}}",
30957
+ "system.device-online.body": "Il dispositivo {{deviceName}} è di nuovo online",
30912
30958
  "system.device-offline.title": "Offline — {{deviceName}}",
30913
30959
  "system.device-offline.body": "Il dispositivo {{deviceName}} è offline",
30914
30960
  "system.device-disabled.title": "Spento — {{deviceName}}",
@@ -30921,14 +30967,14 @@ var it_default = {
30921
30967
  "system.device-battery-normal.title": "Batteria rientrata — {{deviceName}}",
30922
30968
  "system.device-battery-normal.body": "{{deviceName}} è risalito al {{percentage}}% di batteria",
30923
30969
  "system.device-battery-normal.body.binary": "{{deviceName}} non segnala più la batteria scarica",
30924
- "system.stream-online.title": "Stream online — {{deviceName}}",
30925
- "system.stream-online.body": "Lo stream della camera {{deviceName}} è online",
30926
- "system.stream-online.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è online",
30970
+ "system.stream-online.title": "Stream di nuovo online — {{deviceName}}",
30971
+ "system.stream-online.body": "Lo stream della camera {{deviceName}} è di nuovo online",
30972
+ "system.stream-online.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è di nuovo online",
30927
30973
  "system.stream-offline.title": "Stream offline — {{deviceName}}",
30928
30974
  "system.stream-offline.body": "Lo stream della camera {{deviceName}} è offline",
30929
30975
  "system.stream-offline.body.profiled": "Lo stream {{profile}} della camera {{deviceName}} è offline",
30930
- "system.node-online.title": "Nodo online — {{nodeName}}",
30931
- "system.node-online.body.agent": "L'agent {{nodeName}} è online",
30976
+ "system.node-online.title": "Nodo di nuovo online — {{nodeName}}",
30977
+ "system.node-online.body.agent": "L'agent {{nodeName}} è di nuovo online",
30932
30978
  "system.node-online.body.agent.after": "L'agent {{nodeName}} è di nuovo online dopo {{offlineFor}} offline",
30933
30979
  "system.node-offline.title": "Nodo offline — {{nodeName}}",
30934
30980
  "system.node-offline.body.agent": "L'agent {{nodeName}} è offline",
@@ -30943,7 +30989,7 @@ var it_default = {
30943
30989
  "one": "La camera {{deviceName}} non scrive un segmento da {{minutes}} minuto",
30944
30990
  "other": "La camera {{deviceName}} non scrive un segmento da {{minutes}} minuti"
30945
30991
  },
30946
- "system.recording-healthy.title": "Registrazione ok — {{deviceName}}",
30992
+ "system.recording-healthy.title": "Registra di nuovo — {{deviceName}}",
30947
30993
  "system.recording-healthy.body": "La camera {{deviceName}} scrive di nuovo i segmenti",
30948
30994
  "system.recording-degraded.title": "Registrazione degradata — {{deviceName}}",
30949
30995
  "system.recording-degraded.body": "La camera {{deviceName}} ha esaurito i riavvii del writer ({{message}})",
@@ -31209,7 +31255,13 @@ var it_default = {
31209
31255
  "calendar-events.line.timed": "• {{when}} {{title}}{{where}}",
31210
31256
  "calendar-events.line.allDay": "• Tutto il giorno {{title}}{{where}}",
31211
31257
  "calendar-events.where": " @ {{location}}",
31212
- "calendar-events.more": "…e altri {{count}}"
31258
+ "calendar-events.more": "…e altri {{count}}",
31259
+ "system.rule-broken.title": "Regola di notifica rotta — {{ruleName}}",
31260
+ "system.rule-broken.body.unparseable": "La regola \"{{ruleName}}\" non si carica e non invia nulla. Aprila e salvala di nuovo.",
31261
+ "system.rule-broken.body.device-deleted": "La regola \"{{ruleName}}\" usa ancora un dispositivo eliminato ({{offendingIds}}) ed è stata disattivata. Scegli di nuovo il dispositivo.",
31262
+ "system.rule-broken.body.target-deleted": "La regola \"{{ruleName}}\" invia ancora a una destinazione eliminata ({{offendingIds}}) ed è stata disattivata. Scegli di nuovo una destinazione.",
31263
+ "system.rule-repaired.title": "Regola di notifica di nuovo funzionante — {{ruleName}}",
31264
+ "system.rule-repaired.body": "La regola \"{{ruleName}}\" si carica e non usa più nulla di eliminato."
31213
31265
  };
31214
31266
  //#endregion
31215
31267
  //#region src/notification-center/text-catalog.ts
@@ -33480,6 +33532,10 @@ function systemEventKeys(kind) {
33480
33532
  titleKey: `system.${kind}.title`,
33481
33533
  bodyKey: `system.${kind}.body.cleanWater`
33482
33534
  };
33535
+ if (kind === "rule-broken") return {
33536
+ titleKey: `system.${kind}.title`,
33537
+ bodyKey: `system.${kind}.body.device-deleted`
33538
+ };
33483
33539
  return {
33484
33540
  titleKey: `system.${kind}.title`,
33485
33541
  bodyKey: `system.${kind}.body`
@@ -39735,6 +39791,99 @@ var NcRecordedCutMemo = class {
39735
39791
  }
39736
39792
  };
39737
39793
  //#endregion
39794
+ //#region src/notification-center/notification-label-layout.ts
39795
+ /**
39796
+ * Where each box's caption goes on a notification picture — pure geometry,
39797
+ * rects in, placements out (D897 follow-up, 2026-10-22).
39798
+ *
39799
+ * A caption drawn INSIDE its box used to cover the subject whole on a small
39800
+ * box, and a caption next to a crowd landed on a neighbour's box. The rule:
39801
+ *
39802
+ * 1. Try, in order: OUTSIDE above the box (inside the picture), OUTSIDE below
39803
+ * it, then INSIDE its top edge — the last only when the box is big enough
39804
+ * that the plate is a thin band of it: plate height ≤
39805
+ * {@link NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION} of the box height and plate
39806
+ * width ≤ the box width. A fifth of the height leaves the head and torso of
39807
+ * an upright person, or most of a vehicle, visible; anything taller is the
39808
+ * caption hiding what it names.
39809
+ * 2. A candidate is refused when its plate intersects ANY other box drawn on
39810
+ * the picture, or a plate already placed.
39811
+ * 3. The subject is placed first; the other tracks follow by score, highest
39812
+ * first, so a crowd never costs the subject its caption.
39813
+ * 4. The font never shrinks to make a plate fit: no fitting candidate means
39814
+ * no caption for that box. A box without its caption still carries its
39815
+ * outline and colour.
39816
+ *
39817
+ * Coordinates are the picture's own pixels, whatever it is (a scene or a crop).
39818
+ */
39819
+ /** The inside placement is allowed only when the plate is at most this fraction of the box height. */
39820
+ var NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION = .2;
39821
+ /** A caption is never drawn smaller than this, in output pixels. */
39822
+ var NC_LABEL_MIN_FONT_PX = 14;
39823
+ /** The dark plate a caption sits on, for this text at this font size. */
39824
+ function labelPlateSize(label, fontSize) {
39825
+ return {
39826
+ w: Math.round(label.length * fontSize * .62 + fontSize * .8),
39827
+ h: Math.round(fontSize * 1.35)
39828
+ };
39829
+ }
39830
+ /** Positive-area overlap; rectangles that only touch do not intersect. */
39831
+ function intersects(a, b) {
39832
+ return a.x < b.x + b.w && b.x < a.x + a.w && a.y < b.y + b.h && b.y < a.y + a.h;
39833
+ }
39834
+ function captionOrder(boxes) {
39835
+ const rank = (b) => b.role === "subject" ? 0 : 1;
39836
+ return boxes.map((_, i) => i).sort((i, j) => {
39837
+ const a = boxes[i];
39838
+ const b = boxes[j];
39839
+ if (a === void 0 || b === void 0) return 0;
39840
+ return rank(a) - rank(b) || (b.score ?? 0) - (a.score ?? 0) || i - j;
39841
+ });
39842
+ }
39843
+ function candidates(box, plate, width, height) {
39844
+ if (plate.w > width) return [];
39845
+ const x = Math.min(box.x, Math.max(0, width - plate.w));
39846
+ const out = [];
39847
+ if (box.y - plate.h >= 0) out.push({
39848
+ x,
39849
+ y: box.y - plate.h,
39850
+ ...plate,
39851
+ where: "above"
39852
+ });
39853
+ if (box.y + box.h + plate.h <= height) out.push({
39854
+ x,
39855
+ y: box.y + box.h,
39856
+ ...plate,
39857
+ where: "below"
39858
+ });
39859
+ if (plate.h <= box.h * NC_LABEL_INSIDE_MAX_HEIGHT_FRACTION && plate.w <= box.w) out.push({
39860
+ x,
39861
+ y: box.y,
39862
+ ...plate,
39863
+ where: "inside"
39864
+ });
39865
+ return out;
39866
+ }
39867
+ /** Place every caption by the rule above. */
39868
+ function layoutBoxLabels(input) {
39869
+ const { width, height, boxes } = input;
39870
+ const fontSize = Math.max(NC_LABEL_MIN_FONT_PX, input.fontSize);
39871
+ const placements = /* @__PURE__ */ new Map();
39872
+ let dropped = 0;
39873
+ for (const i of captionOrder(boxes)) {
39874
+ const box = boxes[i];
39875
+ if (box === void 0 || box.label.length === 0) continue;
39876
+ const chosen = candidates(box, labelPlateSize(box.label, fontSize), width, height).find((c) => !boxes.some((other, j) => j !== i && intersects(c, other)) && ![...placements.values()].some((p) => intersects(c, p)));
39877
+ if (chosen === void 0) dropped += 1;
39878
+ else placements.set(i, chosen);
39879
+ }
39880
+ return {
39881
+ placements: boxes.map((_, i) => placements.get(i) ?? null),
39882
+ placed: placements.size,
39883
+ dropped
39884
+ };
39885
+ }
39886
+ //#endregion
39738
39887
  //#region src/notification-center/trigger-zone-still.ts
39739
39888
  /**
39740
39889
  * How far a candidate's instant may sit from the nearest trail point before the
@@ -39857,6 +40006,15 @@ var NC_IMAGE_OTHER_COLOR = "#f2f2f2";
39857
40006
  var NC_IMAGE_ZONE_COLOR = "#29b6f6";
39858
40007
  /** Two tracks whose boxes overlap this much at one instant are the same subject (a fused id). */
39859
40008
  var SAME_SUBJECT_IOU = .8;
40009
+ /**
40010
+ * A box of the SUBJECT'S CLASS overlapping the subject this much at one
40011
+ * instant is the subject itself — an unfused piece L2 has not joined yet
40012
+ * (D683 fuses 10–20 s later; a push leaves ~6 s after the trigger). The
40013
+ * detector's per-class NMS (`NMS_IOU_DEFAULT` 0.45) keeps one of two
40014
+ * same-class boxes above 0.45 in a frame, so two REAL subjects of one class
40015
+ * cannot overlap past it; 0.5 leaves a margin over that.
40016
+ */
40017
+ var NC_IMAGE_SAME_CLASS_SUBJECT_IOU = .5;
39860
40018
  var JPEG_QUALITY = 85;
39861
40019
  /** The caption of a box: class (or label) and confidence — "dog 87%". Language-free. */
39862
40020
  function imageLabelText(name, confidence) {
@@ -39889,15 +40047,21 @@ function toPixels(box, width, height) {
39889
40047
  h: Math.max(1, Math.min(Math.round(box.h * height), height - y))
39890
40048
  };
39891
40049
  }
40050
+ /** {@link notificationOverlay}'s SVG alone. */
40051
+ function notificationOverlaySvg(input) {
40052
+ return notificationOverlay(input).svg;
40053
+ }
39892
40054
  /**
39893
- * The SVG drawn over a scene picture of `width` × `height`. The subject is
39894
- * drawn LAST so a crowd never hides it. Captions sit on a dark plate above
39895
- * the box (inside it when the box touches the top edge), white text.
40055
+ * The SVG drawn over a picture of `width` × `height`. The subject is drawn
40056
+ * LAST so a crowd never hides it. Captions sit on a dark plate, white text,
40057
+ * where `layoutBoxLabels` puts them — outside above, outside below, inside a
40058
+ * box only when it covers a thin band of it, never on another box or caption,
40059
+ * and nowhere at all rather than over the subject.
39896
40060
  */
39897
- function notificationOverlaySvg(input) {
40061
+ function notificationOverlay(input) {
39898
40062
  const { width, height } = input;
39899
40063
  const strokes = overlayStrokes(width, height);
39900
- const fontSize = Math.max(14, Math.round(height / 36));
40064
+ const fontSize = Math.max(NC_LABEL_MIN_FONT_PX, Math.round(height / 36));
39901
40065
  const parts = [];
39902
40066
  for (const zone of input.zones) {
39903
40067
  if (zone.length < 3) continue;
@@ -39909,19 +40073,29 @@ function notificationOverlaySvg(input) {
39909
40073
  parts.push(`<polyline data-role="subject-path" points="${require_clip_model_registry.subjectPathSvgPoints(run, width, height)}" fill="none" stroke="${NC_IMAGE_SUBJECT_COLOR}" stroke-width="${String(strokes.subject)}" stroke-linejoin="round" stroke-linecap="round" stroke-opacity="0.9"/>`);
39910
40074
  }
39911
40075
  const ordered = [...input.boxes.filter((b) => b.role === "other"), ...input.boxes.filter((b) => b.role === "subject")];
39912
- for (const box of ordered) {
40076
+ const layout = layoutBoxLabels({
40077
+ width,
40078
+ height,
40079
+ fontSize,
40080
+ boxes: ordered
40081
+ });
40082
+ const plates = [];
40083
+ ordered.forEach((box, i) => {
39913
40084
  const subject = box.role === "subject";
39914
40085
  const color = subject ? NC_IMAGE_SUBJECT_COLOR : NC_IMAGE_OTHER_COLOR;
39915
40086
  const stroke = subject ? strokes.subject : strokes.other;
39916
40087
  parts.push(`<rect data-role="${box.role}" x="${String(box.x)}" y="${String(box.y)}" width="${String(box.w)}" height="${String(box.h)}" fill="none" stroke="${color}" stroke-width="${String(stroke)}"/>`);
39917
- if (box.label.length === 0) continue;
39918
- const plateH = Math.round(fontSize * 1.35);
39919
- const plateW = Math.round(box.label.length * fontSize * .62 + fontSize * .8);
39920
- const plateY = box.y - plateH >= 0 ? box.y - plateH : box.y;
39921
- const plateX = Math.min(box.x, Math.max(0, width - plateW));
39922
- parts.push(`<rect data-role="${box.role}-plate" x="${String(plateX)}" y="${String(plateY)}" width="${String(plateW)}" height="${String(plateH)}" fill="#000000" fill-opacity="0.72" stroke="${color}" stroke-width="${String(subject ? 2 : 1)}"/>`, `<text data-role="${box.role}-label" x="${String(plateX + Math.round(fontSize * .4))}" y="${String(plateY + Math.round(plateH * .75))}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="${String(fontSize)}" font-weight="${subject ? "700" : "400"}" fill="#ffffff">${escapeXml$2(box.label)}</text>`);
39923
- }
39924
- return `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${parts.join("")}</svg>`;
40088
+ const plate = layout.placements[i];
40089
+ if (plate === null || plate === void 0) return;
40090
+ plates.push(`<rect data-role="${box.role}-plate" x="${String(plate.x)}" y="${String(plate.y)}" width="${String(plate.w)}" height="${String(plate.h)}" fill="#000000" fill-opacity="0.72" stroke="${color}" stroke-width="${String(subject ? 2 : 1)}"/>`, `<text data-role="${box.role}-label" x="${String(plate.x + Math.round(fontSize * .4))}" y="${String(plate.y + Math.round(plate.h * .75))}" font-family="DejaVu Sans, Helvetica, Arial, sans-serif" font-size="${String(fontSize)}" font-weight="${subject ? "700" : "400"}" fill="#ffffff">${escapeXml$2(box.label)}</text>`);
40091
+ });
40092
+ return {
40093
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${String(width)}" height="${String(height)}">${[...parts, ...plates].join("")}</svg>`,
40094
+ labels: {
40095
+ placed: layout.placed,
40096
+ dropped: layout.dropped
40097
+ }
40098
+ };
39925
40099
  }
39926
40100
  /** The pixel rectangle a `crop` cuts from a `width` × `height` frame, or null when too small. */
39927
40101
  function subjectCropRect(box, width, height) {
@@ -39951,11 +40125,18 @@ function iou$4(a, b) {
39951
40125
  }
39952
40126
  /**
39953
40127
  * The tracks to draw thin: every track at the instant except the subject —
39954
- * the same id, or a box that is the subject's (a fused id reads under its
39955
- * survivor's name).
39956
- */
39957
- function otherTracks(tracks, subjectTrackId, subjectBox) {
39958
- return tracks.filter((t) => t.trackId !== subjectTrackId && (subjectBox === null || iou$4(t.bbox, subjectBox) < SAME_SUBJECT_IOU));
40128
+ * the same id, a box that is the subject's (a fused id reads under its
40129
+ * survivor's name), or a box of the subject's class over it past what NMS
40130
+ * lets two real subjects share (an unfused piece of the same subject).
40131
+ */
40132
+ function otherTracks(tracks, subjectTrackId, subjectBox, subjectClassName) {
40133
+ return tracks.filter((t) => {
40134
+ if (t.trackId === subjectTrackId) return false;
40135
+ if (subjectBox === null) return true;
40136
+ const overlap = iou$4(t.bbox, subjectBox);
40137
+ if (overlap >= SAME_SUBJECT_IOU) return false;
40138
+ return !(t.className === subjectClassName && overlap >= NC_IMAGE_SAME_CLASS_SUBJECT_IOU);
40139
+ });
39959
40140
  }
39960
40141
  /**
39961
40142
  * What picture a ROW asks for. A row written since D897 carries `mediaImage`;
@@ -39972,9 +40153,15 @@ function payloadImageMode(payload) {
39972
40153
  if (payload.media === "keyFrame") return "clean";
39973
40154
  return "crop";
39974
40155
  }
39975
- /** The zone ids a row's picture outlines: the zones that fired it, when it asked. */
40156
+ /**
40157
+ * The zone ids a row's picture outlines: the zone(s) THIS event triggered,
40158
+ * when it asked — never every zone the rule lists (a dog in Uscio drew Aiuola
40159
+ * too). A row that asked but froze no `matchedZoneIds` (written before the
40160
+ * field) draws none: an outline of a zone the subject never entered is a
40161
+ * picture that lies about its own reason.
40162
+ */
39976
40163
  function payloadZoneOverlayIds(payload) {
39977
- if (payload.mediaShowTriggerZone === true) return payload.triggerZoneIds ?? [];
40164
+ if (payload.mediaShowTriggerZone === true) return payload.matchedZoneIds ?? [];
39978
40165
  if (payload.mediaImage === void 0) return payload.mediaZoneIds ?? [];
39979
40166
  return [];
39980
40167
  }
@@ -40063,7 +40250,8 @@ async function renderNotificationImage(req) {
40063
40250
  const boxes = req.mode === "scene-boxes" ? [...req.others.map((o) => ({
40064
40251
  ...toPixels(o, outW, outH),
40065
40252
  role: "other",
40066
- label: o.label
40253
+ label: o.label,
40254
+ ...o.confidence !== void 0 ? { score: o.confidence } : {}
40067
40255
  })), ...req.subject !== null ? [{
40068
40256
  ...toPixels(req.subject, outW, outH),
40069
40257
  role: "subject",
@@ -40081,16 +40269,18 @@ async function renderNotificationImage(req) {
40081
40269
  height: outH,
40082
40270
  fit: "fill"
40083
40271
  });
40272
+ let labels;
40084
40273
  if (boxes.length > 0 || zones.length > 0 || paths.length > 0) {
40085
- const svg = notificationOverlaySvg({
40274
+ const overlay = notificationOverlay({
40086
40275
  width: outW,
40087
40276
  height: outH,
40088
40277
  boxes,
40089
40278
  zones,
40090
40279
  paths
40091
40280
  });
40281
+ if (boxes.length > 0) labels = overlay.labels;
40092
40282
  pipeline = pipeline.composite([{
40093
- input: Buffer.from(svg),
40283
+ input: Buffer.from(overlay.svg),
40094
40284
  top: 0,
40095
40285
  left: 0
40096
40286
  }]);
@@ -40100,7 +40290,8 @@ async function renderNotificationImage(req) {
40100
40290
  return {
40101
40291
  bytes: toArrayBufferBytes(out),
40102
40292
  name: `${rendered}.jpg`,
40103
- rendered
40293
+ rendered,
40294
+ ...labels !== void 0 ? { labels } : {}
40104
40295
  };
40105
40296
  }
40106
40297
  var NcNotificationImageStage = class {
@@ -40174,10 +40365,19 @@ var NcNotificationImageStage = class {
40174
40365
  ...subjectBox,
40175
40366
  label: imageLabelText(payload.subject.label ?? payload.subject.className, payload.subject.confidence)
40176
40367
  } : null;
40177
- const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox).map((t) => ({
40368
+ const others = mode === "scene-boxes" ? otherTracks(await this.tracksAt(camera, source.timestamp), subjectTrackId, subjectBox, payload.subject.className).map((t) => ({
40178
40369
  ...t.bbox,
40179
- label: imageLabelText(t.label ?? t.className, t.confidence)
40370
+ label: imageLabelText(t.label ?? t.className, t.confidence),
40371
+ ...t.confidence !== void 0 ? { confidence: t.confidence } : {}
40180
40372
  })) : [];
40373
+ if (mode !== "crop" && input.withSubject && payload.mediaShowTriggerZone === true && payload.matchedZoneIds === void 0) this.deps.logger.info("trigger zone not drawn — the row never froze which zone fired", {
40374
+ tags: { deviceId: camera },
40375
+ meta: {
40376
+ ruleId: entry.ruleId,
40377
+ recordId: entry.recordId,
40378
+ ruleZoneIds: (payload.triggerZoneIds ?? []).join(",")
40379
+ }
40380
+ });
40181
40381
  const zones = mode === "crop" || zoneIds.length === 0 ? [] : await this.zonePolygons(camera, zoneIds);
40182
40382
  try {
40183
40383
  const image = await this.render({
@@ -40190,6 +40390,7 @@ var NcNotificationImageStage = class {
40190
40390
  });
40191
40391
  const reason = image.rendered === mode ? void 0 : mode === "crop" ? "no-subject-box" : "no-tracks-at-instant";
40192
40392
  if (reason !== void 0) this.reportFallback(input, mode, reason);
40393
+ if (image.labels !== void 0) this.reportLabels(input, image.labels);
40193
40394
  return reason !== void 0 ? {
40194
40395
  ...image,
40195
40396
  reason
@@ -40238,6 +40439,19 @@ var NcNotificationImageStage = class {
40238
40439
  }
40239
40440
  });
40240
40441
  }
40442
+ /** ONE line per rendered picture: how many captions it carries and how many had no clear spot. */
40443
+ reportLabels(input, labels) {
40444
+ const camera = input.camera === "composite" ? input.entry.payload.subject.deviceId : input.camera;
40445
+ this.deps.logger.debug("notification picture captions laid out", {
40446
+ tags: { deviceId: camera },
40447
+ meta: {
40448
+ ruleId: input.entry.ruleId,
40449
+ recordId: input.entry.recordId,
40450
+ placed: labels.placed,
40451
+ dropped: labels.dropped
40452
+ }
40453
+ });
40454
+ }
40241
40455
  /** The subject's trail, read once per render for its box AND its path. */
40242
40456
  async subjectTrail(camera, trackId) {
40243
40457
  const read = this.deps.getSubjectTrail;
@@ -46931,6 +47145,27 @@ function isRecord(value) {
46931
47145
  return typeof value === "object" && value !== null && !Array.isArray(value);
46932
47146
  }
46933
47147
  /**
47148
+ * Incident 2026-10-22: a row stored with `groupingSec: 0` failed `NcRuleSchema` (min
47149
+ * {@link NC_GROUPING_MIN_SEC}) and the WHOLE rule was skipped as malformed on every load — a
47150
+ * system-event rule on which a window cannot even apply vanished from the engine and from
47151
+ * `listRules`. A non-positive (or non-numeric) window only ever meant "no window": dropped. A
47152
+ * positive one outside the range is clamped. One bad optional field never costs the rule.
47153
+ */
47154
+ function repairStoredGroupingSec(value) {
47155
+ if (value === void 0) return { kind: "unchanged" };
47156
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) return {
47157
+ kind: "repaired",
47158
+ from: value,
47159
+ to: null
47160
+ };
47161
+ const clamped = clampGroupingSec(value);
47162
+ return clamped === value ? { kind: "unchanged" } : {
47163
+ kind: "repaired",
47164
+ from: value,
47165
+ to: clamped
47166
+ };
47167
+ }
47168
+ /**
46934
47169
  * Project a RAW stored rule value (`data['rule']`, before any Zod parse). A value that is not
46935
47170
  * an object, or has no string `delivery`, is returned untouched: the schema parse that follows
46936
47171
  * rejects it exactly as before, and a projection must never be what decides a row is valid.
@@ -46938,9 +47173,12 @@ function isRecord(value) {
46938
47173
  function projectRawStoredRule(raw) {
46939
47174
  if (!isRecord(raw) || typeof raw["delivery"] !== "string") return {
46940
47175
  candidate: raw,
46941
- projection: { kind: "unchanged" }
47176
+ projection: { kind: "unchanged" },
47177
+ groupingRepair: { kind: "unchanged" }
46942
47178
  };
46943
- const groupingSec = raw["groupingSec"];
47179
+ const groupingRepair = repairStoredGroupingSec(raw["groupingSec"]);
47180
+ const repaired = withRepairedGrouping(raw, groupingRepair);
47181
+ const groupingSec = repaired["groupingSec"];
46944
47182
  const projection = projectRetiredRuleFields({
46945
47183
  delivery: raw["delivery"],
46946
47184
  conditions: isRecord(raw["conditions"]) ? raw["conditions"] : {},
@@ -46949,15 +47187,25 @@ function projectRawStoredRule(raw) {
46949
47187
  ...typeof groupingSec === "number" ? { groupingSec } : {}
46950
47188
  });
46951
47189
  if (projection.kind === "unchanged" || projection.groupingSec === void 0) return {
46952
- candidate: raw,
46953
- projection
47190
+ candidate: repaired,
47191
+ projection,
47192
+ groupingRepair
46954
47193
  };
46955
47194
  return {
46956
47195
  candidate: {
46957
- ...raw,
47196
+ ...repaired,
46958
47197
  groupingSec: projection.groupingSec
46959
47198
  },
46960
- projection
47199
+ projection,
47200
+ groupingRepair
47201
+ };
47202
+ }
47203
+ function withRepairedGrouping(raw, repair) {
47204
+ if (repair.kind === "unchanged") return raw;
47205
+ const { groupingSec: _dropped, ...rest } = raw;
47206
+ return repair.to === null ? rest : {
47207
+ ...rest,
47208
+ groupingSec: repair.to
46961
47209
  };
46962
47210
  }
46963
47211
  /**
@@ -47010,17 +47258,45 @@ var LOAD_LIMIT = 1e4;
47010
47258
  * Parse a raw body object (as returned from storage — may contain retired keys)
47011
47259
  * into an `NcRule` applying the legacy field projection.
47012
47260
  */
47013
- function bodyToRule(id, raw, onProjection) {
47014
- const { candidate, projection } = projectRawStoredRule(raw);
47261
+ function bodyToRule(id, raw, onProjection, onGroupingRepair) {
47262
+ const parsed = parseStoredRule(id, raw, onProjection, onGroupingRepair);
47263
+ return parsed.ok ? parsed.rule : null;
47264
+ }
47265
+ function parseStoredRule(id, raw, onProjection, onGroupingRepair, onClassWidening) {
47266
+ const { candidate, projection, groupingRepair } = projectRawStoredRule(raw);
47015
47267
  const parsed = require_clip_model_registry.NcRuleSchema.safeParse({
47016
47268
  ...candidate,
47017
47269
  id
47018
47270
  });
47019
- if (!parsed.success) return null;
47271
+ if (!parsed.success) {
47272
+ const issue = parsed.error.issues[0];
47273
+ return {
47274
+ ok: false,
47275
+ reason: issue === void 0 ? "invalid" : `${issue.path.join(".") || "(root)"}: ${issue.message}`
47276
+ };
47277
+ }
47020
47278
  if (projection.kind === "projected") onProjection?.(id, projection);
47021
- return withNormalizedMedia(parsed.data);
47279
+ if (groupingRepair.kind === "repaired") onGroupingRepair?.(id, groupingRepair);
47280
+ const media = withNormalizedMedia(parsed.data);
47281
+ const rule = require_clip_model_registry.withSystemEventClasses(media);
47282
+ if (rule !== media) onClassWidening?.(id, media, rule);
47283
+ return {
47284
+ ok: true,
47285
+ rule
47286
+ };
47022
47287
  }
47023
47288
  /**
47289
+ * D906: what a rule is stored as — its media policy folded (D897) and, on an
47290
+ * INSTANT system rule, every paired class it touches completed with both
47291
+ * edges. Applied on every WRITE; the load applies the same through
47292
+ * {@link parseStoredRule}, which also says once that it widened a rule.
47293
+ */
47294
+ function normalizeStoredRule(rule) {
47295
+ return require_clip_model_registry.withSystemEventClasses(withNormalizedMedia(rule));
47296
+ }
47297
+ /** At most this many skipped rows are NAMED in the WARN line; the count is always whole. */
47298
+ var SKIPPED_ROWS_NAMED_MAX = 10;
47299
+ /**
47024
47300
  * D897: the rule with its media policy in the stored shape — `image` set, the
47025
47301
  * legacy `attach` / `frame` / `zoneCrop` folded into it. Applied on LOAD (a read
47026
47302
  * projection: a rule nobody saved since keeps its row as written) and on every
@@ -47096,6 +47372,8 @@ var NcRuleStore = class NcRuleStore {
47096
47372
  now;
47097
47373
  newId;
47098
47374
  identityIdsByName;
47375
+ /** Rows the last successful {@link load} skipped; `null` before one succeeded. */
47376
+ skipped = null;
47099
47377
  /** Has a {@link load} ever completed? See the note there. */
47100
47378
  loaded = false;
47101
47379
  /** Legacy detection rules naming sounds already reported (D690) — once each. */
@@ -47107,6 +47385,10 @@ var NcRuleStore = class NcRuleStore {
47107
47385
  pendingProjections = /* @__PURE__ */ new Map();
47108
47386
  /** Rules whose projection was already logged in this process (D692 §9) — once each. */
47109
47387
  projectionLogged = /* @__PURE__ */ new Set();
47388
+ /** Rules whose out-of-range `groupingSec` repair was already logged — once each. */
47389
+ groupingRepairLogged = /* @__PURE__ */ new Set();
47390
+ /** Rules whose class widening (D906) was already logged — once each. */
47391
+ classWideningLogged = /* @__PURE__ */ new Set();
47110
47392
  constructor(deps) {
47111
47393
  this.logger = deps.logger;
47112
47394
  this.now = deps.now ?? (() => Date.now());
@@ -47141,20 +47423,29 @@ var NcRuleStore = class NcRuleStore {
47141
47423
  try {
47142
47424
  const { records } = await this.kind.query({ limit: LOAD_LIMIT });
47143
47425
  const next = /* @__PURE__ */ new Map();
47144
- let skipped = 0;
47426
+ const skippedRows = [];
47145
47427
  for (const row of records) {
47146
- const rule = bodyToRule(row.id, row.data, (ruleId, projection) => {
47428
+ const parsed = parseStoredRule(row.id, row.data, (ruleId, projection) => {
47147
47429
  this.pendingProjections.set(ruleId, projection);
47148
- });
47149
- if (rule === null) {
47150
- skipped += 1;
47430
+ }, (ruleId, repair) => this.logGroupingRepair(ruleId, repair), (ruleId, before, after) => this.logClassWidening(ruleId, before, after));
47431
+ if (!parsed.ok) {
47432
+ const name = row.data["name"];
47433
+ skippedRows.push({
47434
+ ruleId: row.id,
47435
+ name: typeof name === "string" ? name : null,
47436
+ reason: parsed.reason
47437
+ });
47151
47438
  continue;
47152
47439
  }
47153
- next.set(row.id, rule);
47440
+ next.set(row.id, parsed.rule);
47154
47441
  }
47155
47442
  this.byId.clear();
47156
47443
  for (const [id, rule] of next) this.byId.set(id, rule);
47157
- if (skipped > 0) this.logger.warn("notification rule rows skipped as malformed", { meta: { skipped } });
47444
+ this.skipped = skippedRows;
47445
+ if (skippedRows.length > 0) this.logger.warn("notification rule rows skipped as malformed", { meta: {
47446
+ skipped: skippedRows.length,
47447
+ rows: skippedRows.slice(0, SKIPPED_ROWS_NAMED_MAX)
47448
+ } });
47158
47449
  this.loaded = true;
47159
47450
  } catch (err) {
47160
47451
  this.logger.warn("notification rules load failed — keeping rules already in memory", { meta: {
@@ -47166,6 +47457,27 @@ var NcRuleStore = class NcRuleStore {
47166
47457
  for (const id of warnLegacyAudioClassRules([...this.byId.values()], this.audioClassWarned, this.logger)) this.audioClassWarned.add(id);
47167
47458
  this.logger.debug("notification rules loaded", { meta: { rules: this.byId.size } });
47168
47459
  }
47460
+ /** Say, once per process per rule, that an instant rule was read as its whole class (D906). */
47461
+ logClassWidening(ruleId, before, after) {
47462
+ if (this.classWideningLogged.has(ruleId)) return;
47463
+ this.classWideningLogged.add(ruleId);
47464
+ const had = new Set(before.conditions.systemEvent?.kinds ?? []);
47465
+ this.logger.info("instant system rule read as its whole event class — both edges", { meta: {
47466
+ ruleId,
47467
+ rule: after.name,
47468
+ added: (after.conditions.systemEvent?.kinds ?? []).filter((kind) => !had.has(kind))
47469
+ } });
47470
+ }
47471
+ /** Say, once per process per rule, that an out-of-range stored `groupingSec` was repaired. */
47472
+ logGroupingRepair(ruleId, repair) {
47473
+ if (this.groupingRepairLogged.has(ruleId)) return;
47474
+ this.groupingRepairLogged.add(ruleId);
47475
+ this.logger.warn("rule carries an out-of-range groupingSec — repaired on read", { meta: {
47476
+ ruleId,
47477
+ from: repair.from,
47478
+ to: repair.to
47479
+ } });
47480
+ }
47169
47481
  /** Log, once per process per rule, what `bodyToRule` projected from a retired field. */
47170
47482
  logPendingProjections() {
47171
47483
  for (const [ruleId, projection] of this.pendingProjections) {
@@ -47234,6 +47546,13 @@ var NcRuleStore = class NcRuleStore {
47234
47546
  * camera's ring) must ask this first: before the first load the empty list
47235
47547
  * is not a fact, and a failed load leaves whatever was already mirrored.
47236
47548
  */
47549
+ /**
47550
+ * The rows the last SUCCESSFUL load could not parse — `null` until one has
47551
+ * succeeded, so "not read yet" never reads as "nothing broken" (D315).
47552
+ */
47553
+ skippedRows() {
47554
+ return this.skipped;
47555
+ }
47237
47556
  isLoaded() {
47238
47557
  return this.loaded;
47239
47558
  }
@@ -47263,7 +47582,7 @@ var NcRuleStore = class NcRuleStore {
47263
47582
  /** Create a new rule. `createdBy` is the SERVER-injected caller userId. */
47264
47583
  async create(input, createdBy) {
47265
47584
  const now = this.now();
47266
- const rule = withNormalizedMedia({
47585
+ const rule = normalizeStoredRule({
47267
47586
  ...input,
47268
47587
  id: this.newId(),
47269
47588
  createdBy,
@@ -47308,7 +47627,7 @@ var NcRuleStore = class NcRuleStore {
47308
47627
  createdAt: existing.createdAt,
47309
47628
  updatedAt: this.now()
47310
47629
  };
47311
- const updated = withNormalizedMedia(require_clip_model_registry.NcRuleSchema.parse(candidate));
47630
+ const updated = normalizeStoredRule(require_clip_model_registry.NcRuleSchema.parse(candidate));
47312
47631
  await this.write(updated);
47313
47632
  return updated;
47314
47633
  }
@@ -47348,7 +47667,7 @@ var NcRuleStore = class NcRuleStore {
47348
47667
  id: ruleId
47349
47668
  } : candidate);
47350
47669
  if (!parsed.success) return existing;
47351
- const next = withNormalizedMedia(parsed.data);
47670
+ const next = normalizeStoredRule(parsed.data);
47352
47671
  const { id: _omit, ...data } = next;
47353
47672
  await this.kind.put({
47354
47673
  id: ruleId,
@@ -53377,6 +53696,104 @@ var NcZoneOwnershipCache = class {
53377
53696
  }
53378
53697
  };
53379
53698
  //#endregion
53699
+ //#region src/notification-center/rule-health.ts
53700
+ var EMPTY_SNAPSHOT = {
53701
+ timelapse: [],
53702
+ summary: [],
53703
+ alarm: null,
53704
+ mutedDeviceIds: []
53705
+ };
53706
+ function fingerprintOf(reasons) {
53707
+ return reasons.map((r) => `${r.code}:${[...r.ids].sort().join(",")}`).join("|");
53708
+ }
53709
+ /** The deleted subjects a rule still names, from the prune's `kept*` records. */
53710
+ function deletedReasons(rule, prunes) {
53711
+ const snapshot = {
53712
+ ...EMPTY_SNAPSHOT,
53713
+ rules: [rule]
53714
+ };
53715
+ const devices = referencedDeviceIds(snapshot);
53716
+ const targets = referencedTargetIds(snapshot);
53717
+ const deadDevices = /* @__PURE__ */ new Set();
53718
+ const deadTargets = /* @__PURE__ */ new Set();
53719
+ for (const p of prunes) {
53720
+ if (p.kind !== "rule" || p.ruleId !== rule.id) continue;
53721
+ if (p.outcome !== "kept" && p.outcome !== "kept-disabled") continue;
53722
+ if (p.subjectKind === "device" && devices.has(Number(p.subject))) deadDevices.add(p.subject);
53723
+ if (p.subjectKind === "target" && targets.has(p.subject)) deadTargets.add(p.subject);
53724
+ }
53725
+ const reasons = [];
53726
+ if (deadDevices.size > 0) reasons.push({
53727
+ code: "device-deleted",
53728
+ ids: [...deadDevices].sort()
53729
+ });
53730
+ if (deadTargets.size > 0) reasons.push({
53731
+ code: "target-deleted",
53732
+ ids: [...deadTargets].sort()
53733
+ });
53734
+ return reasons;
53735
+ }
53736
+ var NcRuleHealthMonitor = class {
53737
+ verdicts = /* @__PURE__ */ new Map();
53738
+ /** Judge every rule once; return the edges to announce. */
53739
+ evaluate(inputs) {
53740
+ const next = /* @__PURE__ */ new Map();
53741
+ const seen = /* @__PURE__ */ new Set();
53742
+ if (inputs.skipped !== null) for (const row of inputs.skipped) {
53743
+ seen.add(row.ruleId);
53744
+ const unparseable = {
53745
+ code: "unparseable",
53746
+ ids: [],
53747
+ detail: row.reason
53748
+ };
53749
+ next.set(row.ruleId, this.verdict(row.name, unparseable, []));
53750
+ }
53751
+ if (inputs.rules !== null) for (const rule of inputs.rules) {
53752
+ seen.add(rule.id);
53753
+ const previous = this.verdicts.get(rule.id);
53754
+ const deleted = inputs.prunes !== null ? deletedReasons(rule, inputs.prunes) : previous?.deleted ?? [];
53755
+ const unparseable = inputs.skipped !== null ? null : previous?.unparseable ?? null;
53756
+ next.set(rule.id, this.verdict(rule.name, unparseable, deleted));
53757
+ }
53758
+ const edges = [];
53759
+ const answeredAll = inputs.rules !== null && inputs.skipped !== null;
53760
+ for (const [ruleId, previous] of this.verdicts) {
53761
+ if (seen.has(ruleId)) continue;
53762
+ if (!answeredAll) next.set(ruleId, previous);
53763
+ }
53764
+ for (const [ruleId, verdict] of next) {
53765
+ const previous = this.verdicts.get(ruleId);
53766
+ const reasons = reasonsOf(verdict);
53767
+ if (reasons.length > 0) {
53768
+ if (previous?.fingerprint !== verdict.fingerprint) edges.push({
53769
+ kind: "broken",
53770
+ ruleId,
53771
+ ruleName: verdict.name,
53772
+ reasons
53773
+ });
53774
+ } else if (previous !== void 0 && reasonsOf(previous).length > 0) edges.push({
53775
+ kind: "repaired",
53776
+ ruleId,
53777
+ ruleName: verdict.name
53778
+ });
53779
+ }
53780
+ this.verdicts.clear();
53781
+ for (const [ruleId, verdict] of next) if (reasonsOf(verdict).length > 0) this.verdicts.set(ruleId, verdict);
53782
+ return edges;
53783
+ }
53784
+ verdict(name, unparseable, deleted) {
53785
+ return {
53786
+ name,
53787
+ unparseable,
53788
+ deleted,
53789
+ fingerprint: fingerprintOf([...unparseable !== null ? [unparseable] : [], ...deleted])
53790
+ };
53791
+ }
53792
+ };
53793
+ function reasonsOf(verdict) {
53794
+ return [...verdict.unparseable !== null ? [verdict.unparseable] : [], ...verdict.deleted];
53795
+ }
53796
+ //#endregion
53380
53797
  //#region src/notification-center/index.ts
53381
53798
  /**
53382
53799
  * NotificationCenter — the P1 core of the Notification Center (spec
@@ -53443,7 +53860,7 @@ var NC_ALARM_ANNOUNCE_RULE_ID = "nc-alarm-announce";
53443
53860
  * media (the SOURCE rule of a combined alarm row), and a zone overlay is
53444
53861
  * frozen only when the rule asked for one AND fired on a zone.
53445
53862
  */
53446
- function mediaPolicyFields(mediaRule, cropZoneIds) {
53863
+ function mediaPolicyFields(mediaRule, cropZoneIds, matchedZoneIds) {
53447
53864
  const media = effectiveMediaPolicyForRule(mediaRule);
53448
53865
  const image = media.image ?? "crop";
53449
53866
  return {
@@ -53452,6 +53869,7 @@ function mediaPolicyFields(mediaRule, cropZoneIds) {
53452
53869
  ...media.showTriggerZone === true && cropZoneIds !== void 0 && cropZoneIds.length > 0 ? { mediaShowTriggerZone: true } : {},
53453
53870
  ...media.showSubjectPath === true && image !== "none" ? { mediaShowSubjectPath: true } : {},
53454
53871
  ...cropZoneIds !== void 0 ? { triggerZoneIds: [...cropZoneIds] } : {},
53872
+ ...matchedZoneIds !== void 0 ? { matchedZoneIds: [...matchedZoneIds] } : {},
53455
53873
  ...media.profile !== void 0 ? { mediaProfile: media.profile } : {},
53456
53874
  ...media.gif === true ? { mediaGif: true } : {},
53457
53875
  ...media.clip === true ? { mediaClip: true } : {},
@@ -53957,6 +54375,21 @@ function groupOrderInput(fate, triggerZoneIds) {
53957
54375
  * condition's ids, else an occupancy rule's one zone. A grouped member freezes the same
53958
54376
  * ids a plain row of the rule would.
53959
54377
  */
54378
+ /**
54379
+ * The zones of `cropZoneIds` this subject actually triggered — what the zone
54380
+ * condition saw (membership plus the crossed zone, `zonesVisitedBy`), on the
54381
+ * rule whose media the row carries. On a combined alarm row the subject is the
54382
+ * alarm, so the source match's frozen zones answer. An occupancy rule's one
54383
+ * zone is the zone the count edge is ABOUT, so it is its own trigger.
54384
+ */
54385
+ function matchedTriggerZoneIds(mediaRule, cropZoneIds, subject) {
54386
+ if (cropZoneIds === void 0) return void 0;
54387
+ if (mediaRule.conditions.zones === void 0) return [...cropZoneIds];
54388
+ const alarmSource = subject.systemEvent?.alarmSource;
54389
+ const visited = new Set(alarmSource !== void 0 ? alarmSource.zones ?? [] : subject.zones);
54390
+ if (alarmSource === void 0 && subject.crossing !== void 0) visited.add(subject.crossing.zoneId);
54391
+ return cropZoneIds.filter((id) => visited.has(id));
54392
+ }
53960
54393
  function ruleCropZoneIds(rule) {
53961
54394
  if (rule.conditions.zones !== void 0) return rule.conditions.zones.ids;
53962
54395
  const zoneId = rule.conditions.occupancy?.zoneId;
@@ -54110,6 +54543,8 @@ var NotificationCenter = class {
54110
54543
  * evaluating one.
54111
54544
  */
54112
54545
  pruneLedger;
54546
+ /** D905: which rules cannot work, announced once per distinct reason. */
54547
+ ruleHealth = new NcRuleHealthMonitor();
54113
54548
  /** The prune itself — see `device-prune-runner.ts`. */
54114
54549
  pruner;
54115
54550
  /**
@@ -57539,7 +57974,7 @@ var NotificationCenter = class {
57539
57974
  const subject = incoming.subject;
57540
57975
  const alarmSource = subject.systemEvent?.alarmSource;
57541
57976
  const sourceRule = alarmSource !== void 0 ? this.rules.get(alarmSource.ruleId) : null;
57542
- const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule)) : { media: "none" };
57977
+ const media = sourceRule !== null ? mediaPolicyFields(sourceRule, ruleCropZoneIds(sourceRule), matchedTriggerZoneIds(sourceRule, ruleCropZoneIds(sourceRule), subject)) : { media: "none" };
57543
57978
  const mediaDeviceId = alarmSource?.deviceId ?? panel.deviceId;
57544
57979
  const entries = targets.map((targetId) => ({
57545
57980
  ruleId: NC_ALARM_ANNOUNCE_RULE_ID,
@@ -58745,6 +59180,7 @@ var NotificationCenter = class {
58745
59180
  const renderedZones = notificationZones(rule, subject);
58746
59181
  const confirm = frozenConfirmPolicy(rule, isSystemEvent);
58747
59182
  const cropZoneIds = ruleCropZoneIds(mediaRule);
59183
+ const matchedZoneIds = matchedTriggerZoneIds(mediaRule, cropZoneIds, subject);
58748
59184
  const allTargets = [...rule.targets, ...userTargets.filter((t) => !direct.has(t.targetId))];
58749
59185
  const linkedOwners = !noMedia && subject.linkedStills !== void 0 ? linkedMediaOwners(subject.linkedStills, linkedCameraMode(mediaRule.media)) : void 0;
58750
59186
  const groupV2 = extras?.groupV2 ?? (subject.systemEvent !== void 0 && !noMedia ? alarmIncidentGroup(subject.systemEvent, subject.timestamp) : void 0);
@@ -58755,7 +59191,7 @@ var NotificationCenter = class {
58755
59191
  ...origin === "synthetic" ? { synthetic: true } : {},
58756
59192
  priority: rule.priority,
58757
59193
  ...rule.template !== void 0 ? { template: rule.template } : {},
58758
- ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds),
59194
+ ...noMedia ? { media: "none" } : mediaPolicyFields(mediaRule, cropZoneIds, matchedZoneIds),
58759
59195
  ...confirm !== void 0 ? {
58760
59196
  confirm,
58761
59197
  ...cooldown !== void 0 ? { cooldown } : {},
@@ -59400,6 +59836,51 @@ var NotificationCenter = class {
59400
59836
  if (!this.pruneLedger.isLoaded()) await this.pruneLedger.load();
59401
59837
  await this.pruner.reconcileDevices();
59402
59838
  await this.pruner.reconcileTargets();
59839
+ this.announceRuleHealth();
59840
+ }
59841
+ /**
59842
+ * D905 — say which rules cannot work: a row the load skipped, or a rule still
59843
+ * naming a device / target the prune confirmed deleted. One edge per rule per
59844
+ * distinct set of reasons; a reading that did not answer changes nothing.
59845
+ */
59846
+ announceRuleHealth() {
59847
+ const edges = this.ruleHealth.evaluate({
59848
+ rules: this.rules.isLoaded() ? this.rules.list() : null,
59849
+ skipped: this.rules.skippedRows(),
59850
+ prunes: this.pruneLedger.isLoaded() ? this.pruner.listPrunes() : null
59851
+ });
59852
+ for (const edge of edges) {
59853
+ const source = {
59854
+ type: "addon",
59855
+ id: "notification-center"
59856
+ };
59857
+ if (edge.kind === "broken") {
59858
+ const [first] = edge.reasons;
59859
+ if (first === void 0) continue;
59860
+ const offendingIds = [...new Set(edge.reasons.flatMap((r) => r.ids))];
59861
+ this.logger.warn("notification rule cannot work — announced", { meta: {
59862
+ ruleId: edge.ruleId,
59863
+ rule: edge.ruleName,
59864
+ reasons: edge.reasons
59865
+ } });
59866
+ this.deps.emitBusEvent?.(require_clip_model_registry.createEvent(require_clip_model_registry.EventCategory.NotificationRuleBroken, source, {
59867
+ ruleId: edge.ruleId,
59868
+ ruleName: edge.ruleName,
59869
+ reason: first.code,
59870
+ reasons: edge.reasons,
59871
+ offendingIds
59872
+ }));
59873
+ } else {
59874
+ this.logger.info("notification rule works again — announced", { meta: {
59875
+ ruleId: edge.ruleId,
59876
+ rule: edge.ruleName
59877
+ } });
59878
+ this.deps.emitBusEvent?.(require_clip_model_registry.createEvent(require_clip_model_registry.EventCategory.NotificationRuleRepaired, source, {
59879
+ ruleId: edge.ruleId,
59880
+ ruleName: edge.ruleName
59881
+ }));
59882
+ }
59883
+ }
59403
59884
  }
59404
59885
  /**
59405
59886
  * `DeviceUnregistered` — a device was DELETED (only `removeDevice` emits it,
@@ -103523,6 +104004,8 @@ var NC_SYSTEM_EVENT_CATEGORIES = [
103523
104004
  require_clip_model_registry.EventCategory.StorageLocationLow,
103524
104005
  require_clip_model_registry.EventCategory.StorageLocationCritical,
103525
104006
  require_clip_model_registry.EventCategory.StorageLocationOk,
104007
+ require_clip_model_registry.EventCategory.NotificationRuleBroken,
104008
+ require_clip_model_registry.EventCategory.NotificationRuleRepaired,
103526
104009
  require_clip_model_registry.EventCategory.AddonRunnerFailed,
103527
104010
  require_clip_model_registry.EventCategory.AddonStarted,
103528
104011
  require_clip_model_registry.EventCategory.AddonRestarted,
@@ -115553,6 +116036,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_clip_m
115553
116036
  nodeAddons: (nodeId) => api.nodes.getNodeAddons.query({ nodeId })
115554
116037
  },
115555
116038
  readAlerts: () => api.alerts.list.query({ limit: 5e3 }),
116039
+ emitBusEvent: (event) => this.ctx.eventBus.emit(event),
115556
116040
  getSnapshot: (deviceId) => api.snapshot.getSnapshot.query({ deviceId }),
115557
116041
  recognitionOutlook: ({ deviceId, trackId, className, labelKind, now }) => recognitionOutlook({
115558
116042
  className,