@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.
@@ -2,7 +2,7 @@ Object.defineProperties(exports, {
2
2
  __esModule: { value: true },
3
3
  [Symbol.toStringTag]: { value: "Module" }
4
4
  });
5
- const require_dist = require("../dist-B9V4PRZO.js");
5
+ const require_dist = require("../dist-Br0p61E2.js");
6
6
  let node_fs = require("node:fs");
7
7
  node_fs = require_dist.__toESM(node_fs, 1);
8
8
  let node_path = require("node:path");
@@ -1460,7 +1460,8 @@ var NcAlarmPanelDevice = class extends require_dist.BaseDevice {
1460
1460
  return {
1461
1461
  ...this.readDelays(),
1462
1462
  announceArm: this.config.get("announceArm"),
1463
- announceTargets: [...this.config.get("announceTargets")]
1463
+ announceTargets: [...this.config.get("announceTargets")],
1464
+ nonBlocking: []
1464
1465
  };
1465
1466
  }
1466
1467
  /**
@@ -10822,6 +10823,23 @@ function requireCapCaller(method, caller) {
10822
10823
  return caller;
10823
10824
  }
10824
10825
  //#endregion
10826
+ //#region src/notification-center/clock-text.ts
10827
+ /**
10828
+ * `{{time}}` — hours and minutes in the NOTIFICATION language.
10829
+ *
10830
+ * It used to be `toLocaleTimeString()` in the server's own locale, so an
10831
+ * Italian hub running an English container sent "8:05:13 PM" inside an
10832
+ * Italian sentence. The time zone is the host's unless a caller (a spec)
10833
+ * pins one.
10834
+ */
10835
+ function ncClockText(language, atMs, timeZone) {
10836
+ return new Intl.DateTimeFormat(language, {
10837
+ hour: "2-digit",
10838
+ minute: "2-digit",
10839
+ ...timeZone !== void 0 ? { timeZone } : {}
10840
+ }).format(new Date(atMs));
10841
+ }
10842
+ //#endregion
10825
10843
  //#region src/notification-center/system-event-tap-url.ts
10826
10844
  /** The admin UI's own routes, as `packages/addon-admin-ui/src/App.tsx` declares them. */
10827
10845
  var ADMIN_ADDONS_PATH = "/system/addons";
@@ -12790,8 +12808,7 @@ var NcDispatcher = class {
12790
12808
  async buildNotification(entry, target) {
12791
12809
  const subject = entry.payload.subject;
12792
12810
  const systemEvent = subject.systemEvent;
12793
- const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
12794
- const deviceName = systemEvent !== void 0 && systemEvent.deviceId === void 0 ? "system" : await this.deps.getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
12811
+ const deviceName = await notificationDeviceName(entry, this.deps.getDeviceName);
12795
12812
  const alarmSource = systemEvent?.alarmSource;
12796
12813
  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) : [];
12797
12814
  const vars = buildTemplateVars(this.texts, entry, deviceName, zoneLabels);
@@ -13545,17 +13562,25 @@ async function resolveZoneLabels(getZoneNames, deviceId, zoneIds) {
13545
13562
  function percentOf(confidence) {
13546
13563
  return confidence !== void 0 ? `${Math.round(confidence * 100)}%` : "";
13547
13564
  }
13565
+ /** What `{{camera}}` renders on a system row that names no device. */
13566
+ var NC_DEVICELESS_CAMERA_NAME = "system";
13548
13567
  /**
13549
- * The variable map ONE render pass sees: scalars, then the composites built
13550
- * from them, then the params a system-event row froze at intake.
13568
+ * The name `{{camera}}` renders for a row — the ONE derivation, shared by the
13569
+ * dispatcher and the template-variable parity spec.
13551
13570
  *
13552
- * The three groups are layered in that order deliberately. A composite
13553
- * (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
13554
- * visible to the fragments it was built from — that is the depth bound, and it
13555
- * is enforced by `text-compose.ts` never being handed this map. The frozen
13556
- * params go last because they are the row's own truth about a system event and
13557
- * outrank the live vocabulary for exactly those names.
13571
+ * A system row that names no device (a node, a backup, an update) renders the
13572
+ * filler {@link NC_DEVICELESS_CAMERA_NAME}: there is no camera to name, which
13573
+ * is why the catalog offers `{{camera}}` on a system rule only for the kinds
13574
+ * that carry a device (template-vars.ts). Everything else asks the directory,
13575
+ * and falls back to `camera <id>` when it cannot answer.
13558
13576
  */
13577
+ async function notificationDeviceName(entry, getDeviceName) {
13578
+ const subject = entry.payload.subject;
13579
+ const systemEvent = subject.systemEvent;
13580
+ if (systemEvent !== void 0 && systemEvent.deviceId === void 0) return NC_DEVICELESS_CAMERA_NAME;
13581
+ const displayDeviceId = systemEvent?.deviceId ?? subject.deviceId;
13582
+ return await getDeviceName(displayDeviceId).catch(() => null) ?? `camera ${displayDeviceId}`;
13583
+ }
13559
13584
  /** Class composition of a group, busiest classes in first-seen order. */
13560
13585
  function classCounts(members) {
13561
13586
  const counts = /* @__PURE__ */ new Map();
@@ -13565,7 +13590,18 @@ function classCounts(members) {
13565
13590
  count
13566
13591
  }));
13567
13592
  }
