@camstack/addon-post-analysis 1.2.274 → 1.2.275

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,4 +1,4 @@
1
- import { $ as TimelapseRuleSchema, $t as discriminatedUnion, A as NcRuleInputSchema, At as readTimelapseGeneratedAt, B as OpsLogEntrySchema, Bt as BaseAddon, C as MediaFileKindEnum, Ct as notificationRulesCapability, Dt as pipelineAnalyticsCapability, E as NC_DEFAULT_SNOOZE_MINUTES, Et as pickClusterStepModels, F as NcSnoozeInputSchema, Ft as subKindsOf, Gt as defineCustomActions, H as RECORDING_EXPORT_MAX_READ_BYTES, Ht as DeviceType, I as NcSnoozeSchema, It as systemEventFilterApplies, J as SCENE_DIVERGED, Jt as nodePin, K as SCENE_DEFAULT_ANCHOR_THRESHOLD, Kt as hydrateSchema, L as NcSnoozeSuppressedSchema, Lt as vectorDimFromBase64, M as NcRuleSchema, N as NcRuleTargetSchema, Nt as sceneMonitorCapability, O as NC_TAXONOMY, Ot as plateGalleryCapability, P as NcScheduleSchema, Pt as storageOccupancyCapability, Q as TimelapseRulePatchSchema, Qt as boolean, R as NcSystemEventKindSchema, Rt as zoneAnalyticsCapability, S as MOTION_CLOSE_AFTER_MS, St as mayWriteToLocation, T as NC_CONDITION_CATALOG, U as RECORDING_SOURCE_CAMSTACK_ADDON, Ut as createEvent, Vt as CamProfileSchema, W as RetrainStatusSchema, Wt as customAction, X as TIMELAPSE_DENSE_FLOOR_SEC, Xt as _enum, Y as SceneMonitorSchema, Yt as sleep$1, Z as TimelapseRuleInputSchema, Zt as array, _ as FailureCounters, _t as isEventOwnerType, a as CLUSTER_MODEL_SCOPED_STEPS, an as string, at as audioModeOf, b as MAX_BIRTH_DECISION_RECORDS, bt as isSourceCap, ct as deriveRecordingMode, dt as evaluateSensorEdge, en as literal, et as TrackSourceSchema, f as DeclaredDevices, ft as evictionPolicyOfLocation, g as FULL_IMAGE_BBOX, gt as isDetectionMacroClass, h as FIRST_LEVEL_MACRO_CLASSES, i as BirthDecisionRecordSchema, in as record, it as audioMetricsCapability, j as NcRulePatchSchema, jt as resolveLocationMode, k as NcConditionDescriptorSchema, kt as readDeviceStateFrom, m as EVENT_OWNER_TYPES, mt as failureContributionCapability, n as ArchivedDebugNoteSchema, nn as object, nt as alarmPanelCapability, o as COCO_TO_MACRO, on as unknown, ot as buildEventKindDescriptor, p as EVENT_KIND_BY_CAP, pt as faceGalleryCapability, q as SCENE_DEFAULT_UNCOVERED_POLICY, qt as isDeviceScopedCap, r as BaseDevice, rn as partialRecord, rt as assertTimelapseCadences, s as DEFAULT_EVENT_COLOR, sn as EventCategory, st as cosineSimilarity$1, t as AUDIO_MACRO_LABELS, tn as number, tt as addonWidgetsSourceCapability, u as DETECTION_MACRO_CLASSES, ut as encodeVectorBase64, v as LabelAttributionSchema, vt as isMediaOwnerType, w as NC_ALARM_SYSTEM_EVENT_KINDS, wt as occupancyScope, xt as kebabToCamel, y as MAX_ARCHIVED_DEBUG_NOTES, yt as isScheduleActive, z as NcTaxonomySchema, zt as errMsg$1 } from "../dist-DDko6srJ.mjs";
1
+ import { $ as SCENE_DIVERGED, $t as hydrateSchema, A as NcConditionDescriptorSchema, At as occupancyScope, B as NcSystemEventKindSchema, Bt as storageOccupancyCapability, C as MediaFileKindEnum, Ct as isKnownTemplateVar, Dt as kebabToCamel, E as NC_DEFAULT_SNOOZE_MINUTES, Et as isSourceCap, F as NcRuleUpdateInputSchema, Ft as readDeviceStateFrom, G as OpsLogEntrySchema, Gt as zoneAnalyticsCapability, H as NcTemplatePreviewInputSchema, Ht as systemEventFilterApplies, I as NcScheduleSchema, It as readTimelapseGeneratedAt, J as RECORDING_SOURCE_CAMSTACK_ADDON, Jt as CamProfileSchema, Kt as errMsg$1, L as NcSnoozeInputSchema, Lt as resolveLocationMode, M as NcRulePatchSchema, Mt as pickClusterStepModels, N as NcRuleSchema, Nt as pipelineAnalyticsCapability, O as NC_TAXONOMY, Ot as mayWriteToLocation, P as NcRuleTargetSchema, Pt as plateGalleryCapability, Q as SCENE_DEFAULT_UNCOVERED_POLICY, Qt as defineCustomActions, R as NcSnoozeSchema, S as MOTION_CLOSE_AFTER_MS, St as isEventOwnerType, T as NC_CONDITION_CATALOG, Tt as isScheduleActive, U as NcTemplatePreviewSchema, Ut as templateVarsFor, V as NcTaxonomySchema, Vt as subKindsOf, W as NcTemplateVarDescriptorSchema, Wt as vectorDimFromBase64, Xt as createEvent, Y as RetrainStatusSchema, Yt as DeviceType, Z as SCENE_DEFAULT_ANCHOR_THRESHOLD, Zt as customAction, _ as FailureCounters, _t as evictionPolicyOfLocation, a as CLUSTER_MODEL_SCOPED_STEPS, an as boolean, at as TrackSourceSchema, b as MAX_BIRTH_DECISION_RECORDS, cn as number, ct as assertTimelapseCadences, dn as record, dt as buildEventKindDescriptor, en as isDeviceScopedCap, et as SceneMonitorSchema, f as DeclaredDevices, fn as string, ft as cosineSimilarity$1, g as FULL_IMAGE_BBOX, gt as evaluateSensorEdge, h as FIRST_LEVEL_MACRO_CLASSES, ht as encodeVectorBase64, i as BirthDecisionRecordSchema, in as array, it as TimelapseRuleSchema, j as NcRuleInputSchema, k as NC_TEMPLATE_VARS, kt as notificationRulesCapability, ln as object, lt as audioMetricsCapability, m as EVENT_OWNER_TYPES, mn as EventCategory, n as ArchivedDebugNoteSchema, nn as sleep$1, nt as TimelapseRuleInputSchema, o as COCO_TO_MACRO, on as discriminatedUnion, ot as addonWidgetsSourceCapability, p as EVENT_KIND_BY_CAP, pn as unknown, pt as deriveRecordingMode, q as RECORDING_EXPORT_MAX_READ_BYTES, qt as BaseAddon, r as BaseDevice, rn as _enum, rt as TimelapseRulePatchSchema, s as DEFAULT_EVENT_COLOR, sn as literal, st as alarmPanelCapability, t as AUDIO_MACRO_LABELS, tn as nodePin, tt as TIMELAPSE_DENSE_FLOOR_SEC, u as DETECTION_MACRO_CLASSES, un as partialRecord, ut as audioModeOf, v as LabelAttributionSchema, vt as faceGalleryCapability, w as NC_ALARM_SYSTEM_EVENT_KINDS, wt as isMediaOwnerType, xt as isDetectionMacroClass, y as MAX_ARCHIVED_DEBUG_NOTES, yt as failureContributionCapability, z as NcSnoozeSuppressedSchema, zt as sceneMonitorCapability } from "../dist-D3Yub96m.mjs";
2
2
  import { t as __exportAll } from "../embedding-encoder/index.mjs";
