@camstack/addon-post-analysis 1.2.48 → 1.2.49

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 object, B as errMsg, C as buildEventKindDescriptor, E as defineCustomActions, F as readDeviceStateFrom, G as isDeviceScopedCap, H as DeviceType, I as subKindsOf, J as _enum, K as nodePin, L as vectorDimFromBase64, M as notificationRulesCapability, N as pipelineAnalyticsCapability, O as encodeVectorBase64, P as plateGalleryCapability, Q as number, R as videoclipsCapability, S as audioMetricsCapability, T as customAction, U as createEvent, V as BaseAddon, W as hydrateSchema, X as boolean, Y as array, Z as literal, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as addonWidgetsSourceCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as record, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as kebabToCamel, k as faceGalleryCapability, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, nt as unknown, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as sleep, r as EVENT_KIND_BY_CAP, rt as EventCategory, s as NC_TAXONOMY, t as BaseDevice, tt as string, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as cosineSimilarity, x as alarmPanelCapability, y as TrackSourceSchema, z as zoneAnalyticsCapability } from "../dist-4NhN1aPx.mjs";
1
+ import { $ as object, B as errMsg, C as buildEventKindDescriptor, E as defineCustomActions, F as readDeviceStateFrom, G as isDeviceScopedCap, H as DeviceType, I as subKindsOf, J as _enum, K as nodePin, L as vectorDimFromBase64, M as notificationRulesCapability, N as pipelineAnalyticsCapability, O as encodeVectorBase64, P as plateGalleryCapability, Q as number, R as videoclipsCapability, S as audioMetricsCapability, T as customAction, U as createEvent, V as BaseAddon, W as hydrateSchema, X as boolean, Y as array, Z as literal, _ as TimelapseRuleInputSchema, a as MACRO_LABELS, b as addonWidgetsSourceCapability, c as NcConditionDescriptorSchema, d as NcRuleSchema, et as record, f as NcSnoozeInputSchema, g as OpsLogEntrySchema, h as NcTaxonomySchema, i as EVENT_PAD_MS, j as kebabToCamel, k as faceGalleryCapability, l as NcRuleInputSchema, m as NcSnoozeSuppressedSchema, n as DEFAULT_EVENT_COLOR, nt as unknown, o as NC_CONDITION_CATALOG, p as NcSnoozeSchema, q as sleep, r as EVENT_KIND_BY_CAP, rt as EventCategory, s as NC_TAXONOMY, t as BaseDevice, tt as string, u as NcRulePatchSchema, v as TimelapseRuleSchema, w as cosineSimilarity, x as alarmPanelCapability, y as TrackSourceSchema, z as zoneAnalyticsCapability } from "../dist-DYOxxL_e.mjs";
2
2
  import { promises } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { createHmac, randomUUID, timingSafeEqual } from "node:crypto";