13568
- function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13593
+ /**
13594
+ * The variable map ONE render pass sees: scalars, then the composites built
13595
+ * from them, then the params a system-event row froze at intake.
13596
+ *
13597
+ * The three groups are layered in that order deliberately. A composite
13598
+ * (`{{subject}}`, `{{inZones}}`, `{{op}}`) is a finished phrase and must not be
13599
+ * visible to the fragments it was built from — that is the depth bound, and it
13600
+ * is enforced by `text-compose.ts` never being handed this map. The frozen
13601
+ * params go last because they are the row's own truth about a system event and
13602
+ * outrank the live vocabulary for exactly those names.
13603
+ */
13604
+ function buildTemplateVars(texts, entry, deviceName, zoneLabels, timeZone) {
13569
13605
  const subject = entry.payload.subject;
13570
13606
  const occupancy = subject.occupancy;
13571
13607
  const systemEvent = subject.systemEvent;
@@ -13573,7 +13609,7 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13573
13609
  const group = entry.payload.group;
13574
13610
  return {
13575
13611
  camera: deviceName,
13576
- class: detection?.className ?? subject.className,
13612
+ class: detection?.className ?? (systemEvent !== void 0 ? "" : subject.className),
13577
13613
  label: detection?.label ?? subject.label ?? "",
13578
13614
  zones: zoneLabels.join(", "),
13579
13615
  subject: detection?.className !== void 0 ? ncSubjectText(texts, {
@@ -13586,10 +13622,14 @@ function buildTemplateVars(texts, entry, deviceName, zoneLabels) {
13586
13622
  }),
13587
13623
  detectionSummary: group !== void 0 && group.members.length > 1 ? ncDetectionSummary(texts, classCounts(group.members)) : "",
13588
13624
  inZones: ncInZonesText(texts, zoneLabels),
13589
- scope: occupancy?.zone ?? deviceName,
13625
+ scope: occupancy !== void 0 ? occupancy.zone ?? deviceName : "",
13590
13626
  zone: occupancy?.zone ?? zoneLabels[0] ?? "",
13591
13627
  confidence: percentOf(detection?.confidence ?? subject.confidence),
13592
- time: new Date(subject.timestamp).toLocaleTimeString(),
13628
+ time: ncClockText(texts.language, subject.timestamp, timeZone),
13629
+ sounds: subject.audio?.labels.join(", ") ?? "",
13630
+ hitPercent: subject.audio?.hitPercent !== void 0 ? `${Math.round(subject.audio.hitPercent)}` : "",
13631
+ db: subject.audio?.peakDbfs !== void 0 ? `${Math.round(subject.audio.peakDbfs)}` : "",
13632
+ eventType: subject.eventType ?? "",
13593
13633
  rule: entry.payload.ruleName,
13594
13634
  count: occupancy !== void 0 ? `${occupancy.count}` : group !== void 0 && group.members.length > 1 ? `${group.members.length}` : "",
13595
13635
  capacity: occupancy !== void 0 ? `${occupancy.capacity}` : "",
@@ -17420,12 +17460,22 @@ var NcRuleStore = class {
17420
17460
  await this.ledger.put(rule);
17421
17461
  return rule;
17422
17462
  }
17423
- /** Apply a partial patch. Immutable: returns the NEW rule object. */
17424
- async update(ruleId, patch) {
17463
+ /**
17464
+ * Apply a partial patch. Immutable: returns the NEW rule object.
17465
+ *
17466
+ * `clear` names optional fields to REMOVE before the merge — an omitted key
17467
+ * keeps its stored value, so this is how an editor empties one. Setting and
17468
+ * clearing the same key is a contradiction and is refused rather than
17469
+ * resolved by ordering.
17470
+ */
17471
+ async update(ruleId, patch, clear = []) {
17425
17472
  const existing = this.ledger.get(ruleId);
17426
17473
  if (!existing) throw new Error(`notification rule not found: ${ruleId}`);
17474
+ const conflict = clear.find((key) => patch[key] !== void 0);
17475
+ if (conflict !== void 0) throw new Error(`notification rule patch both sets and clears '${conflict}'`);
17476
+ const cleared = new Set(clear);
17427
17477
  const candidate = {
17428
- ...existing,
17478
+ ...Object.fromEntries(Object.entries(existing).filter(([key]) => !cleared.has(key))),
17429
17479
  ...patch,
17430
17480
  id: existing.id,
17431
17481
  createdBy: existing.createdBy,
@@ -19333,13 +19383,18 @@ function clockOf$2(atMs) {
19333
19383
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
19334
19384
  }
19335
19385
  /** Template vocabulary. Deliberately the SAME names the detection and timelapse
19336
- * bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones. */
19337
- function templateVars$1(input) {
19386
+ * bodies use (`rule`, `from`, `to`, `time`) plus the digest-only ones.
19387
+ *
19388
+ * Exported as `summaryTemplateVars` — not just an implementation detail of
19389
+ * {@link buildSummaryOutboxInputs} — so the parity spec
19390
+ * (`template-var-parity.spec.ts`) can assert this map directly against the
19391
+ * catalog, the same way `buildTemplateVars` is asserted for rule rows. */
19392
+ function summaryTemplateVars(input) {
19338
19393
  return {
19339
19394
  rule: input.ruleName,
19340
19395
  from: clockOf$2(input.window.startMs),
19341
19396
  to: clockOf$2(input.window.endMs),
19342
- time: clockOf$2(input.generatedAt),
19397
+ time: ncClockText(input.texts.language, input.generatedAt),
19343
19398
  events: String(input.tileCount),
19344
19399
  cameras: String(input.cameraCount),
19345
19400
  matched: String(input.matchedCount),
@@ -19446,7 +19501,7 @@ function summaryCaptionText(input) {
19446
19501
  * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
19447
19502
  */
19448
19503
  function buildSummaryOutboxInputs(input) {
19449
- const vars = templateVars$1(input);
19504
+ const vars = summaryTemplateVars(input);
19450
19505
  const optedOut = new Set(input.disabledTargetIds ?? []);
19451
19506
  const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
19452
19507
  key: "summary.title",
@@ -21685,6 +21740,67 @@ async function deriveSyntheticMedia(jpeg, bbox, timestamp) {
21685
21740
  return files;
21686
21741
  }
21687
21742
  //#endregion
21743
+ //#region src/notification-center/template-preview.ts
21744
+ /**
21745
+ * `previewTemplate` — render a rule's draft title/body against EXAMPLE values,
21746
+ * so an operator sees `{{scope}}: {{count}}/{{capacity}}` become
21747
+ * `Cancello: 3/2` before saving the rule, not after the first real event.
21748
+ *
21749
+ * Pure with respect to the caller: it never sends anything, and the only
21750
+ * side-channel it reads is the live `NcTextCatalog` handed in, so a preview
21751
+ * always speaks the hub's CURRENT language and CURRENT overrides — the same
21752
+ * words a delivered notification would use.
21753
+ */
21754
+ /**
21755
+ * The instant `{{time}}` is previewed at — fixed, and in UTC, so the example
21756
+ * is the same on every host: "20:05" in Italian, "08:05 PM" in English.
21757
+ */
21758
+ var PREVIEW_INSTANT_MS = Date.UTC(2026, 8, 24, 20, 5);
21759
+ var PREVIEW_TIME_ZONE = "UTC";
21760
+ /**
21761
+ * The example map: every declared example, with the COMPOSITES rendered by the
21762
+ * real composers so the preview speaks the hub's language exactly as a
21763
+ * delivered notification would.
21764
+ */
21765
+ function exampleVars(texts) {
21766
+ return {
21767
+ ...Object.fromEntries(require_dist.NC_TEMPLATE_VARS.filter((d) => d.dynamic === void 0).map((d) => [d.name, d.example])),
21768
+ subject: ncSubjectText(texts, {
21769
+ className: "person",
21770
+ label: "Mario",
21771
+ labelKind: "identity"
21772
+ }),
21773
+ inZones: ncInZonesText(texts, ["Cancello"]),
21774
+ op: ncOccupancyOp(texts, true),
21775
+ time: ncClockText(texts.language, PREVIEW_INSTANT_MS, PREVIEW_TIME_ZONE),
21776
+ detectionSummary: ncDetectionSummary(texts, [{
21777
+ className: "person",
21778
+ count: 2
21779
+ }, {
21780
+ className: "car",
21781
+ count: 1
21782
+ }])
21783
+ };
21784
+ }
21785
+ var DYNAMIC_EXAMPLE = "3";
21786
+ function previewTemplate(texts, input) {
21787
+ const ctx = input.context;
21788
+ const allowed = new Set(require_dist.templateVarsFor(ctx).map((d) => d.name));
21789
+ const examples = exampleVars(texts);
21790
+ const used = [input.template.title ?? "", input.template.body ?? ""].flatMap((t) => [...templatePlaceholders(t)]);
21791
+ const unknown = [...new Set(used.filter((name) => !require_dist.isKnownTemplateVar(name, ctx)))];
21792
+ const vars = {};
21793
+ for (const name of used) {
21794
+ if (!require_dist.isKnownTemplateVar(name, ctx)) continue;
21795
+ vars[name] = allowed.has(name) ? examples[name] ?? "" : DYNAMIC_EXAMPLE;
21796
+ }
21797
+ return {
21798
+ title: renderTemplate(input.template.title, vars),
21799
+ body: renderTemplate(input.template.body, vars),
21800
+ unknown
21801
+ };
21802
+ }
21803
+ //#endregion
21688
21804
  //#region src/notification-center/text-catalog-view.ts
21689
21805
  /**
21690
21806
  * The text catalog, projected for an EDITOR — Phase 2's read model, pure.
@@ -22124,14 +22240,19 @@ function clockOf(atMs) {
22124
22240
  return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
22125
22241
  }
22126
22242
  /** Template vocabulary. Deliberately the same NAMES the rule templates use
22127
- * (`camera`, `rule`, `time`) plus the timelapse-only ones. */
22128
- function templateVars(input) {
22243
+ * (`camera`, `rule`, `time`) plus the timelapse-only ones.
22244
+ *
22245
+ * Exported as `timelapseTemplateVars` — not just an implementation detail of
22246
+ * {@link buildTimelapseOutboxInputs} — so the parity spec
22247
+ * (`template-var-parity.spec.ts`) can assert this map directly against the
22248
+ * catalog, the same way `buildTemplateVars` is asserted for rule rows. */
22249
+ function timelapseTemplateVars(input) {
22129
22250
  return {
22130
22251
  camera: input.deviceName ?? `camera ${input.deviceId}`,
22131
22252
  rule: input.ruleName,
22132
22253
  from: clockOf(input.window.startMs),
22133
22254
  to: clockOf(input.window.endMs),
22134
- time: clockOf(input.generatedAt),
22255
+ time: ncClockText(input.texts.language, input.generatedAt),
22135
22256
  coverage: String(input.coverage.percent),
22136
22257
  events: String(input.denseRanges),
22137
22258
  ...detectionTemplateVars(input.texts, input.detections ?? NO_DETECTIONS)
@@ -22212,7 +22333,7 @@ function timelapseRecordId(deviceId, windowEndMs) {
22212
22333
  * @returns rows ready for `NcOutbox.enqueue` — never sent from here.
22213
22334
  */
22214
22335
  function buildTimelapseOutboxInputs(input) {
22215
- const vars = templateVars(input);
22336
+ const vars = timelapseTemplateVars(input);
22216
22337
  const optedOut = new Set(input.disabledTargetIds ?? []);
22217
22338
  const title = renderTemplate(input.template?.title, vars) ?? input.texts.text({
22218
22339
  key: "timelapse.title",
@@ -23195,7 +23316,8 @@ function consumablesSliceOf(event) {
23195
23316
  var NC_ALARM_SETTINGS_FALLBACK = {
23196
23317
  ...DEFAULT_DELAYS,
23197
23318
  announceArm: false,
23198
- announceTargets: []
23319
+ announceTargets: [],
23320
+ nonBlocking: []
23199
23321
  };
23200
23322
  /**
23201
23323
  * How often the "matched NO rule" report may fire per device. Long enough that
@@ -24445,6 +24567,10 @@ var NotificationCenter = class NotificationCenter {
24445
24567
  get textCatalogEditor() {
24446
24568
  return this.textEditor;
24447
24569
  }
24570
+ /** The live catalog every producer renders with — the preview must use the same one. */
24571
+ get textCatalog() {
24572
+ return this.texts;
24573
+ }
24448
24574
  /**
24449
24575
  * Produce the last closed window for one camera of one timelapse rule, now.
24450
24576
  *
@@ -25307,17 +25433,19 @@ var NotificationCenter = class NotificationCenter {
25307
25433
  } });
25308
25434
  return { rule: created };
25309
25435
  },
25310
- updateRule: async ({ ruleId, patch, caller }) => {
25436
+ updateRule: async ({ ruleId, patch, clear, caller }) => {
25311
25437
  if (patch.targets !== void 0) await this.validateTargetRefs(patch.targets.map((t) => t.targetId));
25312
- if (patch.targets !== void 0 || patch.targetUsers !== void 0) {
25438
+ const clearsUsers = clear?.includes("targetUsers") ?? false;
25439
+ if (patch.targets !== void 0 || patch.targetUsers !== void 0 || clearsUsers) {
25313
25440
  const existing = this.rules.get(ruleId);
25314
- NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], patch.targetUsers ?? existing?.targetUsers);
25441
+ NotificationCenter.assertHasAddressee(patch.targets ?? existing?.targets ?? [], clearsUsers ? void 0 : patch.targetUsers ?? existing?.targetUsers);
25315
25442
  }
25316
25443
  const { disabledTargetIds: _optOut, ...safePatch } = patch;
25317
- const updated = await this.rules.update(ruleId, safePatch);
25444
+ const updated = await this.rules.update(ruleId, safePatch, clear ?? []);
25318
25445
  this.logger.info("notification rule updated", { meta: {
25319
25446
  ruleId,
25320
- by: caller.userId
25447
+ by: caller.userId,
25448
+ ...clear && clear.length > 0 ? { cleared: clear } : {}
25321
25449
  } });
25322
25450
  return { rule: updated };
25323
25451
  },
@@ -25334,6 +25462,8 @@ var NotificationCenter = class NotificationCenter {
25334
25462
  catalog: [...require_dist.NC_CONDITION_CATALOG],
25335
25463
  taxonomy: require_dist.NC_TAXONOMY
25336
25464
  }),
25465
+ getTemplateCatalog: async () => ({ vars: [...require_dist.NC_TEMPLATE_VARS] }),
25466
+ previewTemplate: async (input) => previewTemplate(this.texts, input),
25337
25467
  getHistory: async ({ filter }) => {
25338
25468
  const entries = await this.outbox.queryHistory({
25339
25469
  ...filter.ruleId !== void 0 ? { ruleId: filter.ruleId } : {},
@@ -26521,6 +26651,12 @@ var NotificationCenter = class NotificationCenter {
26521
26651
  ...subject.observedAt !== void 0 ? { observedAt: subject.observedAt } : {},
26522
26652
  ...subject.occupancy !== void 0 ? { occupancy: frozenOccupancy(rule, subject.occupancy) } : {},
26523
26653
  ...subject.packagePhase !== void 0 ? { packagePhase: subject.packagePhase } : {},
26654
+ ...subject.audioWindow !== void 0 ? { audio: {
26655
+ labels: subject.audioWindow.labels,
26656
+ ...subject.audioWindow.mode === "level" ? { hitPercent: subject.audioWindow.hitPercent } : {},
26657
+ ...subject.audioWindow.peakDbfs !== void 0 ? { peakDbfs: subject.audioWindow.peakDbfs } : {}
26658
+ } } : {},
26659
+ ...subject.eventType !== void 0 ? { eventType: subject.eventType } : {},
26524
26660
  ...subject.systemEvent !== void 0 ? { systemEvent: subject.systemEvent } : {}
26525
26661
  }
26526
26662
  };
@@ -27709,14 +27845,13 @@ var ncActions = require_dist.defineCustomActions({
27709
27845
  catalog: require_dist.array(require_dist.NcConditionDescriptorSchema),
27710
27846
  taxonomy: require_dist.NcTaxonomySchema
27711
27847
  })),
27848
+ "nc.getTemplateCatalog": require_dist.customAction(require_dist.object({}), require_dist.object({ vars: require_dist.array(require_dist.NcTemplateVarDescriptorSchema) })),
27849
+ "nc.previewTemplate": require_dist.customAction(require_dist.NcTemplatePreviewInputSchema, require_dist.NcTemplatePreviewSchema, { kind: "mutation" }),
27712
27850
  "nc.createRule": require_dist.customAction(require_dist.object({ rule: require_dist.NcRuleInputSchema }), require_dist.object({ rule: require_dist.NcRuleSchema }), {
27713
27851
  kind: "mutation",
27714
27852
  caller: "required"
27715
27853
  }),
27716
- "nc.updateRule": require_dist.customAction(require_dist.object({
27717
- ruleId: require_dist.string(),
27718
- patch: require_dist.NcRulePatchSchema
27719
- }), require_dist.object({ rule: require_dist.NcRuleSchema }), {
27854
+ "nc.updateRule": require_dist.customAction(require_dist.NcRuleUpdateInputSchema, require_dist.object({ rule: require_dist.NcRuleSchema }), {
27720
27855
  kind: "mutation",
27721
27856
  caller: "required"
27722
27857
  }),
@@ -27735,14 +27870,17 @@ var ncActions = require_dist.defineCustomActions({
27735
27870
  /**
27736
27871
  * THE AI SECTION of the ordinary rules — read and write.
27737
27872
  *
27738
- * A pair of its own rather than two more keys on `nc.createRule` /
27739
- * `nc.updateRule`, and that is the whole point of the design: those two
27740
- * validate against `NcRuleInputSchema` from `@camstack/types`, which the
27741
- * addon resolves from the `@camstack/server` closure at runtime — so a new
27742
- * key on them is SILENTLY STRIPPED at save until a framework train lands and
27743
- * is installed. These two validate against a schema that lives in the addon
27744
- * (`rule-ai.ts`), so a field added to it is live on the next
27745
- * `camstack deploy packages/addon-post-analysis`.
27873
+ * A pair of its own rather than two more keys on the rule CRUD, and that is
27874
+ * the whole point of the design. The addon builds self-contained and INLINES
27875
+ * `@camstack/types`, so its own copy of `NcRuleInputSchema` is never the
27876
+ * problem and nothing here fails at load. The coupling is the HUB: the cap
27877
+ * route (`notificationRules.createRule` / `updateRule`, which the admin
27878
+ * editor uses) validates input against the copy of `@camstack/types` that
27879
+ * `@camstack/system` inlines (`bundleTypesMainEntry`), so a new key on
27880
+ * `NcRuleInputSchema` is SILENTLY STRIPPED there until a `@camstack/server`
27881
+ * release carrying it is installed. These two validate only here, against a
27882
+ * schema that lives in the addon (`rule-ai.ts`), so a field added to it is
27883
+ * live on the next `camstack deploy packages/addon-post-analysis`.
27746
27884
  *
27747
27885
  * Scoped exactly as the rule CRUD is: reading needs a rule the caller can
27748
27886
  * SEE, writing needs one they own (admins bypass ownership, never the caller
@@ -28234,6 +28372,12 @@ function makeNcActionHandlers(deps) {
28234
28372
  catalog: [...require_dist.NC_CONDITION_CATALOG],
28235
28373
  taxonomy: require_dist.NC_TAXONOMY
28236
28374
  }),
28375
+ "nc.getTemplateCatalog": async () => ({ vars: [...require_dist.NC_TEMPLATE_VARS] }),
28376
+ "nc.previewTemplate": async (input) => {
28377
+ const texts = deps.previewTexts;
28378
+ if (texts === void 0) throw new Error("nc.previewTemplate: text catalog not wired");
28379
+ return previewTemplate(texts, input);
28380
+ },
28237
28381
  "nc.createRule": async (input, caller) => {
28238
28382
  const c = requireCaller$1(caller);
28239
28383
  const parsed = require_dist.NcRuleInputSchema.parse(input.rule);
@@ -28254,10 +28398,11 @@ function makeNcActionHandlers(deps) {
28254
28398
  const patch = require_dist.NcRulePatchSchema.parse(input.patch);
28255
28399
  if (patch.targets !== void 0) await assertTargetsOwned(patch.targets.map((t) => t.targetId), c);
28256
28400
  const { ownerUserId: _owner, disabledTargetIds: _optOut, ...safe } = patch;
28257
- const rule = await deps.ruleStore.update(input.ruleId, safe);
28401
+ const rule = await deps.ruleStore.update(input.ruleId, safe, input.clear ?? []);
28258
28402
  deps.logger.info("nc rule updated", { meta: {
28259
28403
  ruleId: rule.id,
28260
- owner: c.userId
28404
+ owner: c.userId,
28405
+ ...input.clear && input.clear.length > 0 ? { cleared: input.clear } : {}
28261
28406
  } });
28262
28407
  return { rule };
28263
28408
  },
@@ -71738,9 +71883,111 @@ async function projectSensorMarkers(deps, data, timestamp) {
71738
71883
  }
71739
71884
  return landed;
71740
71885
  }
71886
+ //#endregion
71887
+ //#region src/pipeline-analytics/services/sensor-transition-gate.ts
71888
+ /**
71889
+ * The gate between "a `DeviceStateChanged` arrived for a mapped sensor cap"
71890
+ * and "the sensor actually CHANGED".
71891
+ *
71892
+ * ## What this fixes
71893
+ *
71894
+ * `handleSensorStateChanged` treated the ARRIVAL of the bus event as the
71895
+ * assertion, and never looked at the slice. It cannot: the kernel mirror
71896
+ * (`device-state-mirror.ts` `applySingleCapUpdate`) diffs the whole SLICE, and
71897
+ * a contact slice carries `lastChangedAt` beside `entryOpen`. Anything that
71898
+ * moves the timestamp without moving the boolean is a `DeviceStateChanged`,
71899
+ * and downstream it became a `SensorEvent` row and a synthetic `contact` track
71900
+ * on the linked camera's timeline.
71901
+ *
71902
+ * Measured on camera 615 / contact sensor 4131, 2026-08-29→30: **30 of 35**
71903
+ * contact events were one of two non-events.
71904
+ *
71905
+ * - The RECOVERY family. The Zigbee entity flaps `off → unavailable → off`
71906
+ * (03:22:37→03:23:28, 03:56:51→03:58:19, 04:40:05→04:45:45). The HA
71907
+ * provider drops the `unavailable` push and writes the recovery, whose
71908
+ * `entryOpen` is still `false` and whose `lastChangedAt` is HA's fresh
71909
+ * `last_changed`. The mirror diffs, and emits.
71910
+ * - The COLD-SLICE family. `HaBinarySensorDevice`'s constructor writes
71911
+ * `{ entryOpen: false, lastChangedAt: 0 }` on every device rebuild; the
71912
+ * real state lands seconds later with a `lastChangedAt` hours old. Two
71913
+ * emissions, no transition.
71914
+ *
71915
+ * ## Why the predicate is imported and not written here
71916
+ *
71917
+ * `evaluateSensorEdge` (`@camstack/types`) is already THE edge predicate: the
71918
+ * virtual doorbell feeds it (`builtins/doorbell/trigger-engine.ts`) and so does
71919
+ * the recorder's sensor trigger (`addon-pipeline` `recorder/sensor-trigger.ts`).
71920
+ * Post-analysis was the third consumer of the same bus event and the only one
71921
+ * that did not. A second predicate here would diverge on the first cap added —
71922
+ * the exact failure [D62](../../../../../docs/decisions/adr-0062.md) names.
71923
+ *
71924
+ * ## Where it deliberately differs from the other two
71925
+ *
71926
+ * The doorbell and the recorder want the RISING edge only. A timeline marker
71927
+ * is not a doorbell: a door CLOSING is an event an operator wants to see, and
71928
+ * the taxonomy has one `contact` kind for both directions. So this gate accepts
71929
+ * `edge: 'rising'` **and** the `falling-edge` verdict, and rejects only the
71930
+ * verdicts that mean "nothing moved" — which is precisely the 30.
71931
+ *
71932
+ * ## Caps with no boolean level pass through untouched
71933
+ *
71934
+ * `EVENT_KIND_BY_CAP` covers sixteen caps; `SOURCE_CAP_ACTIVE_FIELD` covers the
71935
+ * ten that have a boolean whose edge means something. `doorbell`, `button`,
71936
+ * `lock-control`, `presence`, `enum-sensor` and `event-emitter` are NOT levels
71937
+ * — their ARRIVAL is the event, and three presses in a row are three markers.
71938
+ * Gating them would re-ship the failure of 2026-08-08, when camera 615's press
71939
+ * produced a row, a ring, a matched rule and no marker.
71940
+ *
71941
+ * ## A cover is a level whose level is a WORD
71942
+ *
71943
+ * `cover` is the sixteenth cap, and it is neither of the above. Its level is
71944
+ * the `state` word (`open`, `opening`, `closing`, `closed`, `stopped`), and its
71945
+ * slice also carries `position`, which ticks all the way through a travel and
71946
+ * is republished unchanged by providers that poll. Passed through, every tick
71947
+ * and every republish would write a sensor-event row per linked camera and an
71948
+ * NC evaluation — so it transitions on the WORD only (`WORD_LEVEL_FIELD`):
71949
+ * `closed → opening → open` is two events, a position tick or a same-word
71950
+ * republish is a `no-change` drop.
71951
+ *
71952
+ * Its FIRST sighting follows the booleans' rule (`evaluateSensorEdge`), with
71953
+ * `closed` as the inactive word: `closed` seeds the baseline and is dropped
71954
+ * (`baseline-seeded-inactive`); any other word is an EVENT when the slice's
71955
+ * `lastChangedAt` clears the same floor, `max(startedAt, now - freshness)`,
71956
+ * and is otherwise seeded and dropped (`baseline-seeded-stale-active`). It has
71957
+ * to emit: after a runner respawn the memory is empty and the next slice is
71958
+ * usually the next REAL change, and on a two-word cover (Velux, most HA
71959
+ * covers: `closed`/`open` only) seeding it would lose the whole opening.
71960
+ *
71961
+ * Residual edges, both erring towards silence on the ambiguous case like the
71962
+ * booleans do: a cover that was already open and whose `lastChangedAt` is
71963
+ * older than the floor is not reported (a respawn cannot tell it from a
71964
+ * hydration); and a first sighting whose `lastChangedAt` is fresh only
71965
+ * because a POSITION tick moved it (the slice timestamp is not the word's)
71966
+ * is reported once as if the word had just changed. The alarm needs covers
71967
+ * to be eventful to see one open (D630).
71968
+ *
71969
+ * ## Nothing here logs, and no drop is silent
71970
+ *
71971
+ * The gate is pure towards the world (no bus, no logger, no clock of its own —
71972
+ * the caller brings `now`), like the recorder's router. But it sits on a path
71973
+ * that carries every camera's native `motion` slice at motion rate — ~0.6/s
71974
+ * fleet-wide — so a line per drop would be its own outage. It accumulates
71975
+ * instead, per `(deviceId, capName, reason)`, and the caller drains one summary
71976
+ * per window. A `debug` at 3.3/s that nobody read is how a 100 %-rejection
71977
+ * regime survived three months here.
71978
+ */
71979
+ /**
71980
+ * Caps whose level is a string word rather than a boolean: cap → slice field.
71981
+ * See "A cover is a level whose level is a WORD" above.
71982
+ */
71983
+ var WORD_LEVEL_FIELD = { cover: "state" };
71984
+ /** The word a word-level cap rests in — its `false`, for the first-sighting rule. */
71985
+ var WORD_LEVEL_INACTIVE = { cover: "closed" };
71741
71986
  var SensorTransitionGate = class {
71742
71987
  /** Last observed boolean, keyed by `${deviceId}:${capName}`. */
71743
71988
  lastValues = /* @__PURE__ */ new Map();
71989
+ /** Last observed word of a word-level cap, keyed by `${deviceId}:${capName}`. */
71990
+ lastWords = /* @__PURE__ */ new Map();
71744
71991
  /** Drops since the window opened, keyed by `${deviceId}:${capName}:${reason}`. */
71745
71992
  drops = /* @__PURE__ */ new Map();
71746
71993
  windowStartedAt;
@@ -71758,10 +72005,13 @@ var SensorTransitionGate = class {
71758
72005
  /**
71759
72006
  * Feed one `DeviceStateChanged` slice for a cap `EVENT_KIND_BY_CAP` maps.
71760
72007
  *
71761
- * A cap with no boolean level passes through unconditionally. A cap that HAS
71762
- * one passes only when its boolean moved — in either direction.
72008
+ * A cap with no level passes through unconditionally. A cap that HAS one
72009
+ * passes only when its level moved — a boolean in either direction, or a
72010
+ * word-level cap's word changing.
71763
72011
  */
71764
72012
  observe(deviceId, capName, slice) {
72013
+ const wordField = WORD_LEVEL_FIELD[capName];
72014
+ if (wordField !== void 0) return this.observeWord(deviceId, capName, slice, wordField);
71765
72015
  if (!require_dist.isSourceCap(capName)) return { changed: true };
71766
72016
  const key = `${deviceId}:${capName}`;
71767
72017
  const verdict = require_dist.evaluateSensorEdge({
@@ -71799,6 +72049,40 @@ var SensorTransitionGate = class {
71799
72049
  this.drops.clear();
71800
72050
  return out;
71801
72051
  }
72052
+ /**
72053
+ * A word-level cap: a transition is the word changing. The memory is
72054
+ * written only when the slice CARRIED a word, for the same reason as the
72055
+ * booleans — a malformed slice must not become a baseline.
72056
+ */
72057
+ observeWord(deviceId, capName, slice, wordField) {
72058
+ const raw = slice[wordField];
72059
+ if (typeof raw !== "string" || raw.length === 0) return this.drop(deviceId, capName, "non-word-value");
72060
+ const key = `${deviceId}:${capName}`;
72061
+ const prior = this.lastWords.get(key);
72062
+ this.lastWords.set(key, raw);
72063
+ if (prior === void 0) return this.firstWordSighting(deviceId, capName, slice, raw);
72064
+ if (prior === raw) return this.drop(deviceId, capName, "no-change");
72065
+ return { changed: true };
72066
+ }
72067
+ /**
72068
+ * The booleans' first-sighting rule, on a word: the resting word seeds; any
72069
+ * other word is an event only when the slice's own `lastChangedAt` clears
72070
+ * the SAME floor `evaluateSensorEdge` uses, from the same two knobs.
72071
+ */
72072
+ firstWordSighting(deviceId, capName, slice, word) {
72073
+ if (word === WORD_LEVEL_INACTIVE[capName]) return this.drop(deviceId, capName, "baseline-seeded-inactive");
72074
+ const changedAt = slice["lastChangedAt"];
72075
+ const floor = Math.max(this.startedAtMs, this.now() - this.freshnessMs);
72076
+ if (!(typeof changedAt === "number" && Number.isFinite(changedAt) && changedAt > 0 && changedAt >= floor)) return this.drop(deviceId, capName, "baseline-seeded-stale-active");
72077
+ return { changed: true };
72078
+ }
72079
+ drop(deviceId, capName, reason) {
72080
+ this.noteDrop(deviceId, capName, reason);
72081
+ return {
72082
+ changed: false,
72083
+ reason
72084
+ };
72085
+ }
71802
72086
  noteDrop(deviceId, capName, reason) {
71803
72087
  const key = `${deviceId}:${capName}:${reason}`;
71804
72088
  const prior = this.drops.get(key);
@@ -78385,6 +78669,7 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
78385
78669
  runNow: (input) => center.runSummaryNow(input)
78386
78670
  },
78387
78671
  texts: center.textCatalogEditor,
78672
+ previewTexts: center.textCatalog,
78388
78673
  ...this.ncArtifactIndex !== null ? { artifacts: this.ncArtifactIndex } : {},
78389
78674
  artifactShelf: { remove: async (id) => await this.ncArtifactStore?.remove(id) ?? false },
78390
78675
  ruleStatus: {
@@ -85224,7 +85509,9 @@ var PipelineAnalyticsAddon = class PipelineAnalyticsAddon extends require_dist.B
85224
85509
  * A cap with no boolean level (`doorbell`, `button`, `lock-control`,
85225
85510
  * `presence`, `enum-sensor`, `event-emitter`) always passes — its ARRIVAL is
85226
85511
  * the event, and gating it would re-ship the silence of 2026-08-08. A cap
85227
- * that HAS one passes only when the boolean moved, in either direction.
85512
+ * that HAS one passes only when the boolean moved, in either direction — or,
85513
+ * for `cover`, whose level is its `state` word, only when the word changed
85514
+ * (a position tick or a same-word republish is a drop).
85228
85515
  *
85229
85516
  * A slice that is not an object at all is refused rather than defaulted to
85230
85517
  * `{}`: `evaluateSensorEdge` would read `non-boolean-value` from an empty