3
3
  import * as fs from "node:fs";
4
4
  import { promises } from "node:fs";
@@ -1454,7 +1454,8 @@ var NcAlarmPanelDevice = class extends BaseDevice {
1454
1454
  return {
1455
1455
  ...this.readDelays(),
1456
1456
  announceArm: this.config.get("announceArm"),
1457
- announceTargets: [...this.config.get("announceTargets")]
1457
+ announceTargets: [...this.config.get("announceTargets")],
1458
+ nonBlocking: []
1458
1459
  };
1459
1460
  }
1460
1461
  /**
@@ -10815,6 +10816,23 @@ function requireCapCaller(method, caller) {
10815
10816
  return caller;
10816
10817
  }
10817
10818
  //#endregion
10819
+ //#region src/notification-center/clock-text.ts
10820
+ /**
10821
+ * `{{time}}` — hours and minutes in the NOTIFICATION language.
10822
+ *
10823
+ * It used to be `toLocaleTimeString()` in the server's own locale, so an
10824
+ * Italian hub running an English container sent "8:05:13 PM" inside an
10825
+ * Italian sentence. The time zone is the host's unless a caller (a spec)
10826
+ * pins one.
10827
+ */
10828
+ function ncClockText(language, atMs, timeZone) {
10829
+ return new Intl.DateTimeFormat(language, {
10830
+ hour: "2-digit",
10831
+ minute: "2-digit",
10832
+ ...timeZone !== void 0 ? { timeZone } : {}
10833
+ }).format(new Date(atMs));
10834
+ }
10835
+ //#endregion
10818
10836
  //#region src/notification-center/system-event-tap-url.ts
10819
10837
  /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
10820
10838
  var ADMIN_ADDONS_PATH = "/system/addons";