@@ -6806,6 +6806,34 @@ function isEdgeAwareNewBest(current, candidate, hysteresis) {
6806
6806
  //#endregion
6807
6807
  //#region src/pipeline-analytics/pipeline/label-utils.ts
6808
6808
  /**
6809
+ * Every macro class the pipeline can carry on a detection. A `label` exists to
6810
+ * say something FINER than the class — a species, a vehicle type, a plate read,
6811
+ * an identity — so a value drawn from this set is by definition not a label,
6812
+ * whatever produced it.
6813
+ *
6814
+ * Typed against the macro unions so a new macro class is a compile error here
6815
+ * rather than a silent hole. `package` is a first-level macro of the default
6816
+ * detector that predates `FirstLevelMacro`, hence the explicit member.
6817
+ */
6818
+ var MACRO_CLASSES = new Set([
6819
+ "person",
6820
+ "vehicle",
6821
+ "animal",
6822
+ "package",
6823
+ "face",
6824
+ "plate",
6825
+ "audio"
6826
+ ]);
6827
+ /**
6828
+ * Whether a class token is a macro class. EXACT match, no case folding: plate
6829
+ * OCR text is upper-case and identities are capitalised, so `PLATE` (a real
6830
+ * read) and `Person` (a real name) must survive while `plate` / `person` (the
6831
+ * taxonomy tokens) must not.
6832
+ */
6833
+ function isMacroClass(value) {
6834
+ return MACRO_CLASSES.has(value);
6835
+ }
6836
+ /**
6809
6837
  * Opaque internal label markers that carry no human-readable meaning and must
6810
6838
  * never surface on an event/track. The face-embedding (arcface) step pushes a
6811
6839
  * `embedding:<dim>d` marker (score 1.0) onto the parent's labels — it is a
@@ -6836,12 +6864,26 @@ function pickTopLabel(labels, excludeClass) {
6836
6864
  /**
6837
6865
  * Resolve the label to stamp on an ObjectEvent / track: an enrichment label
6838
6866
  * (identity, plate text) takes precedence; otherwise the raw detector class
6839
- * (`originalClass`) when it differs from the macro class; otherwise nothing.
6867
+ * (`originalClass`) when it is genuinely FINER than the macro class; otherwise
6868
+ * nothing.
6869
+ *
6870
+ * "Finer" is load-bearing, and `originalClass !== className` alone does not
6871
+ * establish it. The two fields come from different time bases — `className` is
6872
+ * the tracker's confidence-weighted LIFETIME class vote, `originalClass` is the
6873
+ * class of the detection matched into the track THIS frame — and outside
6874
+ * `verbosity === 'full'` the runner ships no `debug.originalClass` at all, so
6875
+ * the FrameProcessor substitutes `det.macroClass`. A person track that a single
6876
+ * animal detection matched into therefore arrived here as
6877
+ * `{ className: 'person', originalClass: 'animal' }` and got `label: 'animal'`
6878
+ * written on it: a COARSE class in the slot reserved for finer information.
6879
+ * A macro class is never an answer, so it is never a label.
6840
6880
  */
6841
6881
  function resolveDetectionLabel(input) {
6842
6882
  const top = pickTopLabel(input.labels, input.className);
6843
6883
  if (top) return top;
6844
- return input.originalClass && input.originalClass !== input.className ? input.originalClass : void 0;
6884
+ const raw = input.originalClass;
6885
+ if (raw === void 0 || raw === input.className || isMacroClass(raw)) return void 0;
6886
+ return raw;
6845
6887
  }
6846
6888
  var DEFAULT_ZONE_TRANSITION_CONFIG = {
6847
6889
  enterFrames: 2,
@@ -11834,9 +11876,24 @@ var TrackDetailDispatcher = class {
11834
11876
  dev.scheduler.onResult(req.trackId, req.stepId, topScore, Date.now());
11835
11877
  }
11836
11878
  /**
11837
- * Run the request once via the frameHandle, and — on a miss (null OR throw)
11838
- * — retry ONCE with a `cropJpeg` fallback when one can be captured. Returns
11839
- * the detail list, or `null` when both attempts fail to produce a result.
11879
+ * Walk the crop-source ladder for one request, stopping at the first rung that
11880
+ * produces a result. Returns the detail list, or `null` when every rung failed.
11881
+ *
11882
+ * 1. `frameHandle` — cheapest when it hits: no payload moves and the runner's
11883
+ * leaf children re-cut their ROI at native resolution. Tried first, always.
11884
+ * 2. `frameJpeg` — the freshest frame's FULL raster, fetched from the node that
11885
+ * owns the handle and handed to the runner UNCUT, so the rectangle still
11886
+ * comes from the single derivation (D52). This rung exists because rung 1 is
11887
+ * structurally unreachable for most dispatches: the decode worker's native
11888
+ * lease is 1.2 s and a tick + inference routinely lands outside it, while
11889
+ * the retained store holds the frame for 3 s.
11890
+ * 3. `cropJpeg` — the pre-cut tile. Last, because the caller decided its
11891
+ * rectangle and because in practice the same native miss that emptied rung 1
11892
+ * empties this one too.
11893
+ *
11894
+ * On D9/D18: the payload that crosses in rung 2 is a COMPRESSED JPEG fetched on
11895
+ * demand, per TRACK per dispatch (≤2 in flight per device, ~1 Hz) and only after
11896
+ * rung 1 missed — never a frame-rate stream, and never raw pixels.
11840
11897
  */
11841
11898
  async runOnce(deviceId, dev, req, frame) {
11842
11899
  const parent = {
@@ -11853,7 +11910,39 @@ var TrackDetailDispatcher = class {
11853
11910
  }, frame.nodeId);
11854
11911
  if (primary !== null) return primary.details;
11855
11912
  } catch (err) {
11856
- this.deps.logger.debug("detail primary call failed — trying crop fallback", {
11913
+ this.deps.logger.debug("detail primary call failed — trying frame fallback", {
11914
+ tags: { deviceId },
11915
+ meta: {
11916
+ trackId: req.trackId,
11917
+ stepId: req.stepId,
11918
+ error: String(err)
11919
+ }
11920
+ });
11921
+ }
11922
+ if (this.deps.captureFullFrameBase64 !== void 0 && frame.frameHandle !== void 0) try {
11923
+ const full = await this.deps.captureFullFrameBase64(frame);
11924
+ if (full !== null) if (full.width !== frame.frameWidth || full.height !== frame.frameHeight) this.deps.logger.warn("detail full-frame fallback refused — frame is not the bbox scale", {
11925
+ tags: { deviceId },
11926
+ meta: {
11927
+ trackId: req.trackId,
11928
+ stepId: req.stepId,
11929
+ frameWidth: full.width,
11930
+ frameHeight: full.height,
11931
+ bboxFrameWidth: frame.frameWidth,
11932
+ bboxFrameHeight: frame.frameHeight
11933
+ }
11934
+ });
11935
+ else {
11936
+ const derived = await this.deps.runDetailSubtree({
11937
+ deviceId,
11938
+ frameJpeg: full.base64,
11939
+ parent,
11940
+ steps
11941
+ }, frame.nodeId);
11942
+ if (derived !== null) return derived.details;
11943
+ }
11944
+ } catch (err) {
11945
+ this.deps.logger.debug("detail full-frame fallback call failed", {
11857
11946
  tags: { deviceId },
11858
11947
  meta: {
11859
11948
  trackId: req.trackId,
@@ -11890,12 +11979,14 @@ var TrackDetailDispatcher = class {
11890
11979
  const now = Date.now();
11891
11980
  if (now - dev.lastFailWarnAt < FAIL_WARN_THROTTLE_MS) return;
11892
11981
  dev.lastFailWarnAt = now;
11893
- this.deps.logger.warn("runDetailSubtree produced no result (frame + crop both missed)", {
11982
+ this.deps.logger.warn("runDetailSubtree produced no result (every crop source missed)", {
11894
11983
  tags: { deviceId },
11895
11984
  meta: {
11896
11985
  trackId: req.trackId,
11897
11986
  stepId: req.stepId,
11898
- reason: req.reason
11987
+ reason: req.reason,
11988
+ triedFullFrame: this.deps.captureFullFrameBase64 !== void 0,
11989
+ triedCropJpeg: this.deps.captureCropBase64 !== void 0
11899
11990
  }
11900
11991
  });
11901
11992
  }
@@ -18619,6 +18710,20 @@ function rowToSensorEvent(id, data) {
18619
18710
  function isRecord(x) {
18620
18711
  return x !== null && typeof x === "object" && !Array.isArray(x);
18621
18712
  }
18713
+ /**
18714
+ * May the interval this attempt consumed be given back?
18715
+ *
18716
+ * Pure, and deliberately takes the CURRENT clock as well as the mark: a rollback
18717
+ * is only correct while this attempt is still the one the gate is standing on. A
18718
+ * later frame's pending mark, or a snapshot that landed in between, has already
18719
+ * moved the reference forward and restoring an older one would re-open a window
18720
+ * that is legitimately closed.
18721
+ */
18722
+ function canRollbackSnapshot(mark, currentLastSnapshotAt) {
18723
+ if (mark === void 0) return false;
18724
+ if (mark.at !== currentLastSnapshotAt) return false;
18725
+ return mark.priorRollbacks < 3;
18726
+ }
18622
18727
  //#endregion
18623
18728
  //#region src/pipeline-analytics/store/recent-cursor.ts
18624
18729
  function encodeRecentCursor(cursor) {
@@ -18952,6 +19057,8 @@ var TrackStore = class {
18952
19057
  t.snapshots.push(snapshot);
18953
19058
  t.lastSnapshotAt = snapshot.timestamp;
18954
19059
  t.lastSnapshotBbox = { ...snapshot.position.bbox };
19060
+ delete t.snapshotPending;
19061
+ t.snapshotRollbacks = 0;
18955
19062
  }
18956
19063
  /**
18957
19064
  * Synchronously advance the periodic-snapshot gate reference (clock + bbox) at
@@ -18963,18 +19070,57 @@ var TrackStore = class {
18963
19070
  * snapshots. Mirrors the synchronous `lastFrameAtByTrack` advance for the
18964
19071
  * rolling `lastFrame`.
18965
19072
  *
18966
- * No rollback: if the write later fails the slot is simply lost (a rare dropped
18967
- * snapshot is preferable to a burst). `addSnapshot` re-stamps the same
18968
- * clock/bbox when the real snapshot lands, so the two stay consistent. No-op for
18969
- * an unknown/expired track (the active entry is dropped on expiry, so there is
18970
- * no separate map to leak).
19073
+ * The advance is REVERSIBLE. It used to be permanent — this docblock said
19074
+ * "if the write later fails the slot is simply lost" — which made `snapshot`
19075
+ * the only media kind with no retry of any shape while every sibling advanced
19076
+ * its bookkeeping from the ids that actually persisted. See
19077
+ * {@link rollbackSnapshotPending} and `pipeline/snapshot-retry.ts`.
19078
+ *
19079
+ * `addSnapshot` re-stamps the same clock/bbox when the real snapshot lands, so
19080
+ * the two stay consistent. No-op for an unknown/expired track (the active entry
19081
+ * is dropped on expiry, so there is no separate map to leak).
18971
19082
  */
18972
19083
  markSnapshotPending(trackId, timestamp, bbox) {
18973
19084
  const t = this.active.get(trackId);
18974
19085
  if (!t) return;
19086
+ t.snapshotPending = {
19087
+ at: timestamp,
19088
+ previous: {
19089
+ at: t.lastSnapshotAt,
19090
+ bbox: t.lastSnapshotBbox ? { ...t.lastSnapshotBbox } : void 0
19091
+ },
19092
+ priorRollbacks: t.snapshotRollbacks ?? 0
19093
+ };
18975
19094
  t.lastSnapshotAt = timestamp;
18976
19095
  t.lastSnapshotBbox = { ...bbox };
18977
19096
  }
19097
+ /**
19098
+ * The capture decided at `timestamp` did NOT land: restore the gate reference
19099
+ * it displaced so the next frame re-evaluates instead of waiting out an
19100
+ * interval that was never used. Returns whether the slot was given back.
19101
+ *
19102
+ * Refused — deliberately — when the attempt is no longer the one the gate
19103
+ * stands on (a snapshot landed, or a newer frame already marked its own
19104
+ * pending), and once the bounded run of non-landings is spent
19105
+ * ({@link SNAPSHOT_MAX_ROLLBACKS}): a camera whose frames are permanently
19106
+ * unresolvable must not re-decide a snapshot on every frame forever.
19107
+ */
19108
+ rollbackSnapshotPending(trackId, timestamp) {
19109
+ const t = this.active.get(trackId);
19110
+ if (!t) return false;
19111
+ const mark = t.snapshotPending;
19112
+ if (mark?.at !== timestamp) return false;
19113
+ if (!canRollbackSnapshot(mark, t.lastSnapshotAt)) {
19114
+ delete t.snapshotPending;
19115
+ return false;
19116
+ }
19117
+ t.lastSnapshotAt = mark.previous.at;
19118
+ if (mark.previous.bbox) t.lastSnapshotBbox = { ...mark.previous.bbox };
19119
+ else delete t.lastSnapshotBbox;
19120
+ t.snapshotRollbacks = mark.priorRollbacks + 1;
19121
+ delete t.snapshotPending;
19122
+ return true;
19123
+ }
18978
19124
  lastSnapshotAt(trackId) {
18979
19125
  return this.active.get(trackId)?.lastSnapshotAt ?? 0;
18980
19126
  }
@@ -21095,6 +21241,35 @@ function createNativeFrameTransport(deps) {
21095
21241
  };
21096
21242
  };
21097
21243
  const getNativeFullFrameRgb = async (handle, maxWidth) => (await fetchNativeFullFrameTiered(handle, maxWidth))?.frame ?? null;
21244
+ const getFullFrameJpegBase64 = async (handle, maxWidth) => {
21245
+ if (!pipelineRunnerApi?.getNativeCrop) return null;
21246
+ try {
21247
+ const full = await pipelineRunnerApi.getNativeCrop.query({
21248
+ handle,
21249
+ bbox: {
21250
+ x: 0,
21251
+ y: 0,
21252
+ w: 1,
21253
+ h: 1
21254
+ },
21255
+ maxWidth,
21256
+ encodeJpeg: true
21257
+ }, nodePin(handle.nodeId));
21258
+ if (!full?.jpeg || full.width <= 0 || full.height <= 0) return null;
21259
+ return {
21260
+ base64: full.jpeg,
21261
+ width: full.width,
21262
+ height: full.height,
21263
+ tier: full.tier === "ram-fullframe" ? "ram-fullframe" : "native"
21264
+ };
21265
+ } catch (err) {
21266
+ logger.debug("full-frame jpeg fetch failed", { meta: {
21267
+ nodeId: handle.nodeId,
21268
+ error: errMsg(err)
21269
+ } });
21270
+ return null;
21271
+ }
21272
+ };
21098
21273
  const cropMetricLogger = logger.child("NativeCrop");
21099
21274
  let nativeHits = 0;
21100
21275
  let nativeFallbacks = 0;
@@ -21145,6 +21320,7 @@ function createNativeFrameTransport(deps) {
21145
21320
  getRemoteFrame,
21146
21321
  getNativeFullFrameRgb,
21147
21322
  getNativeKeyFrameRgb: fetchNativeFullFrameTiered,
21323
+ getFullFrameJpegBase64,
21148
21324
  getNativeCropJpeg,
21149
21325
  captureCrop: createCaptureCrop({
21150
21326
  tryNativeCrop,
@@ -21509,23 +21685,47 @@ function wireSubscriptions(deps) {
21509
21685
  * `composeEventKinds` builds the `listEventKinds` payload for a camera:
21510
21686
  * (a) built-ins — motion + audio, always present;
21511
21687
  * (b) detection classes actually OBSERVED on the device (track history);
21512
- * (c) sensor kinds contributed by LINKED devices (device-manager
21513
- * `getLinkedDevices`), one descriptor per bound sensor cap present in
21514
- * the static `EVENT_KIND_BY_CAP` map. Binding-driven per linked device
21515
- * (`getBindings`) — never a global cap enumeration (D12).
21688
+ * (c) sensor kinds contributed by the camera's OWN bindings and by its
21689
+ * LINKED devices (device-manager `getLinkedDevices`), one descriptor per
21690
+ * bound sensor cap present in the static `EVENT_KIND_BY_CAP` map.
21691
+ * Binding-driven per device (`getBindings`) — never a global cap
21692
+ * enumeration (D12).
21516
21693
  *
21517
21694
  * `LinkedCamerasCache` is the ingest-side reverse index (sensor device →
21518
- * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
21519
- * cheap at bus rate.
21695
+ * linked camera ids, plus the camera id set) with a TTL, so the
21696
+ * `DeviceStateChanged` handler stays cheap at bus rate.
21520
21697
  */
21521
21698
  /**
21699
+ * Mapped sensor/control caps that a CAMERA must NOT contribute for ITSELF.
21700
+ *
21701
+ * A sensor cap bound on a camera makes the camera both the source and the
21702
+ * subject — a doorbell wrapper on camera 615, a native Reolink doorbell, a
21703
+ * camera-side button. Those have no other route into the per-camera event
21704
+ * substrate, so the camera includes itself.
21705
+ *
21706
+ * `motion` is the exception, and today the ONLY one: camera motion already
21707
+ * has a dedicated camera-native path (`MotionAnalysis` /
21708
+ * `MotionOnMotionChanged` → `MotionEvent` rows under the built-in `motion`
21709
+ * taxonomy lane). Every real camera provider registers a native `motion` cap
21710
+ * on the camera device, so self-inclusion would mint a SECOND, differently
21711
+ * named copy (`motion-sensor`) of every motion transition, at motion rate.
21712
+ *
21713
+ * Add a cap here only when the camera already emits that event through
21714
+ * another ingest path — never to reduce noise.
21715
+ */
21716
+ var SELF_CAMERA_EXCLUDED_CAPS = new Set(["motion"]);
21717
+ /** True when a camera may contribute `capName` for itself. */
21718
+ function selfCameraMayEmit(capName) {
21719
+ return !SELF_CAMERA_EXCLUDED_CAPS.has(capName);
21720
+ }
21721
+ /**
21522
21722
  * Full event-kind taxonomy a camera CAN emit (config-derived):
21523
21723
  * (a) motion built-in — always;
21524
21724
  * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
21525
21725
  * (c) audio macro + its subs when the classifier is enabled;
21526
21726
  * (d) package kinds when a package zone rule is enabled;
21527
- * (e) sensor/control kinds from LINKED devices (binding-driven), deduped
21528
- * per (kind, source deviceId).
21727
+ * (e) sensor/control kinds from the CAMERA'S OWN bindings and from its
21728
+ * LINKED devices (binding-driven), deduped per (kind, source deviceId).
21529
21729
  * Every descriptor carries parentKind/level/iconId/labelKey via the single
21530
21730
  * taxonomy dictionary — no color/icon is declared here.
21531
21731
  */
@@ -21560,6 +21760,7 @@ async function composeEventKinds(deps, deviceId) {
21560
21760
  const audioP = attempt(deps, "audioEnabled", () => deps.audioEnabled(deviceId));
21561
21761
  const packageP = attempt(deps, "packageZonesEnabled", async () => deps.packageZonesEnabled ? deps.packageZonesEnabled(deviceId) : false);
21562
21762
  const linkedP = attempt(deps, "getLinkedDevices", () => deps.linkedDevices.getLinkedDevices({ deviceId }));
21763
+ const selfBindingsP = attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId }));
21563
21764
  pushKind("motion");
21564
21765
  for (const macro of await macrosP ?? []) {
21565
21766
  pushKind(macro);
@@ -21576,26 +21777,30 @@ async function composeEventKinds(deps, deviceId) {
21576
21777
  const devices = (await linkedP)?.devices ?? [];
21577
21778
  const bindingsPerDevice = await Promise.all(devices.map((linked) => attempt(deps, "getBindings", () => deps.bindings.getBindings({ deviceId: linked.deviceId }))));
21578
21779
  const seen = /* @__PURE__ */ new Set();
21579
- for (const [index, linked] of devices.entries()) {
21580
- const entries = bindingsPerDevice[index]?.entries;
21581
- if (entries === void 0) continue;
21780
+ const pushSensorKinds = (sourceDeviceId, entries, isSelf) => {
21781
+ if (entries === void 0) return;
21582
21782
  for (const { capName } of entries) {
21783
+ if (isSelf && !selfCameraMayEmit(capName)) continue;
21583
21784
  const descriptor = EVENT_KIND_BY_CAP[capName];
21584
21785
  if (descriptor === void 0) continue;
21585
- const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
21786
+ const dedupeKey = `${descriptor.kind}:${sourceDeviceId}`;
21586
21787
  if (seen.has(dedupeKey)) continue;
21587
21788
  seen.add(dedupeKey);
21588
21789
  pushKind(descriptor.kind, {
21589
21790
  capName,
21590
- deviceId: linked.deviceId
21791
+ deviceId: sourceDeviceId
21591
21792
  });
21592
21793
  }
21593
- }
21794
+ };
21795
+ pushSensorKinds(deviceId, (await selfBindingsP)?.entries, true);
21796
+ for (const [index, linked] of devices.entries()) pushSensorKinds(linked.deviceId, bindingsPerDevice[index]?.entries, false);
21594
21797
  return out;
21595
21798
  }
21596
21799
  var DEFAULT_CACHE_TTL_MS = 6e4;
21597
21800
  /**
21598
- * TTL-cached reverse index: source deviceId → camera ids it is linked to.
21801
+ * TTL-cached reverse index: source deviceId → camera ids it is linked to,
21802
+ * plus the camera id SET the index was built from (so the ingest can ask
21803
+ * "is this device itself a camera" without a second enumeration).
21599
21804
  * Rebuilds lazily (single-flight) when stale, so the `DeviceStateChanged`
21600
21805
  * handler pays one map lookup per event in the common case.
21601
21806
  */
@@ -21603,6 +21808,8 @@ var LinkedCamerasCache = class {
21603
21808
  deps;
21604
21809
  ttlMs;
21605
21810
  index = /* @__PURE__ */ new Map();
21811
+ /** Every camera id seen by the last successful build. */
21812
+ cameras = /* @__PURE__ */ new Set();
21606
21813
  /** Ms timestamp of the last build; null = never built / invalidated. */
21607
21814
  builtAt = null;
21608
21815
  building = null;
@@ -21612,23 +21819,37 @@ var LinkedCamerasCache = class {
21612
21819
  }
21613
21820
  /** Camera ids linked to `sourceDeviceId` ([] when none). */
21614
21821
  async camerasFor(sourceDeviceId, nowMs = Date.now()) {
21615
- if (this.builtAt === null || nowMs - this.builtAt >= this.ttlMs) {
21616
- this.building ??= this.rebuild(nowMs).finally(() => {
21617
- this.building = null;
21618
- });
21619
- await this.building;
21620
- }
21822
+ await this.ensureFresh(nowMs);
21621
21823
  return this.index.get(sourceDeviceId) ?? [];
21622
21824
  }
21825
+ /**
21826
+ * True when `deviceId` is ITSELF a camera. Answered off the same build as
21827
+ * {@link camerasFor} — a sensor cap bound on a camera needs both facts in
21828
+ * the same event, and a second enumeration would double the cost of the
21829
+ * ingest path for nothing.
21830
+ */
21831
+ async isCamera(deviceId, nowMs = Date.now()) {
21832
+ await this.ensureFresh(nowMs);
21833
+ return this.cameras.has(deviceId);
21834
+ }
21623
21835
  /** Drop the cached index (e.g. on link-topology change events). */
21624
21836
  invalidate() {
21625
21837
  this.builtAt = null;
21626
21838
  }
21627
21839
  /** Test/maintenance hook: replace the index directly. */
21628
- seed(index, builtAt) {
21840
+ seed(index, builtAt, cameraIds = []) {
21629
21841
  this.index = new Map(index);
21842
+ this.cameras = new Set(cameraIds);
21630
21843
  this.builtAt = builtAt;
21631
21844
  }
21845
+ /** Rebuild (single-flight) when never built or past the TTL. */
21846
+ async ensureFresh(nowMs) {
21847
+ if (this.builtAt !== null && nowMs - this.builtAt < this.ttlMs) return;
21848
+ this.building ??= this.rebuild(nowMs).finally(() => {
21849
+ this.building = null;
21850
+ });
21851
+ await this.building;
21852
+ }
21632
21853
  async rebuild(nowMs) {
21633
21854
  try {
21634
21855
  const cameraIds = await this.deps.cameras.listCameraIds();
@@ -21645,6 +21866,7 @@ var LinkedCamerasCache = class {
21645
21866
  this.deps.onError?.("getLinkedDevices", err);
21646
21867
  }
21647
21868
  this.index = next;
21869
+ this.cameras = new Set(cameraIds);
21648
21870
  this.builtAt = nowMs;
21649
21871
  } catch (err) {
21650
21872
  this.deps.onError?.("listCameraIds", err);
@@ -21653,16 +21875,25 @@ var LinkedCamerasCache = class {
21653
21875
  }
21654
21876
  };
21655
21877
  /**
21656
- * One `DeviceStateChanged` → N history rows (one per linked camera). The
21657
- * EVENT_KIND_BY_CAP gate exits first so non-sensor cap churn costs one map
21658
- * lookup. Returns the number of rows inserted (0 when unmapped/unlinked).
21878
+ * One `DeviceStateChanged` → N history rows, one per camera the event belongs
21879
+ * to: the emitting device's LINKED cameras, plus the emitting device ITSELF
21880
+ * when it is a camera carrying the sensor cap (a doorbell wrapper bound on
21881
+ * camera 615 — see `SELF_CAMERA_EXCLUDED_CAPS` for the one cap that is not
21882
+ * self-ingested). The EVENT_KIND_BY_CAP gate exits first so non-sensor cap
21883
+ * churn costs one map lookup. Returns the number of rows inserted (0 when
21884
+ * unmapped, or when the device belongs to no camera at all).
21659
21885
  * Telemetry-lossy by design (D8) — inserts are best-effort.
21660
21886
  */
21661
21887
  async function ingestSensorStateChange(deps, data, timestamp) {
21662
21888
  const descriptor = EVENT_KIND_BY_CAP[data.capName];
21663
21889
  if (descriptor === void 0) return 0;
21664
- const cameraIds = await deps.cache.camerasFor(data.deviceId);
21665
- if (cameraIds.length === 0) return 0;
21890
+ const linkedCameraIds = await deps.cache.camerasFor(data.deviceId);
21891
+ const isCamera = await deps.cache.isCamera(data.deviceId);
21892
+ const cameraIds = isCamera && selfCameraMayEmit(data.capName) && !linkedCameraIds.includes(data.deviceId) ? [data.deviceId, ...linkedCameraIds] : linkedCameraIds;
21893
+ if (cameraIds.length === 0) {
21894
+ deps.onDropped?.(isCamera ? "camera-native-cap" : "no-camera", descriptor.kind);
21895
+ return 0;
21896
+ }
21666
21897
  const slice = data.slice;
21667
21898
  const value = slice !== null && slice !== void 0 && typeof slice === "object" && !Array.isArray(slice) ? slice : null;
21668
21899
  const makeId = deps.makeId ?? (() => `pa-sensor-${randomUUID()}`);
@@ -21798,6 +22029,8 @@ var SNAPSHOT_MAX_WIDTH = 960;
21798
22029
  /** Bound the once-per-owner display-fallback memo so a long-lived process never
21799
22030
  * leaks it. */
21800
22031
  var MAX_LOGGED_DISPLAY_FALLBACKS = 5e3;
22032
+ /** One `pinned-frame resolve missed` line per device per minute. */
22033
+ var PINNED_MISS_WARN_THROTTLE_MS = 6e4;
21801
22034
  /**
21802
22035
  * Map a detection-frame pixel box (`fromW`×`fromH`, the ≤640 raster the tracker
21803
22036
  * ran on) onto the native full frame (`toW`×`toH`, the 960-downscaled native
@@ -21883,6 +22116,8 @@ var EventMediaDispatcher = class {
21883
22116
  /** Once-per-owner (`${eventId}:${kind}`) memo for the DISPLAY-crop fallback
21884
22117
  * info log — see {@link logDisplayFallbackOnce}. */
21885
22118
  loggedDisplayFallbacks = /* @__PURE__ */ new Set();
22119
+ /** Per-device throttle for {@link warnPinnedFrameMiss}. */
22120
+ lastPinnedMissWarnAt = /* @__PURE__ */ new Map();
21886
22121
  constructor(deps) {
21887
22122
  this.deps = deps;
21888
22123
  this.sharedNativeFullFrame = createSharedFrameResolver((handle) => this.deps.getNativeFullFrameRgb(handle, SNAPSHOT_MAX_WIDTH));
@@ -21896,30 +22131,48 @@ var EventMediaDispatcher = class {
21896
22131
  * by which time the handle's ring slot is usually recycled and the by-handle
21897
22132
  * resolve in {@link captureForFrame} finds nothing (the 2026-07-23 boxed-tile
21898
22133
  * regression). ~0.7MB per pinned frame, freed with the batch — bounded by the
21899
- * lane. Returns `null` on a genuine live-window miss (logged at debug — the
21900
- * caller then omits `pinnedFrame` and captureForFrame's by-handle resolve,
21901
- * WARN-logged on failure, remains the fallback).
22134
+ * lane. Returns `null` on a genuine live-window miss.
22135
+ *
22136
+ * Both miss paths are WARN, throttled to one line per device per minute. They
22137
+ * are the FIRST half of a pair whose second half (`capture batch lost`) has
22138
+ * always been a warn: a batch that lost its frame is reported with
22139
+ * `pinned: false`, and until 2026-08-07 the reason the pin itself failed was
22140
+ * either at debug (the throw) or nowhere at all (the null return). 383 batch
22141
+ * losses in 3 h had `pinned: false` on 100% of them and nothing said why.
21902
22142
  */
21903
22143
  async resolvePinnedFrame(deviceId, frameHandle) {
21904
22144
  try {
21905
22145
  const decoded = await resolveFrame(frameHandle, { getRemoteFrame: this.deps.getRemoteFrame });
21906
- if (!decoded) return null;
22146
+ if (!decoded) {
22147
+ this.warnPinnedFrameMiss(deviceId, frameHandle, "recycled-before-pin");
22148
+ return null;
22149
+ }
21907
22150
  return {
21908
22151
  ...decoded,
21909
22152
  data: Buffer.from(decoded.data)
21910
22153
  };
21911
22154
  } catch (err) {
21912
- this.deps.logger.debug("event media: pinned-frame resolve threw (live window)", {
21913
- tags: { deviceId },
21914
- meta: {
21915
- deviceId,
21916
- shmId: frameHandle.shmId,
21917
- error: String(err)
21918
- }
21919
- });
22155
+ this.warnPinnedFrameMiss(deviceId, frameHandle, "threw", String(err));
21920
22156
  return null;
21921
22157
  }
21922
22158
  }
22159
+ /** One `pinned-frame resolve missed` line per device per minute. Throttled
22160
+ * because it fires per capture batch, and the batch's own loss line is not. */
22161
+ warnPinnedFrameMiss(deviceId, frameHandle, reason, error) {
22162
+ const now = Date.now();
22163
+ if (now - (this.lastPinnedMissWarnAt.get(deviceId) ?? 0) < PINNED_MISS_WARN_THROTTLE_MS) return;
22164
+ this.lastPinnedMissWarnAt.set(deviceId, now);
22165
+ this.deps.logger.warn("event media: pinned-frame resolve missed (live window)", {
22166
+ tags: { deviceId },
22167
+ meta: {
22168
+ reason,
22169
+ shmId: frameHandle.shmId,
22170
+ handle: `${frameHandle.shmId}#${frameHandle.slot}#${frameHandle.seq}`,
22171
+ handleNodeId: frameHandle.nodeId,
22172
+ ...error !== void 0 ? { error } : {}
22173
+ }
22174
+ });
22175
+ }
21923
22176
  /**
21924
22177
  * WARN-log a whole-batch capture abort: every early return in
21925
22178
  * {@link captureForFrame} loses ALL of the frame's targets at once —
@@ -22772,13 +23025,19 @@ var SyntheticTrackMaterializer = class {
22772
23025
  * Fetch the linked camera's snapshot under a bounded timeout. A hanging
22773
23026
  * snapshot cap is skipped (returns null + warns) so the track still lands;
22774
23027
  * a null / failed fetch is likewise reported and skipped.
23028
+ *
23029
+ * Deliberately NOT `force`. `snapshot.cap.ts` defines `force` as the
23030
+ * operator's refresh button — *"On battery cams this WILL wake the camera —
23031
+ * accept the cost only when the user explicitly asks for it"* — and a
23032
+ * synthetic track is materialised from an EVENT, with nobody asking. Forcing
23033
+ * here walked straight past the snapshot addon's sleep gate and woke a
23034
+ * sleeping battery camera per event (roadmap 4f). Without it the cache
23035
+ * answers: ≤10 s old on a mains camera, and on a battery camera the
23036
+ * intentionally stale-but-honest frame that costs no wake.
22775
23037
  */
22776
23038
  async fetchSnapshot(cameraId) {
22777
23039
  try {
22778
- const snap = await withTimeout(this.deps.snapshot.getSnapshot({
22779
- deviceId: cameraId,
22780
- force: true
22781
- }), this.snapshotTimeoutMs);
23040
+ const snap = await withTimeout(this.deps.snapshot.getSnapshot({ deviceId: cameraId }), this.snapshotTimeoutMs);
22782
23041
  if (snap === null) this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
22783
23042
  return snap;
22784
23043
  } catch (err) {
@@ -25760,7 +26019,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
25760
26019
  logger: logger.child("EventMediaDispatcher")
25761
26020
  });
25762
26021
  this.buildRecognizers(api, logger, stores, transport);
25763
- this.buildDetailDispatcher(api, logger);
26022
+ this.buildDetailDispatcher(api, logger, transport);
25764
26023
  const capProviders = this.buildAnalyticsProviders(api, logger);
25765
26024
  await this.buildNotificationCenter(api, logger, stores);
25766
26025
  await this.serveEventMediaDataPlane();
@@ -26208,7 +26467,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26208
26467
  * demand via `pipelineRunner.runDetailSubtree`. The dispatcher owns the
26209
26468
  * per-device schedulers, concurrency cap, and 1 s tick; result routing +
26210
26469
  * the pinned call + crop fallback are injected here. */
26211
- buildDetailDispatcher(api, logger) {
26470
+ buildDetailDispatcher(api, logger, transport) {
26212
26471
  const runnerApi = api.pipelineRunner;
26213
26472
  this.detailDispatcher = new TrackDetailDispatcher({
26214
26473
  logger: logger.child("DetailDispatcher"),
@@ -26218,6 +26477,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26218
26477
  return runnerApi.runDetailSubtree.mutate(input);
26219
26478
  },
26220
26479
  routeResults: (deviceId, trackId, details, frame) => this.routeDetailResults(deviceId, trackId, details, frame),
26480
+ captureFullFrameBase64: async (frame) => {
26481
+ if (frame.frameHandle === void 0) return null;
26482
+ const full = await transport.getFullFrameJpegBase64(frame.frameHandle, frame.frameWidth);
26483
+ return full === null ? null : {
26484
+ base64: full.base64,
26485
+ width: full.width,
26486
+ height: full.height
26487
+ };
26488
+ },
26221
26489
  captureCropBase64: async (frame) => {
26222
26490
  if (frame.frameHandle === void 0 || !this.captureCrop) return null;
26223
26491
  const buf = await this.captureCrop(frame.frameHandle, { ...frame.bbox }, frame.frameWidth, frame.frameHeight, DETAIL_FALLBACK_CROP_PADDING);
@@ -26842,12 +27110,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26842
27110
  inPrevActive: false,
26843
27111
  positionsCount: positionsCountById.get(id) ?? 1
26844
27112
  }) === "resurrection") {
26845
- log.info("track resumed", { meta: {
26846
- trackId: id,
26847
- className: t.className,
26848
- source,
26849
- resurrected: true
26850
- } });
27113
+ log.info("track resumed", {
27114
+ tags: { deviceId },
27115
+ meta: {
27116
+ trackId: id,
27117
+ className: t.className,
27118
+ source,
27119
+ resurrected: true
27120
+ }
27121
+ });
26851
27122
  if (this.eventMediaDispatcher) {
26852
27123
  this.residents.setLastFrameAt(deviceId, id, result.timestamp);
26853
27124
  if (!this.residents.isFirstFrameLanded(id) && !this.residents.isFirstFramePending(id)) this.residents.markFirstFramePending(deviceId, id);
@@ -26982,10 +27253,13 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
26982
27253
  let lostTrackCount = 0;
26983
27254
  for (const id of prevIds) if (!currentTrackIds.has(id)) {
26984
27255
  lostTrackCount += 1;
26985
- log.info("track lost", { meta: {
26986
- trackId: id,
26987
- source
26988
- } });
27256
+ log.info("track lost", {
27257
+ tags: { deviceId },
27258
+ meta: {
27259
+ trackId: id,
27260
+ source
27261
+ }
27262
+ });
26989
27263
  }
26990
27264
  this.suppressedBirths.retain(key, currentTrackIds);
26991
27265
  this.deferredBirths.retain(key, currentTrackIds);
@@ -27222,6 +27496,19 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
27222
27496
  this.residents.markFirstFrameLanded(deviceId, trackId);
27223
27497
  }
27224
27498
  for (const trackId of res.lastFrameTrackIds) this.residents.setLastFrameAt(deviceId, trackId, dispatchTimestamp);
27499
+ const landedSnapshots = new Set(res.storedSnapshots.map((s) => s.trackId));
27500
+ for (const target of snapshotTargets) {
27501
+ if (!target.appendSnapshot || landedSnapshots.has(target.trackId)) continue;
27502
+ const gaveBack = this.trackStore?.rollbackSnapshotPending(target.trackId, target.timestamp);
27503
+ log.warn("snapshot capture did not land", {
27504
+ tags: { deviceId },
27505
+ meta: {
27506
+ trackId: target.trackId,
27507
+ timestamp: target.timestamp,
27508
+ intervalReturned: gaveBack === true
27509
+ }
27510
+ });
27511
+ }
27225
27512
  })
27226
27513
  }).catch(() => {});
27227
27514
  }
@@ -29020,8 +29307,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29020
29307
  }
29021
29308
  /**
29022
29309
  * Sensor-event ingest handler — `DeviceStateChanged` of a device exposing a
29023
- * mapped sensor cap. Resolves the linked-camera set through the TTL cache
29024
- * and inserts ONE row per linked camera. Best-effort (telemetry-lossy).
29310
+ * mapped sensor cap. Resolves the camera set through the TTL cache (the
29311
+ * device's LINKED cameras, plus the device itself when it is a camera
29312
+ * carrying the cap) and inserts ONE row per camera. Best-effort
29313
+ * (telemetry-lossy).
29314
+ *
29315
+ * D8/D11 boundary, stated plainly: the ride here is the LOSSY
29316
+ * `DeviceStateChanged` bus, and there is no reconcile that replays a missed
29317
+ * press from the durable `doorbell.getStatus.lastPressedAt`. A dropped bus
29318
+ * event is a missing history row and a missing notification — never wrong
29319
+ * durable state.
29025
29320
  */
29026
29321
  async handleSensorStateChanged(data, timestamp) {
29027
29322
  const store = this.sensorEventStore;
@@ -29031,7 +29326,8 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29031
29326
  await ingestSensorStateChange({
29032
29327
  sink: store,
29033
29328
  cache,
29034
- onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev)
29329
+ onPersisted: (ev) => this.notificationCenter?.onSensorEventPersisted(ev),
29330
+ onDropped: (reason, kind) => this.logSensorIngestDrop(data, reason, kind)
29035
29331
  }, data, timestamp);
29036
29332
  } catch (err) {
29037
29333
  this.ctx.logger.warn("sensor-event ingest failed", {
@@ -29047,7 +29343,16 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29047
29343
  if (materializer === null || descriptor === void 0) return;
29048
29344
  try {
29049
29345
  const cameraIds = await cache.camerasFor(data.deviceId);
29050
- if (cameraIds.length === 0) return;
29346
+ if (cameraIds.length === 0) {
29347
+ this.ctx.logger.debug("sensor synthetic-track projection skipped — no linked camera", {
29348
+ tags: { deviceId: data.deviceId },
29349
+ meta: {
29350
+ capName: data.capName,
29351
+ kind: descriptor.kind
29352
+ }
29353
+ });
29354
+ return;
29355
+ }
29051
29356
  const producingDeviceName = await this.resolveProducingDeviceName(data.deviceId);
29052
29357
  for (const cameraId of cameraIds) await materializer.materialize({
29053
29358
  cameraId,
@@ -29068,6 +29373,35 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
29068
29373
  }
29069
29374
  }
29070
29375
  /**
29376
+ * Say out loud that a MAPPED sensor cap ingested nothing, and why.
29377
+ *
29378
+ * BOTH branches are `debug`, deliberately. After the self-camera fix neither
29379
+ * is a surprise any more — they are the two designed steady states, and both
29380
+ * ride at bus rate:
29381
+ *
29382
+ * - `camera-native-cap` is EVERY camera's `motion` slice. Every provider
29383
+ * binds a native `motion` cap on the camera device and motion churns
29384
+ * continuously (camera 590 flaps at ~2 Hz), so an `info` here would be a
29385
+ * log flood measured in millions of lines a day — its own outage.
29386
+ * - `no-camera` is every sensor in the fleet that no camera links.
29387
+ *
29388
+ * What must never happen again is the branch being INVISIBLE: a doorbell
29389
+ * press that ingested nothing produced not one line for months. A debug line
29390
+ * carrying `deviceId`, `capName`, `kind` and `reason` answers "why is 615
29391
+ * dark" the moment anyone asks it.
29392
+ */
29393
+ logSensorIngestDrop(data, reason, kind) {
29394
+ const message = reason === "camera-native-cap" ? "sensor-event ingest produced NO row — the camera already emits this cap through its own path" : "sensor-event ingest produced NO row — the device is not a camera and is linked to none";
29395
+ this.ctx.logger.debug(message, {
29396
+ tags: { deviceId: data.deviceId },
29397
+ meta: {
29398
+ capName: data.capName,
29399
+ kind,
29400
+ reason
29401
+ }
29402
+ });
29403
+ }
29404
+ /**
29071
29405
  * Resolve the NAME of a device (the linked sensor/control that produced a
29072
29406
  * synthetic event) via the device-manager cap. Best-effort — returns
29073
29407
  * undefined on any lookup failure or an unknown device so the synthetic