@camstack/addon-post-analysis 1.1.33 → 1.1.35

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 { C as string, S as object, _ as createEvent, a as cosineSimilarity, b as boolean, d as plateGalleryCapability, f as videoclipsCapability, g as DeviceType, h as BaseAddon, i as audioMetricsCapability, l as nodePin, m as errMsg, n as EVENT_PAD_MS, p as zoneAnalyticsCapability, r as addonWidgetsSourceCapability, s as faceGalleryCapability, t as EVENT_KIND_BY_CAP, u as pipelineAnalyticsCapability, v as hydrateSchema, w as EventCategory, x as number, y as array } from "../dist-CA0GikiM.mjs";
1
+ import { C as hydrateSchema, D as object, E as number, O as string, S as createEvent, T as boolean, _ as videoclipsCapability, a as OpsLogEntrySchema, b as BaseAddon, c as buildEventKindDescriptor, d as faceGalleryCapability, g as subKindsOf, h as plateGalleryCapability, i as MACRO_LABELS, k as EventCategory, l as cosineSimilarity, m as pipelineAnalyticsCapability, n as EVENT_KIND_BY_CAP, o as addonWidgetsSourceCapability, p as nodePin, r as EVENT_PAD_MS, s as audioMetricsCapability, t as DEFAULT_EVENT_COLOR, v as zoneAnalyticsCapability, w as array, x as DeviceType, y as errMsg } from "../dist-8DTQLWKO.mjs";
2
2
  import { randomUUID } from "node:crypto";
3
3
  import sharp from "sharp";
4
4
  //#region src/pipeline-analytics/videoclips-provider.ts
@@ -160,13 +160,13 @@ var CLASS_RANK_VEHICLE = .8;
160
160
  var CLASS_RANK_ANIMAL = .5;
161
161
  var CLASS_RANK_DEFAULT = .25;
162
162
  var PERSON_CLASSES = new Set(["person", "face"]);