@@ -12783,8 +12801,7 @@ var NcDispatcher = class {
12783
12801
  async buildNotification(entry, target) {
12784
12802
  const subject = entry.payload.subject;
12785
12803
  const systemEvent = subject.systemEvent;
12786
- const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
12787
- const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
12804
+ const deviceName = await notificationDeviceName(entry, this.deps.getDeviceName);
12788
12805
  const alarmSource = systemEvent?.alarmSource;
12789
12806
  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) : [];
12790
12807
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
@@ -13538,17 +13555,25 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
13538
13555
  function percentOf(confidence) {
13539
13556
  return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
13540
13557
  }
13558
+ /** What `{{camera}}` renders on a system row that names no device. */
13559
+ var NC_DEVICELESS_CAMERA_NAME = "system";
13541
13560
  /**
13542
- * The variable map ONE render pass sees: scalars, then the composites built
13543
- * from them, then the params a system-event row froze at intake.
13561
+ * The name `{{camera}}` renders for a row — the ONE derivation, shared by the
13562
+ * dispatcher and the template-variable parity spec.
13544
13563
  *
13545
- * The three groups are layered in that order deliberately. A composite
13546
- * (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
13547
- * visible to the fragments it was built from — that is the depth bound, and it
13548
- * is enforced by `text-compose.ts` never being handed this map. The frozen
13549
- * params go last because they are the row's own truth about a system event and
13550
- * outrank the live vocabulary for exactly those names.
13564
+ * A system row that names no device (a node, a backup, an update) renders the
13565
+ * filler {@link NC_DEVICELESS_CAMERA_NAME}: there is no camera to name, which
13566
+ * is why the catalog offers `{{camera}}` on a system rule only for the kinds
13567
+ * that carry a device (template-vars.ts). Everything else asks the directory,
13568
+ * and falls back to `camera <id>` when it cannot answer.
13551
13569
  */
13570
+ async function notificationDeviceName(entry, getDeviceName) {
13571
+ const subject = entry.payload.subject;
13572
+ const systemEvent = subject.systemEvent;
13573
+ if (systemEvent !== void 0 && systemEvent.deviceId === void 0) return NC_DEVICELESS_CAMERA_NAME;
13574
+ const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
13575
+ return await getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
13576
+ }
13552
13577
  /** Class composition of a group, busiest classes in first-seen order. */
13553
13578
  function classCounts(members) {
13554
13579
  const counts = /* @__PURE__ */ new Map();
@@ -13558,7 +13583,18 @@ function classCounts(members) {
13558
13583
  count
13559
13584
  }));
13560
13585
  }
13561
- function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13586
+ /**
13587
+ * The variable map ONE render pass sees: scalars, then the composites built
13588
+ * from them, then the params a system-event row froze at intake.
13589
+ *
13590
+ * The three groups are layered in that order deliberately. A composite
13591
+ * (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
13592
+ * visible to the fragments it was built from — that is the depth bound, and it
13593
+ * is enforced by `text-compose.ts` never being handed this map. The frozen
13594
+ * params go last because they are the row's own truth about a system event and
13595
+ * outrank the live vocabulary for exactly those names.
13596
+ */
13597
+ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
13562
13598
  const subject = entry.payload.subject;
13563
13599
  const occupancy = subject.occupancy;
13564
13600
  const systemEvent = subject.systemEvent;
@@ -13566,7 +13602,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13566
13602
  const group = entry.payload.group;
13567
13603
  return {
13568
13604
  camera: deviceName,
13569
- class: detection?.className ?? subject.className,
13605
+ class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
13570
13606
  label: detection?.label ?? subject.label ?? "",
13571
13607
  zones: zoneLabels.join(", "),
13572
13608
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -13579,10 +13615,14 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13579
13615
  }),
13580
13616
  detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
13581
13617
  inZones: ncInZonesText(texts, zoneLabels),
13582
- scope: occupancy?.zone ?? deviceName,
13618
+ scope: occupancy !== void 0 ? occupancy.zone ?? deviceName : "",
13583
13619
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
13584
13620
  confidence: percentOf(detection?.confidence ?? subject.confidence),
13585
- time: new Date(subject.timestamp).toLocaleTimeString(),
13621
+ time: ncClockText(texts.language, subject.timestamp, timeZone),
13622
+ sounds: subject.audio?.labels.join(", ") ?? "",
13623
+ hitPercent: subject.audio?.hitPercent !== void 0 ? `${Math.round(subject.audio.hitPercent)}` : "",
13624
+ db: subject.audio?.peakDbfs !== void 0 ? `${Math.round(subject.audio.peakDbfs)}` : "",
13625
+ eventType: subject.eventType ?? "",
13586
13626
  rule: entry.payload.ruleName,
13587
13627
  count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
13588
13628
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
@@ -17413,12 +17453,22 @@ var NcRuleStore = class {
17413
17453
  await this.ledger.put(rule);
17414
17454
  return rule;
17415
17455
  }
17416
- /** Apply a partial patch. Immutable: returns the NEW rule object. */
17417
- async update(ruleId, patch) {
17456
+ /**
17457
+ * Apply a partial patch. Immutable: returns the NEW rule object.
17458
+ *
17459
+ * `clear` names optional fields to REMOVE before the merge — an omitted key
17460
+ * keeps its stored value, so this is how an editor empties one. Setting and
17461
+ * clearing the same key is a contradiction and is refused rather than
17462
+ * resolved by ordering.
17463
+ */
17464
+ async update(ruleId, patch, clear = []) {
17418
17465
  const existing = this.ledger.get(ruleId);
17419
17466
  if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
17467
+ const conflict = clear.find((key) => patch[key] !== void 0);
17468
+ if (conflict !== void 0) throw new Error(`notification rule patch both sets and clears '${conflict}'`);
17469
+ const cleared = new Set(clear);
17420
17470
  const candidate = {
17421
- ...existing,
17471
+ ...Object.fromEntries(Object.entries(existing).filter(([key]) => !cleared.has(key))),
17422
17472
  ...patch,
17423
17473
  id: existing.id,
17424
17474
  createdBy: existing.createdBy,
@@ -19326,13 +19376,18 @@ function clockOf$2(atMs) {
19326
19376
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
19327
19377
  }
19328
19378
  /** Template vocabulary. Deliberately the SAME names the detection and timelapse
19329
- * bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones. */
19330
- function templateVars$1(input) {
19379
+ * bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
19380
+ *
19381
+ * Exported as `summaryTemplateVars` — not just an implementation detail of
19382
+ * {@link buildSummaryOutboxInputs} — so the parity spec
19383
+ * (`template-var-parity.spec.ts`) can assert this map directly against the
19384
+ * catalog, the same way `buildTemplateVars` is asserted for rule rows. */
19385
+ function summaryTemplateVars(input) {
19331
19386
  return {
19332
19387
  rule: input.ruleName,
19333
19388
  from: clockOf$2(input.window.startMs),
19334
19389
  to: clockOf$2(input.window.endMs),
19335
- time: clockOf$2(input.generatedAt),
19390
+ time: ncClockText(input.texts.language, input.generatedAt),
19336
19391
  events: String(input.tileCount),
19337
19392
  cameras: String(input.cameraCount),
19338
19393
  matched: String(input.matchedCount),
@@ -19439,7 +19494,7 @@ function summaryCaptionText(input) {
19439
19494
  * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
19440
19495
  */
19441
19496
  function buildSummaryOutboxInputs(input) {
19442
- const vars = templateVars$1(input);
19497
+ const vars = summaryTemplateVars(input);
19443
19498
  const optedOut = new Set(input.disabledTargetIds ?? []);
19444
19499
  const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
19445
19500
  key: "summary.title",
@@ -21663,6 +21718,67 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
21663
21718
  return files;
21664
21719
  }
21665
21720
  //#endregion
21721
+ //#region src/notification-center/template-preview.ts
21722
+ /**
21723
+ * `previewTemplate` — render a rule's draft title/body against EXAMPLE values,
21724
+ * so an operator sees `{{scope}}: {{count}}/{{capacity}}` become
21725
+ * `Cancello: 3/2` before saving the rule, not after the first real event.
21726
+ *
21727
+ * Pure with respect to the caller: it never sends anything, and the only
21728
+ * side-channel it reads is the live `NcTextCatalog` handed in, so a preview
21729
+ * always speaks the hub's CURRENT language and CURRENT overrides — the same
21730
+ * words a delivered notification would use.
21731
+ */
21732
+ /**
21733
+ * The instant `{{time}}` is previewed at — fixed, and in UTC, so the example
21734
+ * is the same on every host: "20:05" in Italian, "08:05 PM" in English.
21735
+ */
21736
+ var PREVIEW_INSTANT_MS = Date.UTC(2026, 8, 24, 20, 5);
21737
+ var PREVIEW_TIME_ZONE = "UTC";
21738
+ /**
21739
+ * The example map: every declared example, with the COMPOSITES rendered by the
21740
+ * real composers so the preview speaks the hub's language exactly as a
21741
+ * delivered notification would.
21742
+ */
21743
+ function exampleVars(texts) {
21744
+ return {
21745
+ ...Object.fromEntries(NC_TEMPLATE_VARS.filter((d) => d.dynamic === void 0).map((d) => [d.name, d.example])),
21746
+ subject: ncSubjectText(texts, {
21747
+ className: "person",
21748
+ label: "Mario",
21749
+ labelKind: "identity"
21750
+ }),
21751
+ inZones: ncInZonesText(texts, ["Cancello"]),
21752
+ op: ncOccupancyOp(texts, true),
21753
+ time: ncClockText(texts.language, PREVIEW_INSTANT_MS, PREVIEW_TIME_ZONE),
21754
+ detectionSummary: ncDetectionSummary(texts, [{
21755
+ className: "person",
21756
+ count: 2
21757
+ }, {
21758
+ className: "car",
21759
+ count: 1
21760
+ }])
21761
+ };
21762
+ }
21763
+ var DYNAMIC_EXAMPLE = "3";
21764
+ function previewTemplate(texts, input) {
21765
+ const ctx = input.context;
21766
+ const allowed = new Set(templateVarsFor(ctx).map((d) => d.name));
21767
+ const examples = exampleVars(texts);
21768
+ const used = [input.template.title ?? "", input.template.body ?? ""].flatMap((t) => [...templatePlaceholders(t)]);
21769
+ const unknown = [...new Set(used.filter((name) => !isKnownTemplateVar(name, ctx)))];
21770
+ const vars = {};
21771
+ for (const name of used) {
21772
+ if (!isKnownTemplateVar(name, ctx)) continue;
21773
+ vars[name] = allowed.has(name) ? examples[name] ?? "" : DYNAMIC_EXAMPLE;
21774
+ }
21775
+ return {
21776
+ title: renderTemplate(input.template.title, vars),
21777
+ body: renderTemplate(input.template.body, vars),
21778
+ unknown
21779
+ };
21780
+ }
21781
+ //#endregion
21666
21782
  //#region src/notification-center/text-catalog-view.ts
21667
21783
  /**
21668
21784
  * The text catalog, projected for an EDITOR — Phase 2's read model, pure.
@@ -22102,14 +22218,19 @@ function clockOf(atMs) {
22102
22218
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
22103
22219
  }
22104
22220
  /** Template vocabulary. Deliberately the same NAMES the rule templates use
22105
- * (`camera`, `rule`, `time`) plus the timelapse-only ones. */
22106
- function templateVars(input) {
22221
+ * (`camera`, `rule`, `time`) plus the timelapse-only ones.
22222
+ *
22223
+ * Exported as `timelapseTemplateVars` — not just an implementation detail of
22224
+ * {@link buildTimelapseOutboxInputs} — so the parity spec
22225
+ * (`template-var-parity.spec.ts`) can assert this map directly against the
22226
+ * catalog, the same way `buildTemplateVars` is asserted for rule rows. */
22227
+ function timelapseTemplateVars(input) {
22107
22228
  return {
22108
22229
  camera: input.deviceName ?? `camera ${input.deviceId}`,
22109
22230
  rule: input.ruleName,
22110
22231
  from: clockOf(input.window.startMs),
22111
22232
  to: clockOf(input.window.endMs),
22112
- time: clockOf(input.generatedAt),
22233
+ time: ncClockText(input.texts.language, input.generatedAt),
22113
22234
  coverage: String(input.coverage.percent),
22114
22235
  events: String(input.denseRanges),
22115
22236
  ...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
@@ -22190,7 +22311,7 @@ function timelapseRecordId(deviceId, windowEndMs) {
22190
22311
  * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
22191
22312
  */
22192
22313
  function buildTimelapseOutboxInputs(input) {
22193
- const vars = templateVars(input);
22314
+ const vars = timelapseTemplateVars(input);
22194
22315
  const optedOut = new Set(input.disabledTargetIds ?? []);
22195
22316
  const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
22196
22317
  key: "timelapse.title",
@@ -23173,7 +23294,8 @@ function consumablesSliceOf(event) {
23173
23294
  var NC_ALARM_SETTINGS_FALLBACK = {
23174
23295
  ...DEFAULT_DELAYS,
23175
23296
  announceArm: false,
23176
- announceTargets: []
23297
+ announceTargets: [],
23298
+ nonBlocking: []
23177
23299
  };
23178
23300
  /**
23179
23301
  * How often the "matched NO rule" report may fire per device. Long enough that
@@ -24423,6 +24545,10 @@ var NotificationCenter = class NotificationCenter {
24423
24545
  get textCatalogEditor() {
24424
24546
  return this.textEditor;
24425
24547
  }
24548
+ /** The live catalog every producer renders with — the preview must use the same one. */
24549
+ get textCatalog() {
24550
+ return this.texts;
24551
+ }
24426
24552
  /**
24427
24553
  * Produce the last closed window for one camera of one timelapse rule, now.
24428
24554
  *
@@ -25285,17 +25411,19 @@ var NotificationCenter = class NotificationCenter {
25285
25411
  } });
25286
25412
  return { rule: created };
25287
25413
  },
25288
- updateRule: async ({ ruleId, patch, caller }) => {
25414
+ updateRule: async ({ ruleId, patch, clear, caller }) => {
25289
25415
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
25290
- if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
25416
+ const clearsUsers = clear?.includes("targetUsers") ?? false;
25417
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0 || clearsUsers) {
25291
25418
  const existing = this.rules.get(ruleId);
25292
- NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
25419
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], clearsUsers ? void 0 : patch.targetUsers ?? existing?.targetUsers);
25293
25420
  }
25294
25421
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
25295
- const updated = await this.rules.update(ruleId, safePatch);
25422
+ const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
25296
25423
  this.logger.info("notification rule updated", { meta: {
25297
25424
  ruleId,
25298
- by: caller.userId
25425
+ by: caller.userId,
25426
+ ...clear && clear.length > 0 ? { cleared: clear } : {}
25299
25427
  } });
25300
25428
  return { rule: updated };
25301
25429
  },
@@ -25312,6 +25440,8 @@ var NotificationCenter = class NotificationCenter {
25312
25440
  catalog: [...NC_CONDITION_CATALOG],
25313
25441
  taxonomy: NC_TAXONOMY
25314
25442
  }),
25443
+ getTemplateCatalog: async () => ({ vars: [...NC_TEMPLATE_VARS] }),
25444
+ previewTemplate: async (input) => previewTemplate(this.texts, input),
25315
25445
  getHistory: async ({ filter }) => {
25316
25446
  const entries = await this.outbox.queryHistory({
25317
25447
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -26499,6 +26629,12 @@ var NotificationCenter = class NotificationCenter {
26499
26629
  ...subject.observedAt !== void 0 ? { observedAt: subject.observedAt } : {},
26500
26630
  ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
26501
26631
  ...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
26632
+ ...subject.audioWindow !== void 0 ? { audio: {
26633
+ labels: subject.audioWindow.labels,
26634
+ ...subject.audioWindow.mode === "level" ? { hitPercent: subject.audioWindow.hitPercent } : {},
26635
+ ...subject.audioWindow.peakDbfs !== void 0 ? { peakDbfs: subject.audioWindow.peakDbfs } : {}
26636
+ } } : {},
26637
+ ...subject.eventType !== void 0 ? { eventType: subject.eventType } : {},
26502
26638
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
26503
26639
  }
26504
26640
  };
@@ -27687,14 +27823,13 @@ var ncActions = defineCustomActions({
27687
27823
  catalog: array(NcConditionDescriptorSchema),
27688
27824
  taxonomy: NcTaxonomySchema
27689
27825
  })),
27826
+ "nc.getTemplateCatalog": customAction(object({}), object({ vars: array(NcTemplateVarDescriptorSchema) })),
27827
+ "nc.previewTemplate": customAction(NcTemplatePreviewInputSchema, NcTemplatePreviewSchema, { kind: "mutation" }),
27690
27828
  "nc.createRule": customAction(object({ rule: NcRuleInputSchema }), object({ rule: NcRuleSchema }), {
27691
27829
  kind: "mutation",
27692
27830
  caller: "required"
27693
27831
  }),
27694
- "nc.updateRule": customAction(object({
27695
- ruleId: string(),
27696
- patch: NcRulePatchSchema
27697
- }), object({ rule: NcRuleSchema }), {
27832
+ "nc.updateRule": customAction(NcRuleUpdateInputSchema, object({ rule: NcRuleSchema }), {
27698
27833
  kind: "mutation",
27699
27834
  caller: "required"
27700
27835
  }),
@@ -27713,14 +27848,17 @@ var ncActions = defineCustomActions({
27713
27848
  /**
27714
27849
  * THE AI SECTION of the ordinary rules — read and write.
27715
27850
  *
27716
- * A pair of its own rather than two more keys on `nc.createRule` /
27717
- * `nc.updateRule`, and that is the whole point of the design: those two
27718
- * validate against `NcRuleInputSchema` from `@camstack/types`, which the
27719
- * addon resolves from the `@camstack/server` closure at runtime — so a new
27720
- * key on them is SILENTLY STRIPPED at save until a framework train lands and
27721
- * is installed. These two validate against a schema that lives in the addon
27722
- * (`rule-ai.ts`), so a field added to it is live on the next
27723
- * `camstack deploy packages/addon-post-analysis`.
27851
+ * A pair of its own rather than two more keys on the rule CRUD, and that is
27852
+ * the whole point of the design. The addon builds self-contained and INLINES
27853
+ * `@camstack/types`, so its own copy of `NcRuleInputSchema` is never the
27854
+ * problem and nothing here fails at load. The coupling is the HUB: the cap
27855
+ * route (`notificationRules.createRule` / `updateRule`, which the admin
27856
+ * editor uses) validates input against the copy of `@camstack/types` that
27857
+ * `@camstack/system` inlines (`bundleTypesMainEntry`), so a new key on
27858
+ * `NcRuleInputSchema` is SILENTLY STRIPPED there until a `@camstack/server`
27859
+ * release carrying it is installed. These two validate only here, against a
27860
+ * schema that lives in the addon (`rule-ai.ts`), so a field added to it is
27861
+ * live on the next `camstack deploy packages/addon-post-analysis`.
27724
27862
  *
27725
27863
  * Scoped exactly as the rule CRUD is: reading needs a rule the caller can
27726
27864
  * SEE, writing needs one they own (admins bypass ownership, never the caller
@@ -28212,6 +28350,12 @@ function makeNcActionHandlers(deps) {
28212
28350
  catalog: [...NC_CONDITION_CATALOG],
28213
28351
  taxonomy: NC_TAXONOMY
28214
28352
  }),
28353
+ "nc.getTemplateCatalog": async () => ({ vars: [...NC_TEMPLATE_VARS] }),
28354
+ "nc.previewTemplate": async (input) => {
28355
+ const texts = deps.previewTexts;
28356
+ if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
28357
+ return previewTemplate(texts, input);
28358
+ },
28215
28359
  "nc.createRule": async (input, caller) => {
28216
28360
  const c = requireCaller$1(caller);
28217
28361
  const parsed = NcRuleInputSchema.parse(input.rule);
@@ -28232,10 +28376,11 @@ function makeNcActionHandlers(deps) {
28232
28376
  const patch = NcRulePatchSchema.parse(input.patch);
28233
28377
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
28234
28378
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
28235
- const rule = await deps.ruleStore.update(input.ruleId, safe);
28379
+ const rule = await deps.ruleStore.update(input.ruleId, safe, input.clear ?? []);
28236
28380
  deps.logger.info("nc rule updated", { meta: {
28237
28381
  ruleId: rule.id,
28238
- owner: c.userId
28382
+ owner: c.userId,
28383
+ ...input.clear && input.clear.length > 0 ? { cleared: input.clear } : {}
28239
28384
  } });
28240
28385
  return { rule };
28241
28386
  },
@@ -71662,9 +71807,111 @@ async function projectSensorMarkers(deps, data, timestamp) {
71662
71807
  }
71663
71808
  return landed;
71664
71809
  }
71810
+ //#endregion
71811
+ //#region src/pipeline-analytics/services/sensor-transition-gate.ts
71812
+ /**
71813
+ * The gate between "a `DeviceStateChanged` arrived for a mapped sensor cap"
71814
+ * and "the sensor actually CHANGED".
71815
+ *
71816
+ * ## What this fixes
71817
+ *
71818
+ * `handleSensorStateChanged` treated the ARRIVAL of the bus event as the
71819
+ * assertion, and never looked at the slice. It cannot: the kernel mirror
71820
+ * (`device-state-mirror.ts` `applySingleCapUpdate`) diffs the whole SLICE, and
71821
+ * a contact slice carries `lastChangedAt` beside `entryOpen`. Anything that
71822
+ * moves the timestamp without moving the boolean is a `DeviceStateChanged`,
71823
+ * and downstream it became a `SensorEvent` row and a synthetic `contact` track
71824
+ * on the linked camera's timeline.
71825
+ *
71826
+ * Measured on camera 615 / contact sensor 4131, 2026-08-29→30: **30 of 35**
71827
+ * contact events were one of two non-events.
71828
+ *
71829
+ * - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
71830
+ * (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
71831
+ * provider drops the `unavailable` push and writes the recovery, whose
71832
+ * `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
71833
+ * `last_changed`. The mirror diffs, and emits.
71834
+ * - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
71835
+ * `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
71836
+ * real state lands seconds later with a `lastChangedAt` hours old. Two
71837
+ * emissions, no transition.
71838
+ *
71839
+ * ## Why the predicate is imported and not written here
71840
+ *
71841
+ * `evaluateSensorEdge` (`@camstack/types`) is already THE edge predicate: the
71842
+ * virtual doorbell feeds it (`builtins/doorbell/trigger-engine.ts`) and so does
71843
+ * the recorder's sensor trigger (`addon-pipeline` `recorder/sensor-trigger.ts`).
71844
+ * Post-analysis was the third consumer of the same bus event and the only one
71845
+ * that did not. A second predicate here would diverge on the first cap added —
71846
+ * the exact failure [D62](../../../../../docs/decisions/adr-0062.md) names.
71847
+ *
71848
+ * ## Where it deliberately differs from the other two
71849
+ *
71850
+ * The doorbell and the recorder want the RISING edge only. A timeline marker
71851
+ * is not a doorbell: a door CLOSING is an event an operator wants to see, and
71852
+ * the taxonomy has one `contact` kind for both directions. So this gate accepts
71853
+ * `edge: 'rising'` **and** the `falling-edge` verdict, and rejects only the
71854
+ * verdicts that mean "nothing moved" — which is precisely the 30.
71855
+ *
71856
+ * ## Caps with no boolean level pass through untouched
71857
+ *
71858
+ * `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
71859
+ * ten that have a boolean whose edge means something. `doorbell`, `button`,
71860
+ * `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
71861
+ * — their ARRIVAL is the event, and three presses in a row are three markers.
71862
+ * Gating them would re-ship the failure of 2026-08-08, when camera 615's press
71863
+ * produced a row, a ring, a matched rule and no marker.
71864
+ *
71865
+ * ## A cover is a level whose level is a WORD
71866
+ *
71867
+ * `cover` is the sixteenth cap, and it is neither of the above. Its level is
71868
+ * the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
71869
+ * slice also carries `position`, which ticks all the way through a travel and
71870
+ * is republished unchanged by providers that poll. Passed through, every tick
71871
+ * and every republish would write a sensor-event row per linked camera and an
71872
+ * NC evaluation — so it transitions on the WORD only (`WORD_LEVEL_FIELD`):
71873
+ * `closed → opening → open` is two events, a position tick or a same-word
71874
+ * republish is a `no-change` drop.
71875
+ *
71876
+ * Its FIRST sighting follows the booleans' rule (`evaluateSensorEdge`), with
71877
+ * `closed` as the inactive word: `closed` seeds the baseline and is dropped
71878
+ * (`baseline-seeded-inactive`); any other word is an EVENT when the slice's
71879
+ * `lastChangedAt` clears the same floor, `max(startedAt, now - freshness)`,
71880
+ * and is otherwise seeded and dropped (`baseline-seeded-stale-active`). It has
71881
+ * to emit: after a runner respawn the memory is empty and the next slice is
71882
+ * usually the next REAL change, and on a two-word cover (Velux, most HA
71883
+ * covers: `closed`/`open` only) seeding it would lose the whole opening.
71884
+ *
71885
+ * Residual edges, both erring towards silence on the ambiguous case like the
71886
+ * booleans do: a cover that was already open and whose `lastChangedAt` is
71887
+ * older than the floor is not reported (a respawn cannot tell it from a
71888
+ * hydration); and a first sighting whose `lastChangedAt` is fresh only
71889
+ * because a POSITION tick moved it (the slice timestamp is not the word's)
71890
+ * is reported once as if the word had just changed. The alarm needs covers
71891
+ * to be eventful to see one open (D630).
71892
+ *
71893
+ * ## Nothing here logs, and no drop is silent
71894
+ *
71895
+ * The gate is pure towards the world (no bus, no logger, no clock of its own —
71896
+ * the caller brings `now`), like the recorder's router. But it sits on a path
71897
+ * that carries every camera's native `motion` slice at motion rate — ~0.6/s
71898
+ * fleet-wide — so a line per drop would be its own outage. It accumulates
71899
+ * instead, per `(deviceId, capName, reason)`, and the caller drains one summary
71900
+ * per window. A `debug` at 3.3/s that nobody read is how a 100 %-rejection
71901
+ * regime survived three months here.
71902
+ */
71903
+ /**
71904
+ * Caps whose level is a string word rather than a boolean: cap → slice field.
71905
+ * See "A cover is a level whose level is a WORD" above.
71906
+ */
71907
+ var WORD_LEVEL_FIELD = { cover: "state" };
71908
+ /** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
71909
+ var WORD_LEVEL_INACTIVE = { cover: "closed" };
71665
71910
  var SensorTransitionGate = class {
71666
71911
  /** Last observed boolean, keyed by `${deviceId}:${capName}`. */
71667
71912
  lastValues = /* @__PURE__ */ new Map();
71913
+ /** Last observed word of a word-level cap, keyed by `${deviceId}:${capName}`. */
71914
+ lastWords = /* @__PURE__ */ new Map();
71668
71915
  /** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
71669
71916
  drops = /* @__PURE__ */ new Map();
71670
71917
  windowStartedAt;
@@ -71682,10 +71929,13 @@ var SensorTransitionGate = class {
71682
71929
  /**
71683
71930
  * Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
71684
71931
  *
71685
- * A cap with no boolean level passes through unconditionally. A cap that HAS
71686
- * one passes only when its boolean moved — in either direction.
71932
+ * A cap with no level passes through unconditionally. A cap that HAS one
71933
+ * passes only when its level moved — a boolean in either direction, or a
71934
+ * word-level cap's word changing.
71687
71935
  */
71688
71936
  observe(deviceId, capName, slice) {
71937
+ const wordField = WORD_LEVEL_FIELD[capName];
71938
+ if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
71689
71939
  if (!isSourceCap(capName)) return { changed: true };
71690
71940
  const key = `${deviceId}:${capName}`;
71691
71941
  const verdict = evaluateSensorEdge({
@@ -71723,6 +71973,40 @@ var SensorTransitionGate = class {
71723
71973
  this.drops.clear();
71724
71974
  return out;
71725
71975
  }
71976
+ /**
71977
+ * A word-level cap: a transition is the word changing. The memory is
71978
+ * written only when the slice CARRIED a word, for the same reason as the
71979
+ * booleans — a malformed slice must not become a baseline.
71980
+ */
71981
+ observeWord(deviceId, capName, slice, wordField) {
71982
+ const raw = slice[wordField];
71983
+ if (typeof raw !== "string" || raw.length === 0) return this.drop(deviceId, capName, "non-word-value");
71984
+ const key = `${deviceId}:${capName}`;
71985
+ const prior = this.lastWords.get(key);
71986
+ this.lastWords.set(key, raw);
71987
+ if (prior === void 0) return this.firstWordSighting(deviceId, capName, slice, raw);
71988
+ if (prior === raw) return this.drop(deviceId, capName, "no-change");
71989
+ return { changed: true };
71990
+ }
71991
+ /**
71992
+ * The booleans' first-sighting rule, on a word: the resting word seeds; any
71993
+ * other word is an event only when the slice's own `lastChangedAt` clears
71994
+ * the SAME floor `evaluateSensorEdge` uses, from the same two knobs.
71995
+ */
71996
+ firstWordSighting(deviceId, capName, slice, word) {
71997
+ if (word === WORD_LEVEL_INACTIVE[capName]) return this.drop(deviceId, capName, "baseline-seeded-inactive");
71998
+ const changedAt = slice["lastChangedAt"];
71999
+ const floor = Math.max(this.startedAtMs, this.now() - this.freshnessMs);
72000
+ if (!(typeof changedAt === "number" && Number.isFinite(changedAt) && changedAt > 0 && changedAt >= floor)) return this.drop(deviceId, capName, "baseline-seeded-stale-active");
72001
+ return { changed: true };
72002
+ }
72003
+ drop(deviceId, capName, reason) {
72004
+ this.noteDrop(deviceId, capName, reason);
72005
+ return {
72006
+ changed: false,
72007
+ reason
72008
+ };
72009
+ }
71726
72010
  noteDrop(deviceId, capName, reason) {
71727
72011
  const key = `${deviceId}:${capName}:${reason}`;
71728
72012
  const prior = this.drops.get(key);
@@ -78309,6 +78593,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
78309
78593
  runNow: (input) => center.runSummaryNow(input)
78310
78594
  },
78311
78595
  texts: center.textCatalogEditor,
78596
+ previewTexts: center.textCatalog,
78312
78597
  ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
78313
78598
  artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
78314
78599
  ruleStatus: {
@@ -85148,7 +85433,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends BaseAddon {
85148
85433
  * A cap with no boolean level (`doorbell`, `button`, `lock-control`,
85149
85434
  * `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
85150
85435
  * the event, and gating it would re-ship the silence of 2026-08-08. A cap
85151
- * that HAS one passes only when the boolean moved, in either direction.
85436
+ * that HAS one passes only when the boolean moved, in either direction — or,
85437
+ * for `cover`, whose level is its `state` word, only when the word changed
85438
+ * (a position tick or a same-word republish is a drop).
85152
85439
  *
85153
85440
  * A slice that is not an object at all is refused rather than defaulted to
85154
85441
  * `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty
@@ -30,7 +30,7 @@ async function d(e) {
30
30
  }
31
31
  }
32
32
  async function f() {
33
- return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-CNrdmO1k.mjs")).catch((e) => {
33
+ return l ||= d(() => import("./_virtual_mf-localSharedImportMap___mfe_internal__addon_pipeline_analytics_widgets-Byz9Hzo0.mjs")).catch((e) => {
34
34
  throw l = void 0, e;
35
35
  }), l;
36
36
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/addon-post-analysis",
3
- "version": "1.2.274",
3
+ "version": "1.2.275",
4
4
  "description": "Post-Analysis bundle — enrichment, embedding-encoder, pipeline-analytics. Multi-entry npm package shipping addons that consume pipeline output.",
5
5
  "keywords": [
6
6
  "camstack",