163
- var VEHICLE_CLASSES$1 = new Set([
163
+ var VEHICLE_CLASSES = new Set([
164
164
  "vehicle",
165
165
  "car",
166
166
  "truck",
167
167
  "bus"
168
168
  ]);
169
- var ANIMAL_CLASSES$1 = new Set([
169
+ var ANIMAL_CLASSES = new Set([
170
170
  "animal",
171
171
  "dog",
172
172
  "cat"
@@ -182,8 +182,8 @@ function clamp01(x) {
182
182
  function classRank(className) {
183
183
  const c = className.toLowerCase();
184
184
  if (PERSON_CLASSES.has(c)) return 1;
185
- if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
186
- if (ANIMAL_CLASSES$1.has(c)) return CLASS_RANK_ANIMAL;
185
+ if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
186
+ if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
187
187
  return CLASS_RANK_DEFAULT;
188
188
  }
189
189
  /**
@@ -3314,6 +3314,10 @@ var TRACKS_COLUMNS = [
3314
3314
  name: "label",
3315
3315
  type: "TEXT"
3316
3316
  },
3317
+ {
3318
+ name: "source",
3319
+ type: "TEXT"
3320
+ },
3317
3321
  {
3318
3322
  name: "firstSeen",
3319
3323
  type: "INTEGER",
@@ -4004,6 +4008,34 @@ var TrackStore = class {
4004
4008
  const row = records[0];
4005
4009
  return this.rowToTrack(row.id, row.data);
4006
4010
  }
4011
+ /**
4012
+ * Persist a SYNTHETIC track (a sensor/control event projected into the
4013
+ * unified track store). Unlike `persistCompleted` there is no live in-RAM
4014
+ * state and no trajectory envelope (synthetic tracks carry no positions) —
4015
+ * the row is written directly. `source: 'sensor'` marks it so spatial
4016
+ * consumers skip it; the existing time-based clustering joins it by
4017
+ * timestamp like any other track.
4018
+ */
4019
+ async persistSyntheticTrack(t) {
4020
+ await this.store.set.mutate({
4021
+ collection: TRACKS_COLLECTION,
4022
+ key: t.trackId,
4023
+ value: {
4024
+ deviceId: t.deviceId,
4025
+ className: t.className,
4026
+ ...t.label !== void 0 ? { label: t.label } : {},
4027
+ source: t.source ?? "sensor",
4028
+ firstSeen: t.firstSeen,
4029
+ lastSeen: t.lastSeen,
4030
+ positions: [...t.positions],
4031
+ snapshots: [...t.snapshots],
4032
+ zonesVisited: [...t.zonesVisited],
4033
+ ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
4034
+ totalDistance: t.totalDistance,
4035
+ state: t.state
4036
+ }
4037
+ });
4038
+ }
4007
4039
  async persistCompleted(t) {
4008
4040
  const dims = this.frameDims?.(t.deviceId);
4009
4041
  const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
@@ -4014,6 +4046,7 @@ var TrackStore = class {
4014
4046
  deviceId: t.deviceId,
4015
4047
  className: t.className,
4016
4048
  ...t.label !== void 0 ? { label: t.label } : {},
4049
+ ...t.source !== void 0 ? { source: t.source } : {},
4017
4050
  firstSeen: t.firstSeen,
4018
4051
  lastSeen: t.lastSeen,
4019
4052
  positions: [...t.positions],
@@ -4052,6 +4085,7 @@ var TrackStore = class {
4052
4085
  const zones = data["zonesVisited"] ?? [];
4053
4086
  const classes = data["classes"];
4054
4087
  const label = data["label"];
4088
+ const source = data["source"];
4055
4089
  const importance = data["importance"];
4056
4090
  const bestEventId = data["bestEventId"];
4057
4091
  const importanceReason = data["importanceReason"];
@@ -4071,6 +4105,7 @@ var TrackStore = class {
4071
4105
  deviceId: Number(data["deviceId"]),
4072
4106
  className: String(data["className"]),
4073
4107
  ...typeof label === "string" ? { label } : {},
4108
+ ...source === "sensor" || source === "pipeline" ? { source } : {},
4074
4109
  firstSeen: Number(data["firstSeen"]),
4075
4110
  lastSeen: Number(data["lastSeen"]),
4076
4111
  positions,
@@ -4393,6 +4428,48 @@ var MediaStore = class {
4393
4428
  }
4394
4429
  return removed;
4395
4430
  }
4431
+ /**
4432
+ * On-demand footprint aggregation for the events-management UI: sum
4433
+ * `sizeBytes` and count rows per device across the whole media collection.
4434
+ * Pages by `id` (offset-based) so a large collection never loads at once.
4435
+ * Metadata rows are small (no blob) — the image bytes live on the storage
4436
+ * provider and are counted via the persisted `sizeBytes` column.
4437
+ */
4438
+ async footprintByDevice() {
4439
+ const PAGE = 1e3;
4440
+ const out = /* @__PURE__ */ new Map();
4441
+ let offset = 0;
4442
+ for (;;) {
4443
+ const rows = await this.store.query.query({
4444
+ collection: MEDIA_COLLECTION,
4445
+ filter: {
4446
+ orderBy: {
4447
+ field: "id",
4448
+ direction: "asc"
4449
+ },
4450
+ limit: PAGE,
4451
+ offset
4452
+ }
4453
+ });
4454
+ if (rows.length === 0) break;
4455
+ for (const row of rows) {
4456
+ const data = row.data;
4457
+ const deviceId = Number(data["deviceId"]);
4458
+ const sizeBytes = Number(data["sizeBytes"]);
4459
+ if (!Number.isFinite(deviceId)) continue;
4460
+ const acc = out.get(deviceId) ?? {
4461
+ bytes: 0,
4462
+ rows: 0
4463
+ };
4464
+ acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
4465
+ acc.rows += 1;
4466
+ out.set(deviceId, acc);
4467
+ }
4468
+ if (rows.length < PAGE) break;
4469
+ offset += PAGE;
4470
+ }
4471
+ return out;
4472
+ }
4396
4473
  /** Move a single media entry from one owner to another.
4397
4474
  * The new copy is written before the old one is removed.
4398
4475
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -4754,12 +4831,12 @@ var EventStore = class {
4754
4831
  collection: MOTION_EVENTS_COLLECTION,
4755
4832
  filter: this.buildFilter(q)
4756
4833
  });
4757
- if (q.projection === "slim") return rows.map((r) => slimMotion(r.id, stripNulls(r.data)));
4834
+ if (q.projection === "slim") return rows.map((r) => slimMotion(r.id, stripNulls$1(r.data)));
4758
4835
  return rows.map((r) => {
4759
4836
  return {
4760
4837
  id: r.id,
4761
4838
  kind: "motion",
4762
- ...stripNulls(r.data)
4839
+ ...stripNulls$1(r.data)
4763
4840
  };
4764
4841
  });
4765
4842
  }
@@ -4773,12 +4850,12 @@ var EventStore = class {
4773
4850
  collection: OBJECT_EVENTS_COLLECTION,
4774
4851
  filter
4775
4852
  });
4776
- if (q.projection === "slim") return rows.map((r) => slimObject(r.id, stripNulls(r.data)));
4853
+ if (q.projection === "slim") return rows.map((r) => slimObject(r.id, stripNulls$1(r.data)));
4777
4854
  return rows.map((r) => {
4778
4855
  return {
4779
4856
  id: r.id,
4780
4857
  kind: "object",
4781
- ...stripNulls(r.data)
4858
+ ...stripNulls$1(r.data)
4782
4859
  };
4783
4860
  });
4784
4861
  }
@@ -4787,12 +4864,12 @@ var EventStore = class {
4787
4864
  collection: AUDIO_EVENTS_COLLECTION,
4788
4865
  filter: this.buildFilter(q)
4789
4866
  });
4790
- if (q.projection === "slim") return rows.map((r) => slimAudio(r.id, stripNulls(r.data)));
4867
+ if (q.projection === "slim") return rows.map((r) => slimAudio(r.id, stripNulls$1(r.data)));
4791
4868
  return rows.map((r) => {
4792
4869
  return {
4793
4870
  id: r.id,
4794
4871
  kind: "audio",
4795
- ...stripNulls(r.data)
4872
+ ...stripNulls$1(r.data)
4796
4873
  };
4797
4874
  });
4798
4875
  }
@@ -4938,6 +5015,23 @@ var EventStore = class {
4938
5015
  return updated;
4939
5016
  }
4940
5017
  /**
5018
+ * Total persisted event rows (motion + object + audio) for a device — the
5019
+ * "rows" figure in the events-management footprint. Uses the indexed `count`
5020
+ * aggregate per collection, run in parallel.
5021
+ */
5022
+ async countForDevice(deviceId) {
5023
+ const one = (collection) => this.store.count.query({
5024
+ collection,
5025
+ filter: { where: { deviceId } }
5026
+ });
5027
+ const [motion, object, audio] = await Promise.all([
5028
+ one(MOTION_EVENTS_COLLECTION),
5029
+ one(OBJECT_EVENTS_COLLECTION),
5030
+ one(AUDIO_EVENTS_COLLECTION)
5031
+ ]);
5032
+ return motion + object + audio;
5033
+ }
5034
+ /**
4941
5035
  * Return per-kind event counts in equal-width time buckets.
4942
5036
  *
4943
5037
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -5212,12 +5306,189 @@ function bboxAreaFrac(data) {
5212
5306
  if (w <= 0 || h <= 0) return 0;
5213
5307
  return w * h / (fw * fh);
5214
5308
  }
5215
- function stripNulls(data) {
5309
+ function stripNulls$1(data) {
5216
5310
  const out = {};
5217
5311
  for (const [k, v] of Object.entries(data)) if (v !== null) out[k] = v;
5218
5312
  return out;
5219
5313
  }
5220
5314
  //#endregion
5315
+ //#region src/pipeline-analytics/store/ops-log-store.ts
5316
+ /**
5317
+ * OpsLogStore — the EVENTS-domain operations audit for pipeline-analytics.
5318
+ *
5319
+ * pipeline-analytics already owns SQLite collections, so (unlike the recorder's
5320
+ * DurableState ring) the events ops-log is a DECLARED SQL-backed collection.
5321
+ * MUST be declared in `onInitialize` via `declare()` before the first insert —
5322
+ * an undeclared collection crash-loops the runner.
5323
+ *
5324
+ * Collection name: pipeline-analytics:ops-log
5325
+ *
5326
+ * Append is BEST-EFFORT (`append` catches + logs, never throws) — a failed
5327
+ * audit write must not fail the operation it records.
5328
+ */
5329
+ var OPS_LOG_COLLECTION = "pipeline-analytics:ops-log";
5330
+ var OPS_LOG_COLUMNS = [
5331
+ {
5332
+ name: "id",
5333
+ type: "TEXT",
5334
+ primaryKey: true,
5335
+ notNull: true
5336
+ },
5337
+ {
5338
+ name: "at",
5339
+ type: "INTEGER",
5340
+ notNull: true
5341
+ },
5342
+ {
5343
+ name: "domain",
5344
+ type: "TEXT",
5345
+ notNull: true
5346
+ },
5347
+ {
5348
+ name: "op",
5349
+ type: "TEXT",
5350
+ notNull: true
5351
+ },
5352
+ {
5353
+ name: "reason",
5354
+ type: "TEXT",
5355
+ notNull: true
5356
+ },
5357
+ {
5358
+ name: "deviceId",
5359
+ type: "INTEGER"
5360
+ },
5361
+ {
5362
+ name: "nodeId",
5363
+ type: "TEXT",
5364
+ notNull: true
5365
+ },
5366
+ {
5367
+ name: "itemsAffected",
5368
+ type: "INTEGER",
5369
+ notNull: true
5370
+ },
5371
+ {
5372
+ name: "bytesReclaimed",
5373
+ type: "INTEGER",
5374
+ notNull: true
5375
+ },
5376
+ {
5377
+ name: "detail",
5378
+ type: "TEXT"
5379
+ },
5380
+ {
5381
+ name: "actor",
5382
+ type: "TEXT",
5383
+ notNull: true
5384
+ }
5385
+ ];
5386
+ var OPS_LOG_INDEXES = [{
5387
+ name: "idx_opslog_at",
5388
+ columns: ["at"]
5389
+ }, {
5390
+ name: "idx_opslog_device_at",
5391
+ columns: ["deviceId", "at"]
5392
+ }];
5393
+ var OpsLogStore = class {
5394
+ store;
5395
+ logger;
5396
+ nodeId;
5397
+ now;
5398
+ newId;
5399
+ constructor(deps) {
5400
+ this.store = deps.store;
5401
+ this.logger = deps.logger;
5402
+ this.nodeId = deps.nodeId;
5403
+ this.now = deps.now ?? (() => Date.now());
5404
+ this.newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
5405
+ }
5406
+ static async declare(store) {
5407
+ await store.declareCollection.mutate({
5408
+ collection: OPS_LOG_COLLECTION,
5409
+ columns: [...OPS_LOG_COLUMNS],
5410
+ indexes: [...OPS_LOG_INDEXES]
5411
+ });
5412
+ }
5413
+ /**
5414
+ * Append one events-domain ops-log row. Best-effort — stamps
5415
+ * `domain:'events'`, `nodeId`, `id`, `at`, validates, inserts, and swallows
5416
+ * (logs) any failure so it can never fail the operation being audited.
5417
+ */
5418
+ async append(input) {
5419
+ const entry = {
5420
+ id: this.newId(),
5421
+ at: this.now(),
5422
+ domain: "events",
5423
+ op: input.op,
5424
+ reason: input.reason,
5425
+ deviceId: input.deviceId,
5426
+ nodeId: this.nodeId,
5427
+ itemsAffected: input.itemsAffected,
5428
+ bytesReclaimed: input.bytesReclaimed,
5429
+ detail: input.detail ?? null,
5430
+ actor: input.actor ?? "operator"
5431
+ };
5432
+ try {
5433
+ const { id, ...rest } = OpsLogEntrySchema.parse(entry);
5434
+ await this.store.insert.mutate({
5435
+ collection: OPS_LOG_COLLECTION,
5436
+ record: {
5437
+ id,
5438
+ data: rest
5439
+ }
5440
+ });
5441
+ } catch (err) {
5442
+ this.logger.warn("OpsLogStore.append failed (best-effort)", {
5443
+ tags: { deviceId: input.deviceId ?? void 0 },
5444
+ meta: {
5445
+ op: input.op,
5446
+ error: String(err)
5447
+ }
5448
+ });
5449
+ }
5450
+ }
5451
+ /**
5452
+ * List events ops-log rows newest-first, optionally scoped to one device,
5453
+ * capped at `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
5454
+ */
5455
+ async list(query) {
5456
+ const filter = {
5457
+ orderBy: {
5458
+ field: "at",
5459
+ direction: "desc"
5460
+ },
5461
+ limit: query.limit ?? 200
5462
+ };
5463
+ if (query.deviceId !== void 0) filter.where = { deviceId: query.deviceId };
5464
+ const rows = await this.store.query.query({
5465
+ collection: OPS_LOG_COLLECTION,
5466
+ filter
5467
+ });
5468
+ const out = [];
5469
+ for (const r of rows) {
5470
+ const parsed = OpsLogEntrySchema.safeParse({
5471
+ id: r.id,
5472
+ ...stripNulls(r.data)
5473
+ });
5474
+ if (parsed.success) out.push(parsed.data);
5475
+ else this.logger.debug("OpsLogStore.list: skipped malformed row", { meta: { id: r.id } });
5476
+ }
5477
+ return out;
5478
+ }
5479
+ };
5480
+ /** SQLite stores nullable columns as `null`; the row schema uses `.nullable()`
5481
+ * for deviceId/detail (accepts null) but drop any stray null on required
5482
+ * fields defensively before parse. */
5483
+ function stripNulls(data) {
5484
+ const out = {};
5485
+ for (const [k, v] of Object.entries(data)) {
5486
+ if (v === null && k !== "deviceId" && k !== "detail") continue;
5487
+ out[k] = v;
5488
+ }
5489
+ return out;
5490
+ }
5491
+ //#endregion
5221
5492
  //#region src/pipeline-analytics/store/sensor-event-store.ts
5222
5493
  var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
5223
5494
  var SENSOR_EVENT_COLUMNS = [
@@ -5390,114 +5661,49 @@ function isRecord(x) {
5390
5661
  * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
5391
5662
  * cheap at bus rate.
5392
5663
  */
5393
- var MOTION_COLOR = "#f59e0b";
5394
- var AUDIO_COLOR = "#06b6d4";
5395
- var PERSON_COLOR = "#22c55e";
5396
- var VEHICLE_COLOR = "#3b82f6";
5397
- var ANIMAL_COLOR = "#f97316";
5398
- var GENERIC_DETECTION_COLOR = "#64748b";
5399
- var PACKAGE_COLOR = "#a855f7";
5400
- var VEHICLE_CLASSES = new Set([
5401
- "vehicle",
5402
- "car",
5403
- "truck",
5404
- "bus",
5405
- "motorcycle",
5406
- "bicycle",
5407
- "boat",
5408
- "train"
5409
- ]);
5410
- var ANIMAL_CLASSES = new Set([
5411
- "animal",
5412
- "dog",
5413
- "cat",
5414
- "bird",
5415
- "horse",
5416
- "cow",
5417
- "sheep"
5418
- ]);
5419
- function detectionIcon(className) {
5420
- if (className === "person") return "person";
5421
- if (VEHICLE_CLASSES.has(className)) return "vehicle";
5422
- if (ANIMAL_CLASSES.has(className)) return "animal";
5423
- return "generic";
5424
- }
5425
- function detectionColor(className) {
5426
- if (className === "person") return PERSON_COLOR;
5427
- if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
5428
- if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
5429
- return GENERIC_DETECTION_COLOR;
5430
- }
5431
- function titleCase(s) {
5432
- return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5433
- }
5434
5664
  /**
5435
- * Full event-kind list for a camera. Sensor kinds are deduped per
5436
- * (kind, source deviceId)two linked contact sensors each contribute
5437
- * their own entry, distinguishable by `source.deviceId`.
5665
+ * Full event-kind taxonomy a camera CAN emit (config-derived):
5666
+ * (a) motion built-inalways;
5667
+ * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
5668
+ * (c) audio macro + its subs when the classifier is enabled;
5669
+ * (d) package kinds when a package zone rule is enabled;
5670
+ * (e) sensor/control kinds from LINKED devices (binding-driven), deduped
5671
+ * per (kind, source deviceId).
5672
+ * Every descriptor carries parentKind/level/iconId/labelKey via the single
5673
+ * taxonomy dictionary — no color/icon is declared here.
5438
5674
  */
5439
5675
  async function composeEventKinds(deps, deviceId) {
5440
- const out = [{
5441
- kind: "motion",
5442
- label: "Motion",
5443
- color: MOTION_COLOR,
5444
- icon: "motion",
5445
- category: "motion",
5446
- source: {
5447
- capName: "pipeline-analytics",
5448
- deviceId
5449
- }
5450
- }, {
5451
- kind: "audio",
5452
- label: "Audio",
5453
- color: AUDIO_COLOR,
5454
- icon: "audio",
5455
- category: "audio",
5456
- source: {
5457
- capName: "pipeline-analytics",
5458
- deviceId
5459
- }
5460
- }];
5676
+ const out = [];
5677
+ const cameraSource = {
5678
+ capName: "pipeline-analytics",
5679
+ deviceId
5680
+ };
5681
+ const pushKind = (kind, source = cameraSource) => {
5682
+ const d = buildEventKindDescriptor(kind, source);
5683
+ if (d !== null) out.push(d);
5684
+ };
5685
+ pushKind("motion");
5461
5686
  try {
5462
- const classNames = await deps.observedClassNames(deviceId);
5463
- for (const className of [...classNames].sort()) out.push({
5464
- kind: className,
5465
- label: titleCase(className),
5466
- color: detectionColor(className),
5467
- icon: detectionIcon(className),
5468
- category: "detection",
5469
- source: {
5470
- capName: "pipeline-analytics",
5471
- deviceId
5472
- }
5473
- });
5687
+ const macros = await deps.enabledMacroClasses(deviceId);
5688
+ for (const macro of macros) {
5689
+ pushKind(macro);
5690
+ for (const sub of subKindsOf(macro)) pushKind(sub.kind);
5691
+ }
5474
5692
  } catch (err) {
5475
- deps.onError?.("observedClassNames", err);
5693
+ deps.onError?.("enabledMacroClasses", err);
5694
+ }
5695
+ try {
5696
+ if (await deps.audioEnabled(deviceId)) {
5697
+ pushKind("audio");
5698
+ for (const sub of subKindsOf("audio")) pushKind(sub.kind);
5699
+ }
5700
+ } catch (err) {
5701
+ deps.onError?.("audioEnabled", err);
5476
5702
  }
5477
5703
  try {
5478
5704
  if (deps.packageZonesEnabled && await deps.packageZonesEnabled(deviceId)) {
5479
- out.push({
5480
- kind: "package-delivered",
5481
- label: "Package delivered",
5482
- color: PACKAGE_COLOR,
5483
- icon: "package",
5484
- category: "package",
5485
- source: {
5486
- capName: "pipeline-analytics",
5487
- deviceId
5488
- }
5489
- });
5490
- out.push({
5491
- kind: "package-picked-up",
5492
- label: "Package picked up",
5493
- color: PACKAGE_COLOR,
5494
- icon: "package",
5495
- category: "package",
5496
- source: {
5497
- capName: "pipeline-analytics",
5498
- deviceId
5499
- }
5500
- });
5705
+ pushKind("package");
5706
+ for (const sub of subKindsOf("package")) pushKind(sub.kind);
5501
5707
  }
5502
5708
  } catch (err) {
5503
5709
  deps.onError?.("packageZonesEnabled", err);
@@ -5520,16 +5726,9 @@ async function composeEventKinds(deps, deviceId) {
5520
5726
  const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
5521
5727
  if (seen.has(dedupeKey)) continue;
5522
5728
  seen.add(dedupeKey);
5523
- out.push({
5524
- kind: descriptor.kind,
5525
- label: descriptor.label,
5526
- color: descriptor.color,
5527
- icon: descriptor.icon,
5528
- category: descriptor.category,
5529
- source: {
5530
- capName,
5531
- deviceId: linked.deviceId
5532
- }
5729
+ pushKind(descriptor.kind, {
5730
+ capName,
5731
+ deviceId: linked.deviceId
5533
5732
  });
5534
5733
  }
5535
5734
  }
@@ -5625,6 +5824,110 @@ async function ingestSensorStateChange(deps, data, timestamp) {
5625
5824
  return inserted;
5626
5825
  }
5627
5826
  //#endregion
5827
+ //#region src/pipeline-analytics/services/synthetic-sensor-track.ts
5828
+ /**
5829
+ * Synthetic sensor/control tracks (event-kinds-taxonomy §8).
5830
+ *
5831
+ * A linked sensor/control state change is projected into the UNIFIED track
5832
+ * store as a SYNTHETIC track: `className` = the event kind (`contact` /
5833
+ * `lock` / `switch` …), `source: 'sensor'`, media = an on-demand snapshot of
5834
+ * the linked camera in the SAME `getTrackMedia` shape the UIs consume (no
5835
+ * bbox → full frame), `positions: []`. It joins the timeline via the existing
5836
+ * time-based clustering — no new join mechanism.
5837
+ *
5838
+ * The raw `SensorEvent` remains the durable record; this track is its
5839
+ * projection. Snapshots are debounced per `(camera, kind)` so a chattery
5840
+ * sensor doesn't hammer the snapshot cap. Spatial consumers MUST skip
5841
+ * `source:'sensor'` tracks (they carry no trajectory).
5842
+ */
5843
+ var DEFAULT_DEBOUNCE_MS = 1e4;
5844
+ /** Full-frame, position-less placeholder (spatial consumers skip these). */
5845
+ function zeroPosition(timestamp) {
5846
+ return {
5847
+ x: 0,
5848
+ y: 0,
5849
+ timestamp,
5850
+ bbox: {
5851
+ x: 0,
5852
+ y: 0,
5853
+ w: 0,
5854
+ h: 0
5855
+ }
5856
+ };
5857
+ }
5858
+ var SyntheticSensorTrackMaterializer = class {
5859
+ deps;
5860
+ debounceMs;
5861
+ makeId;
5862
+ now;
5863
+ /** Last snapshot time per `${cameraId}:${kind}`. */
5864
+ lastAt = /* @__PURE__ */ new Map();
5865
+ constructor(deps) {
5866
+ this.deps = deps;
5867
+ this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
5868
+ this.makeId = deps.makeId ?? (() => `pa-synth-${randomUUID()}`);
5869
+ this.now = deps.now ?? Date.now;
5870
+ }
5871
+ /**
5872
+ * Materialize a synthetic track for a sensor/control event on `cameraId`.
5873
+ * Returns the persisted track, or null when debounced. Snapshot failure
5874
+ * still lands a track (with no media) — it never blocks the sensor record.
5875
+ */
5876
+ async materialize(input) {
5877
+ const key = `${input.cameraId}:${input.kind}`;
5878
+ const last = this.lastAt.get(key);
5879
+ if (last !== void 0 && this.now() - last < this.debounceMs) return null;
5880
+ this.lastAt.set(key, this.now());
5881
+ const trackId = this.makeId();
5882
+ const ts = input.timestamp;
5883
+ let mediaKey = null;
5884
+ try {
5885
+ const snap = await this.deps.snapshot.getSnapshot({
5886
+ deviceId: input.cameraId,
5887
+ force: true
5888
+ });
5889
+ if (snap !== null) {
5890
+ const data = Buffer.from(snap.base64, "base64");
5891
+ mediaKey = await this.deps.media.put({
5892
+ deviceId: input.cameraId,
5893
+ ownerKind: "track",
5894
+ ownerId: trackId,
5895
+ kind: "snapshot",
5896
+ timestamp: ts,
5897
+ data
5898
+ });
5899
+ } else this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
5900
+ } catch (err) {
5901
+ this.deps.onError?.("snapshotMedia", err);
5902
+ }
5903
+ const track = {
5904
+ trackId,
5905
+ deviceId: input.cameraId,
5906
+ className: input.kind,
5907
+ source: "sensor",
5908
+ firstSeen: ts,
5909
+ lastSeen: ts,
5910
+ positions: [],
5911
+ snapshots: mediaKey !== null ? [{
5912
+ timestamp: ts,
5913
+ position: zeroPosition(ts),
5914
+ mediaKey
5915
+ }] : [],
5916
+ zonesVisited: [],
5917
+ totalDistance: 0,
5918
+ state: "idle",
5919
+ active: false
5920
+ };
5921
+ try {
5922
+ await this.deps.tracks.persistSyntheticTrack(track);
5923
+ } catch (err) {
5924
+ this.deps.onError?.("persistSyntheticTrack", err);
5925
+ return null;
5926
+ }
5927
+ return track;
5928
+ }
5929
+ };
5930
+ //#endregion
5628
5931
  //#region src/shared/frame/resolve-frame.ts
5629
5932
  /**
5630
5933
  * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
@@ -5672,7 +5975,7 @@ function squareSafeCropRegion(bbox, frame, padding) {
5672
5975
  }
5673
5976
  //#endregion
5674
5977
  //#region src/shared/frame/box-drawer.ts
5675
- var DEFAULT_COLOR = "#22ff55";
5978
+ var DEFAULT_COLOR = DEFAULT_EVENT_COLOR;
5676
5979
  var DEFAULT_QUALITY = 80;
5677
5980
  var STROKE_WIDTH = 3;
5678
5981
  function escapeXml(s) {
@@ -10959,11 +11262,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
10959
11262
  stationaryRegistry = null;
10960
11263
  mediaStore = null;
10961
11264
  eventStore = null;
11265
+ /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
11266
+ eventsOpsLog = null;
10962
11267
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
10963
11268
  sensorEventStore = null;
10964
11269
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
10965
11270
  * so the DeviceStateChanged handler stays cheap. */
10966
11271
  linkedCamerasCache = null;
11272
+ /** Projects sensor/control events into synthetic snapshot-carrying tracks. */
11273
+ syntheticSensorTracks = null;
10967
11274
  identityStore = null;
10968
11275
  faceStore = null;
10969
11276
  faceRecognizer = null;
@@ -11121,6 +11428,7 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11121
11428
  await VehicleStore.declare(api.settingsStore);
11122
11429
  await ObjectEmbeddingStore.declare(api.settingsStore);
11123
11430
  await StationaryObjectRegistry.declare(api.settingsStore);
11431
+ await OpsLogStore.declare(api.settingsStore);
11124
11432
  const logger = this.ctx.logger;
11125
11433
  let storage = this.ctx.kernel.storage;
11126
11434
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
@@ -11130,11 +11438,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11130
11438
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
11131
11439
  }
11132
11440
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
11133
- this.trackStore = new TrackStore({
11441
+ const trackStore = new TrackStore({
11134
11442
  store: api.settingsStore,
11135
11443
  logger: logger.child("TrackStore"),
11136
11444
  frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
11137
11445
  });
11446
+ this.trackStore = trackStore;
11138
11447
  this.stationaryRegistry = new StationaryObjectRegistry({
11139
11448
  store: api.settingsStore,
11140
11449
  logger: logger.child("StationaryRegistry"),
@@ -11164,11 +11473,12 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11164
11473
  }
11165
11474
  });
11166
11475
  await this.stationaryRegistry.load();
11167
- this.mediaStore = new MediaStore({
11476
+ const mediaStore = new MediaStore({
11168
11477
  storage,
11169
11478
  store: api.settingsStore,
11170
11479
  logger: logger.child("MediaStore")
11171
11480
  });
11481
+ this.mediaStore = mediaStore;
11172
11482
  const eventStore = new EventStore({
11173
11483
  store: api.settingsStore,
11174
11484
  logger: logger.child("EventStore"),
@@ -11228,6 +11538,15 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11228
11538
  error: errMsg(err)
11229
11539
  } })
11230
11540
  });
11541
+ this.syntheticSensorTracks = new SyntheticSensorTrackMaterializer({
11542
+ snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
11543
+ media: mediaStore,
11544
+ tracks: trackStore,
11545
+ onError: (scope, err) => logger.warn("synthetic sensor-track error", { meta: {
11546
+ scope,
11547
+ error: errMsg(err)
11548
+ } })
11549
+ });
11231
11550
  this.identityStore = new IdentityStore({
11232
11551
  store: api.settingsStore,
11233
11552
  logger: logger.child("IdentityStore")
@@ -11246,6 +11565,11 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
11246
11565
  });
11247
11566
  const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
11248
11567
  const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
11568
+ this.eventsOpsLog = new OpsLogStore({
11569
+ store: api.settingsStore,
11570
+ logger: logger.child("ops-log"),
11571
+ nodeId: ownNodeId
11572
+ });
11249
11573
  {
11250
11574
  const designated = await this.postProcessingNodeState.get();
11251
11575
  this.isPostProcessingNode = ownNodeId === designated;
@@ -13460,10 +13784,21 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13460
13784
  */
13461
13785
  async listEventKinds(input) {
13462
13786
  const api = this.ctx.api;
13787
+ const status = await api.pipelineOrchestrator.getCameraStatus.query({ deviceId: input.deviceId }).catch((err) => {
13788
+ this.ctx.logger.warn("listEventKinds: getCameraStatus failed", {
13789
+ tags: { deviceId: input.deviceId },
13790
+ meta: { error: errMsg(err) }
13791
+ });
13792
+ return null;
13793
+ });
13794
+ const detectionOn = status?.detection != null;
13795
+ const audioOn = status?.audio?.enabled === true;
13796
+ const detectionMacros = MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
13463
13797
  return composeEventKinds({
13464
13798
  linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
13465
13799
  bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
13466
- observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
13800
+ enabledMacroClasses: async () => detectionOn ? detectionMacros : [],
13801
+ audioEnabled: async () => audioOn,
13467
13802
  packageZonesEnabled: async (deviceId) => {
13468
13803
  return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
13469
13804
  },
@@ -13503,6 +13838,26 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13503
13838
  }
13504
13839
  });
13505
13840
  }
13841
+ const materializer = this.syntheticSensorTracks;
13842
+ const descriptor = EVENT_KIND_BY_CAP[data.capName];
13843
+ if (materializer === null || descriptor === void 0) return;
13844
+ try {
13845
+ const cameraIds = await cache.camerasFor(data.deviceId);
13846
+ for (const cameraId of cameraIds) await materializer.materialize({
13847
+ cameraId,
13848
+ sourceDeviceId: data.deviceId,
13849
+ kind: descriptor.kind,
13850
+ timestamp
13851
+ });
13852
+ } catch (err) {
13853
+ this.ctx.logger.warn("synthetic sensor-track materialize failed", {
13854
+ tags: { deviceId: data.deviceId },
13855
+ meta: {
13856
+ capName: data.capName,
13857
+ error: errMsg(err)
13858
+ }
13859
+ });
13860
+ }
13506
13861
  }
13507
13862
  async clearTracks(input) {
13508
13863
  this.trackStore?.clearDevice(input.deviceId);
@@ -13751,6 +14106,120 @@ var PipelineAnalyticsAddon = class extends BaseAddon {
13751
14106
  };
13752
14107
  }
13753
14108
  /**
14109
+ * Durable event-store footprint for the events-management UI: event ROWS
14110
+ * (motion + object + audio) counted per camera (indexed `count`) + total, and
14111
+ * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
14112
+ * The device set is the union of cameras that have media OR persisted tracks;
14113
+ * cameras with neither rows nor bytes are omitted.
14114
+ */
14115
+ async getEventStoreFootprint() {
14116
+ const eventStore = this.eventStore;
14117
+ const mediaStore = this.mediaStore;
14118
+ if (!eventStore || !mediaStore) return {
14119
+ totalRows: 0,
14120
+ totalBytes: 0,
14121
+ devices: []
14122
+ };
14123
+ const mediaByDevice = await mediaStore.footprintByDevice();
14124
+ const deviceIds = new Set(mediaByDevice.keys());
14125
+ if (this.trackStore) for (const id of await this.trackStore.listDeviceIds()) deviceIds.add(id);
14126
+ const devices = [];
14127
+ let totalRows = 0;
14128
+ let totalBytes = 0;
14129
+ for (const deviceId of deviceIds) {
14130
+ const rows = await eventStore.countForDevice(deviceId);
14131
+ const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
14132
+ if (rows === 0 && bytes === 0) continue;
14133
+ totalRows += rows;
14134
+ totalBytes += bytes;
14135
+ devices.push({
14136
+ deviceId,
14137
+ rows,
14138
+ bytes
14139
+ });
14140
+ }
14141
+ devices.sort((a, b) => b.bytes - a.bytes);
14142
+ return {
14143
+ totalRows,
14144
+ totalBytes,
14145
+ devices
14146
+ };
14147
+ }
14148
+ /**
14149
+ * Cluster-wide prune of events with `timestamp < olderThanMs` across every
14150
+ * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
14151
+ * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
14152
+ * `bytesReclaimed` is 0 (media deletion does not surface freed bytes).
14153
+ */
14154
+ async pruneEvents(input) {
14155
+ const eventStore = this.eventStore;
14156
+ if (!eventStore) return {
14157
+ motion: 0,
14158
+ object: 0,
14159
+ audio: 0
14160
+ };
14161
+ const cutoff = input.olderThanMs;
14162
+ const evicted = await eventStore.evictBefore({
14163
+ motionCutoffMs: cutoff,
14164
+ objectCutoffMs: cutoff,
14165
+ audioCutoffMs: cutoff
14166
+ });
14167
+ const ids = [
14168
+ ...evicted.motion,
14169
+ ...evicted.object,
14170
+ ...evicted.audio
14171
+ ];
14172
+ if (ids.length > 0 && this.mediaStore) await this.mediaStore.deleteForEvents(ids);
14173
+ const counts = {
14174
+ motion: evicted.motion.length,
14175
+ object: evicted.object.length,
14176
+ audio: evicted.audio.length
14177
+ };
14178
+ const total = counts.motion + counts.object + counts.audio;
14179
+ await this.eventsOpsLog?.append({
14180
+ op: "prune",
14181
+ reason: input.reason ?? "retention",
14182
+ deviceId: null,
14183
+ itemsAffected: total,
14184
+ bytesReclaimed: 0,
14185
+ detail: `pruned events older than ${cutoff}`
14186
+ });
14187
+ if (total > 0) this.ctx.logger.info("analytics events pruned (cluster)", { meta: {
14188
+ cutoffMs: cutoff,
14189
+ ...counts
14190
+ } });
14191
+ return counts;
14192
+ }
14193
+ /**
14194
+ * Manually delete EVERY event (motion + object + audio) for one camera and its
14195
+ * event-owned media in lockstep. Logs a manual events ops-log row.
14196
+ */
14197
+ async deleteDeviceEvents(input) {
14198
+ if (!this.eventStore) return {
14199
+ motion: 0,
14200
+ object: 0,
14201
+ audio: 0
14202
+ };
14203
+ const counts = await this.pruneEventsBefore({
14204
+ deviceId: input.deviceId,
14205
+ cutoffMs: Number.MAX_SAFE_INTEGER
14206
+ });
14207
+ const total = counts.motion + counts.object + counts.audio;
14208
+ await this.eventsOpsLog?.append({
14209
+ op: "manual-delete",
14210
+ reason: "manual",
14211
+ deviceId: input.deviceId,
14212
+ itemsAffected: total,
14213
+ bytesReclaimed: 0,
14214
+ detail: "deleted all events for device"
14215
+ });
14216
+ return counts;
14217
+ }
14218
+ /** The events ops-log rows (newest-first), optionally scoped to one camera. */
14219
+ async listOpsLog(input) {
14220
+ return await this.eventsOpsLog?.list(input) ?? [];
14221
+ }
14222
+ /**
13754
14223
  * Track-centric time-based retention (design §5.1). Drains every persisted
13755
14224
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
13756
14225
  * widened cascade — enrolled faces/plates + identity media are exempt (design