@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.
@@ -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-V8XOHDoj.js");
5
+ const require_dist = require("../dist-Bc4L7FGf.js");
6
6
  let node_crypto = require("node:crypto");
7
7
  let sharp = require("sharp");
8
8
  sharp = require_dist.__toESM(sharp);
@@ -165,13 +165,13 @@ var CLASS_RANK_VEHICLE = .8;
165
165
  var CLASS_RANK_ANIMAL = .5;
166
166
  var CLASS_RANK_DEFAULT = .25;
167
167
  var PERSON_CLASSES = new Set(["person", "face"]);
168
- var VEHICLE_CLASSES$1 = new Set([
168
+ var VEHICLE_CLASSES = new Set([
169
169
  "vehicle",
170
170
  "car",
171
171
  "truck",
172
172
  "bus"
173
173
  ]);
174
- var ANIMAL_CLASSES$1 = new Set([
174
+ var ANIMAL_CLASSES = new Set([
175
175
  "animal",
176
176
  "dog",
177
177
  "cat"
@@ -187,8 +187,8 @@ function clamp01(x) {
187
187
  function classRank(className) {
188
188
  const c = className.toLowerCase();
189
189
  if (PERSON_CLASSES.has(c)) return 1;
190
- if (VEHICLE_CLASSES$1.has(c)) return CLASS_RANK_VEHICLE;
191
- if (ANIMAL_CLASSES$1.has(c)) return CLASS_RANK_ANIMAL;
190
+ if (VEHICLE_CLASSES.has(c)) return CLASS_RANK_VEHICLE;
191
+ if (ANIMAL_CLASSES.has(c)) return CLASS_RANK_ANIMAL;
192
192
  return CLASS_RANK_DEFAULT;
193
193
  }
194
194
  /**
@@ -3319,6 +3319,10 @@ var TRACKS_COLUMNS = [
3319
3319
  name: "label",
3320
3320
  type: "TEXT"
3321
3321
  },
3322
+ {
3323
+ name: "source",
3324
+ type: "TEXT"
3325
+ },
3322
3326
  {
3323
3327
  name: "firstSeen",
3324
3328
  type: "INTEGER",
@@ -4009,6 +4013,34 @@ var TrackStore = class {
4009
4013
  const row = records[0];
4010
4014
  return this.rowToTrack(row.id, row.data);
4011
4015
  }
4016
+ /**
4017
+ * Persist a SYNTHETIC track (a sensor/control event projected into the
4018
+ * unified track store). Unlike `persistCompleted` there is no live in-RAM
4019
+ * state and no trajectory envelope (synthetic tracks carry no positions) —
4020
+ * the row is written directly. `source: 'sensor'` marks it so spatial
4021
+ * consumers skip it; the existing time-based clustering joins it by
4022
+ * timestamp like any other track.
4023
+ */
4024
+ async persistSyntheticTrack(t) {
4025
+ await this.store.set.mutate({
4026
+ collection: TRACKS_COLLECTION,
4027
+ key: t.trackId,
4028
+ value: {
4029
+ deviceId: t.deviceId,
4030
+ className: t.className,
4031
+ ...t.label !== void 0 ? { label: t.label } : {},
4032
+ source: t.source ?? "sensor",
4033
+ firstSeen: t.firstSeen,
4034
+ lastSeen: t.lastSeen,
4035
+ positions: [...t.positions],
4036
+ snapshots: [...t.snapshots],
4037
+ zonesVisited: [...t.zonesVisited],
4038
+ ...t.classes !== void 0 ? { classes: [...t.classes] } : {},
4039
+ totalDistance: t.totalDistance,
4040
+ state: t.state
4041
+ }
4042
+ });
4043
+ }
4012
4044
  async persistCompleted(t) {
4013
4045
  const dims = this.frameDims?.(t.deviceId);
4014
4046
  const envelope = computeTrackEnvelope(t.positions, dims?.w, dims?.h);
@@ -4019,6 +4051,7 @@ var TrackStore = class {
4019
4051
  deviceId: t.deviceId,
4020
4052
  className: t.className,
4021
4053
  ...t.label !== void 0 ? { label: t.label } : {},
4054
+ ...t.source !== void 0 ? { source: t.source } : {},
4022
4055
  firstSeen: t.firstSeen,
4023
4056
  lastSeen: t.lastSeen,
4024
4057
  positions: [...t.positions],
@@ -4057,6 +4090,7 @@ var TrackStore = class {
4057
4090
  const zones = data["zonesVisited"] ?? [];
4058
4091
  const classes = data["classes"];
4059
4092
  const label = data["label"];
4093
+ const source = data["source"];
4060
4094
  const importance = data["importance"];
4061
4095
  const bestEventId = data["bestEventId"];
4062
4096
  const importanceReason = data["importanceReason"];
@@ -4076,6 +4110,7 @@ var TrackStore = class {
4076
4110
  deviceId: Number(data["deviceId"]),
4077
4111
  className: String(data["className"]),
4078
4112
  ...typeof label === "string" ? { label } : {},
4113
+ ...source === "sensor" || source === "pipeline" ? { source } : {},
4079
4114
  firstSeen: Number(data["firstSeen"]),
4080
4115
  lastSeen: Number(data["lastSeen"]),
4081
4116
  positions,
@@ -4398,6 +4433,48 @@ var MediaStore = class {
4398
4433
  }
4399
4434
  return removed;
4400
4435
  }
4436
+ /**
4437
+ * On-demand footprint aggregation for the events-management UI: sum
4438
+ * `sizeBytes` and count rows per device across the whole media collection.
4439
+ * Pages by `id` (offset-based) so a large collection never loads at once.
4440
+ * Metadata rows are small (no blob) — the image bytes live on the storage
4441
+ * provider and are counted via the persisted `sizeBytes` column.
4442
+ */
4443
+ async footprintByDevice() {
4444
+ const PAGE = 1e3;
4445
+ const out = /* @__PURE__ */ new Map();
4446
+ let offset = 0;
4447
+ for (;;) {
4448
+ const rows = await this.store.query.query({
4449
+ collection: MEDIA_COLLECTION,
4450
+ filter: {
4451
+ orderBy: {
4452
+ field: "id",
4453
+ direction: "asc"
4454
+ },
4455
+ limit: PAGE,
4456
+ offset
4457
+ }
4458
+ });
4459
+ if (rows.length === 0) break;
4460
+ for (const row of rows) {
4461
+ const data = row.data;
4462
+ const deviceId = Number(data["deviceId"]);
4463
+ const sizeBytes = Number(data["sizeBytes"]);
4464
+ if (!Number.isFinite(deviceId)) continue;
4465
+ const acc = out.get(deviceId) ?? {
4466
+ bytes: 0,
4467
+ rows: 0
4468
+ };
4469
+ acc.bytes += Number.isFinite(sizeBytes) ? sizeBytes : 0;
4470
+ acc.rows += 1;
4471
+ out.set(deviceId, acc);
4472
+ }
4473
+ if (rows.length < PAGE) break;
4474
+ offset += PAGE;
4475
+ }
4476
+ return out;
4477
+ }
4401
4478
  /** Move a single media entry from one owner to another.
4402
4479
  * The new copy is written before the old one is removed.
4403
4480
  * The old-entry deletion is best-effort: on failure a warning is logged
@@ -4759,12 +4836,12 @@ var EventStore = class {
4759
4836
  collection: MOTION_EVENTS_COLLECTION,
4760
4837
  filter: this.buildFilter(q)
4761
4838
  });
4762
- if (q.projection === "slim") return rows.map((r) => slimMotion(r.id, stripNulls(r.data)));
4839
+ if (q.projection === "slim") return rows.map((r) => slimMotion(r.id, stripNulls$1(r.data)));
4763
4840
  return rows.map((r) => {
4764
4841
  return {
4765
4842
  id: r.id,
4766
4843
  kind: "motion",
4767
- ...stripNulls(r.data)
4844
+ ...stripNulls$1(r.data)
4768
4845
  };
4769
4846
  });
4770
4847
  }
@@ -4778,12 +4855,12 @@ var EventStore = class {
4778
4855
  collection: OBJECT_EVENTS_COLLECTION,
4779
4856
  filter
4780
4857
  });
4781
- if (q.projection === "slim") return rows.map((r) => slimObject(r.id, stripNulls(r.data)));
4858
+ if (q.projection === "slim") return rows.map((r) => slimObject(r.id, stripNulls$1(r.data)));
4782
4859
  return rows.map((r) => {
4783
4860
  return {
4784
4861
  id: r.id,
4785
4862
  kind: "object",
4786
- ...stripNulls(r.data)
4863
+ ...stripNulls$1(r.data)
4787
4864
  };
4788
4865
  });
4789
4866
  }
@@ -4792,12 +4869,12 @@ var EventStore = class {
4792
4869
  collection: AUDIO_EVENTS_COLLECTION,
4793
4870
  filter: this.buildFilter(q)
4794
4871
  });
4795
- if (q.projection === "slim") return rows.map((r) => slimAudio(r.id, stripNulls(r.data)));
4872
+ if (q.projection === "slim") return rows.map((r) => slimAudio(r.id, stripNulls$1(r.data)));
4796
4873
  return rows.map((r) => {
4797
4874
  return {
4798
4875
  id: r.id,
4799
4876
  kind: "audio",
4800
- ...stripNulls(r.data)
4877
+ ...stripNulls$1(r.data)
4801
4878
  };
4802
4879
  });
4803
4880
  }
@@ -4943,6 +5020,23 @@ var EventStore = class {
4943
5020
  return updated;
4944
5021
  }
4945
5022
  /**
5023
+ * Total persisted event rows (motion + object + audio) for a device — the
5024
+ * "rows" figure in the events-management footprint. Uses the indexed `count`
5025
+ * aggregate per collection, run in parallel.
5026
+ */
5027
+ async countForDevice(deviceId) {
5028
+ const one = (collection) => this.store.count.query({
5029
+ collection,
5030
+ filter: { where: { deviceId } }
5031
+ });
5032
+ const [motion, object, audio] = await Promise.all([
5033
+ one(MOTION_EVENTS_COLLECTION),
5034
+ one(OBJECT_EVENTS_COLLECTION),
5035
+ one(AUDIO_EVENTS_COLLECTION)
5036
+ ]);
5037
+ return motion + object + audio;
5038
+ }
5039
+ /**
4946
5040
  * Return per-kind event counts in equal-width time buckets.
4947
5041
  *
4948
5042
  * Each bucket maps to a `bucketStart = since + i * bucketMs`.
@@ -5217,12 +5311,189 @@ function bboxAreaFrac(data) {
5217
5311
  if (w <= 0 || h <= 0) return 0;
5218
5312
  return w * h / (fw * fh);
5219
5313
  }
5220
- function stripNulls(data) {
5314
+ function stripNulls$1(data) {
5221
5315
  const out = {};
5222
5316
  for (const [k, v] of Object.entries(data)) if (v !== null) out[k] = v;
5223
5317
  return out;
5224
5318
  }
5225
5319
  //#endregion
5320
+ //#region src/pipeline-analytics/store/ops-log-store.ts
5321
+ /**
5322
+ * OpsLogStore — the EVENTS-domain operations audit for pipeline-analytics.
5323
+ *
5324
+ * pipeline-analytics already owns SQLite collections, so (unlike the recorder's
5325
+ * DurableState ring) the events ops-log is a DECLARED SQL-backed collection.
5326
+ * MUST be declared in `onInitialize` via `declare()` before the first insert —
5327
+ * an undeclared collection crash-loops the runner.
5328
+ *
5329
+ * Collection name: pipeline-analytics:ops-log
5330
+ *
5331
+ * Append is BEST-EFFORT (`append` catches + logs, never throws) — a failed
5332
+ * audit write must not fail the operation it records.
5333
+ */
5334
+ var OPS_LOG_COLLECTION = "pipeline-analytics:ops-log";
5335
+ var OPS_LOG_COLUMNS = [
5336
+ {
5337
+ name: "id",
5338
+ type: "TEXT",
5339
+ primaryKey: true,
5340
+ notNull: true
5341
+ },
5342
+ {
5343
+ name: "at",
5344
+ type: "INTEGER",
5345
+ notNull: true
5346
+ },
5347
+ {
5348
+ name: "domain",
5349
+ type: "TEXT",
5350
+ notNull: true
5351
+ },
5352
+ {
5353
+ name: "op",
5354
+ type: "TEXT",
5355
+ notNull: true
5356
+ },
5357
+ {
5358
+ name: "reason",
5359
+ type: "TEXT",
5360
+ notNull: true
5361
+ },
5362
+ {
5363
+ name: "deviceId",
5364
+ type: "INTEGER"
5365
+ },
5366
+ {
5367
+ name: "nodeId",
5368
+ type: "TEXT",
5369
+ notNull: true
5370
+ },
5371
+ {
5372
+ name: "itemsAffected",
5373
+ type: "INTEGER",
5374
+ notNull: true
5375
+ },
5376
+ {
5377
+ name: "bytesReclaimed",
5378
+ type: "INTEGER",
5379
+ notNull: true
5380
+ },
5381
+ {
5382
+ name: "detail",
5383
+ type: "TEXT"
5384
+ },
5385
+ {
5386
+ name: "actor",
5387
+ type: "TEXT",
5388
+ notNull: true
5389
+ }
5390
+ ];
5391
+ var OPS_LOG_INDEXES = [{
5392
+ name: "idx_opslog_at",
5393
+ columns: ["at"]
5394
+ }, {
5395
+ name: "idx_opslog_device_at",
5396
+ columns: ["deviceId", "at"]
5397
+ }];
5398
+ var OpsLogStore = class {
5399
+ store;
5400
+ logger;
5401
+ nodeId;
5402
+ now;
5403
+ newId;
5404
+ constructor(deps) {
5405
+ this.store = deps.store;
5406
+ this.logger = deps.logger;
5407
+ this.nodeId = deps.nodeId;
5408
+ this.now = deps.now ?? (() => Date.now());
5409
+ this.newId = deps.newId ?? (() => globalThis.crypto.randomUUID());
5410
+ }
5411
+ static async declare(store) {
5412
+ await store.declareCollection.mutate({
5413
+ collection: OPS_LOG_COLLECTION,
5414
+ columns: [...OPS_LOG_COLUMNS],
5415
+ indexes: [...OPS_LOG_INDEXES]
5416
+ });
5417
+ }
5418
+ /**
5419
+ * Append one events-domain ops-log row. Best-effort — stamps
5420
+ * `domain:'events'`, `nodeId`, `id`, `at`, validates, inserts, and swallows
5421
+ * (logs) any failure so it can never fail the operation being audited.
5422
+ */
5423
+ async append(input) {
5424
+ const entry = {
5425
+ id: this.newId(),
5426
+ at: this.now(),
5427
+ domain: "events",
5428
+ op: input.op,
5429
+ reason: input.reason,
5430
+ deviceId: input.deviceId,
5431
+ nodeId: this.nodeId,
5432
+ itemsAffected: input.itemsAffected,
5433
+ bytesReclaimed: input.bytesReclaimed,
5434
+ detail: input.detail ?? null,
5435
+ actor: input.actor ?? "operator"
5436
+ };
5437
+ try {
5438
+ const { id, ...rest } = require_dist.OpsLogEntrySchema.parse(entry);
5439
+ await this.store.insert.mutate({
5440
+ collection: OPS_LOG_COLLECTION,
5441
+ record: {
5442
+ id,
5443
+ data: rest
5444
+ }
5445
+ });
5446
+ } catch (err) {
5447
+ this.logger.warn("OpsLogStore.append failed (best-effort)", {
5448
+ tags: { deviceId: input.deviceId ?? void 0 },
5449
+ meta: {
5450
+ op: input.op,
5451
+ error: String(err)
5452
+ }
5453
+ });
5454
+ }
5455
+ }
5456
+ /**
5457
+ * List events ops-log rows newest-first, optionally scoped to one device,
5458
+ * capped at `limit` (default {@link OPS_LOG_DEFAULT_LIMIT}).
5459
+ */
5460
+ async list(query) {
5461
+ const filter = {
5462
+ orderBy: {
5463
+ field: "at",
5464
+ direction: "desc"
5465
+ },
5466
+ limit: query.limit ?? 200
5467
+ };
5468
+ if (query.deviceId !== void 0) filter.where = { deviceId: query.deviceId };
5469
+ const rows = await this.store.query.query({
5470
+ collection: OPS_LOG_COLLECTION,
5471
+ filter
5472
+ });
5473
+ const out = [];
5474
+ for (const r of rows) {
5475
+ const parsed = require_dist.OpsLogEntrySchema.safeParse({
5476
+ id: r.id,
5477
+ ...stripNulls(r.data)
5478
+ });
5479
+ if (parsed.success) out.push(parsed.data);
5480
+ else this.logger.debug("OpsLogStore.list: skipped malformed row", { meta: { id: r.id } });
5481
+ }
5482
+ return out;
5483
+ }
5484
+ };
5485
+ /** SQLite stores nullable columns as `null`; the row schema uses `.nullable()`
5486
+ * for deviceId/detail (accepts null) but drop any stray null on required
5487
+ * fields defensively before parse. */
5488
+ function stripNulls(data) {
5489
+ const out = {};
5490
+ for (const [k, v] of Object.entries(data)) {
5491
+ if (v === null && k !== "deviceId" && k !== "detail") continue;
5492
+ out[k] = v;
5493
+ }
5494
+ return out;
5495
+ }
5496
+ //#endregion
5226
5497
  //#region src/pipeline-analytics/store/sensor-event-store.ts
5227
5498
  var SENSOR_EVENTS_COLLECTION = "pipeline-analytics:sensor-events";
5228
5499
  var SENSOR_EVENT_COLUMNS = [
@@ -5395,114 +5666,49 @@ function isRecord(x) {
5395
5666
  * linked camera ids) with a TTL, so the `DeviceStateChanged` handler stays
5396
5667
  * cheap at bus rate.
5397
5668
  */
5398
- var MOTION_COLOR = "#f59e0b";
5399
- var AUDIO_COLOR = "#06b6d4";
5400
- var PERSON_COLOR = "#22c55e";
5401
- var VEHICLE_COLOR = "#3b82f6";
5402
- var ANIMAL_COLOR = "#f97316";
5403
- var GENERIC_DETECTION_COLOR = "#64748b";
5404
- var PACKAGE_COLOR = "#a855f7";
5405
- var VEHICLE_CLASSES = new Set([
5406
- "vehicle",
5407
- "car",
5408
- "truck",
5409
- "bus",
5410
- "motorcycle",
5411
- "bicycle",
5412
- "boat",
5413
- "train"
5414
- ]);
5415
- var ANIMAL_CLASSES = new Set([
5416
- "animal",
5417
- "dog",
5418
- "cat",
5419
- "bird",
5420
- "horse",
5421
- "cow",
5422
- "sheep"
5423
- ]);
5424
- function detectionIcon(className) {
5425
- if (className === "person") return "person";
5426
- if (VEHICLE_CLASSES.has(className)) return "vehicle";
5427
- if (ANIMAL_CLASSES.has(className)) return "animal";
5428
- return "generic";
5429
- }
5430
- function detectionColor(className) {
5431
- if (className === "person") return PERSON_COLOR;
5432
- if (VEHICLE_CLASSES.has(className)) return VEHICLE_COLOR;
5433
- if (ANIMAL_CLASSES.has(className)) return ANIMAL_COLOR;
5434
- return GENERIC_DETECTION_COLOR;
5435
- }
5436
- function titleCase(s) {
5437
- return s.length > 0 ? s.charAt(0).toUpperCase() + s.slice(1) : s;
5438
- }
5439
5669
  /**
5440
- * Full event-kind list for a camera. Sensor kinds are deduped per
5441
- * (kind, source deviceId)two linked contact sensors each contribute
5442
- * their own entry, distinguishable by `source.deviceId`.
5670
+ * Full event-kind taxonomy a camera CAN emit (config-derived):
5671
+ * (a) motion built-inalways;
5672
+ * (b) each enabled detection macro + its taxonomy subs (reverse COCO);
5673
+ * (c) audio macro + its subs when the classifier is enabled;
5674
+ * (d) package kinds when a package zone rule is enabled;
5675
+ * (e) sensor/control kinds from LINKED devices (binding-driven), deduped
5676
+ * per (kind, source deviceId).
5677
+ * Every descriptor carries parentKind/level/iconId/labelKey via the single
5678
+ * taxonomy dictionary — no color/icon is declared here.
5443
5679
  */
5444
5680
  async function composeEventKinds(deps, deviceId) {
5445
- const out = [{
5446
- kind: "motion",
5447
- label: "Motion",
5448
- color: MOTION_COLOR,
5449
- icon: "motion",
5450
- category: "motion",
5451
- source: {
5452
- capName: "pipeline-analytics",
5453
- deviceId
5454
- }
5455
- }, {
5456
- kind: "audio",
5457
- label: "Audio",
5458
- color: AUDIO_COLOR,
5459
- icon: "audio",
5460
- category: "audio",
5461
- source: {
5462
- capName: "pipeline-analytics",
5463
- deviceId
5464
- }
5465
- }];
5681
+ const out = [];
5682
+ const cameraSource = {
5683
+ capName: "pipeline-analytics",
5684
+ deviceId
5685
+ };
5686
+ const pushKind = (kind, source = cameraSource) => {
5687
+ const d = require_dist.buildEventKindDescriptor(kind, source);
5688
+ if (d !== null) out.push(d);
5689
+ };
5690
+ pushKind("motion");
5466
5691
  try {
5467
- const classNames = await deps.observedClassNames(deviceId);
5468
- for (const className of [...classNames].sort()) out.push({
5469
- kind: className,
5470
- label: titleCase(className),
5471
- color: detectionColor(className),
5472
- icon: detectionIcon(className),
5473
- category: "detection",
5474
- source: {
5475
- capName: "pipeline-analytics",
5476
- deviceId
5477
- }
5478
- });
5692
+ const macros = await deps.enabledMacroClasses(deviceId);
5693
+ for (const macro of macros) {
5694
+ pushKind(macro);
5695
+ for (const sub of require_dist.subKindsOf(macro)) pushKind(sub.kind);
5696
+ }
5479
5697
  } catch (err) {
5480
- deps.onError?.("observedClassNames", err);
5698
+ deps.onError?.("enabledMacroClasses", err);
5699
+ }
5700
+ try {
5701
+ if (await deps.audioEnabled(deviceId)) {
5702
+ pushKind("audio");
5703
+ for (const sub of require_dist.subKindsOf("audio")) pushKind(sub.kind);
5704
+ }
5705
+ } catch (err) {
5706
+ deps.onError?.("audioEnabled", err);
5481
5707
  }
5482
5708
  try {
5483
5709
  if (deps.packageZonesEnabled && await deps.packageZonesEnabled(deviceId)) {
5484
- out.push({
5485
- kind: "package-delivered",
5486
- label: "Package delivered",
5487
- color: PACKAGE_COLOR,
5488
- icon: "package",
5489
- category: "package",
5490
- source: {
5491
- capName: "pipeline-analytics",
5492
- deviceId
5493
- }
5494
- });
5495
- out.push({
5496
- kind: "package-picked-up",
5497
- label: "Package picked up",
5498
- color: PACKAGE_COLOR,
5499
- icon: "package",
5500
- category: "package",
5501
- source: {
5502
- capName: "pipeline-analytics",
5503
- deviceId
5504
- }
5505
- });
5710
+ pushKind("package");
5711
+ for (const sub of require_dist.subKindsOf("package")) pushKind(sub.kind);
5506
5712
  }
5507
5713
  } catch (err) {
5508
5714
  deps.onError?.("packageZonesEnabled", err);
@@ -5525,16 +5731,9 @@ async function composeEventKinds(deps, deviceId) {
5525
5731
  const dedupeKey = `${descriptor.kind}:${linked.deviceId}`;
5526
5732
  if (seen.has(dedupeKey)) continue;
5527
5733
  seen.add(dedupeKey);
5528
- out.push({
5529
- kind: descriptor.kind,
5530
- label: descriptor.label,
5531
- color: descriptor.color,
5532
- icon: descriptor.icon,
5533
- category: descriptor.category,
5534
- source: {
5535
- capName,
5536
- deviceId: linked.deviceId
5537
- }
5734
+ pushKind(descriptor.kind, {
5735
+ capName,
5736
+ deviceId: linked.deviceId
5538
5737
  });
5539
5738
  }
5540
5739
  }
@@ -5630,6 +5829,110 @@ async function ingestSensorStateChange(deps, data, timestamp) {
5630
5829
  return inserted;
5631
5830
  }
5632
5831
  //#endregion
5832
+ //#region src/pipeline-analytics/services/synthetic-sensor-track.ts
5833
+ /**
5834
+ * Synthetic sensor/control tracks (event-kinds-taxonomy §8).
5835
+ *
5836
+ * A linked sensor/control state change is projected into the UNIFIED track
5837
+ * store as a SYNTHETIC track: `className` = the event kind (`contact` /
5838
+ * `lock` / `switch` …), `source: 'sensor'`, media = an on-demand snapshot of
5839
+ * the linked camera in the SAME `getTrackMedia` shape the UIs consume (no
5840
+ * bbox → full frame), `positions: []`. It joins the timeline via the existing
5841
+ * time-based clustering — no new join mechanism.
5842
+ *
5843
+ * The raw `SensorEvent` remains the durable record; this track is its
5844
+ * projection. Snapshots are debounced per `(camera, kind)` so a chattery
5845
+ * sensor doesn't hammer the snapshot cap. Spatial consumers MUST skip
5846
+ * `source:'sensor'` tracks (they carry no trajectory).
5847
+ */
5848
+ var DEFAULT_DEBOUNCE_MS = 1e4;
5849
+ /** Full-frame, position-less placeholder (spatial consumers skip these). */
5850
+ function zeroPosition(timestamp) {
5851
+ return {
5852
+ x: 0,
5853
+ y: 0,
5854
+ timestamp,
5855
+ bbox: {
5856
+ x: 0,
5857
+ y: 0,
5858
+ w: 0,
5859
+ h: 0
5860
+ }
5861
+ };
5862
+ }
5863
+ var SyntheticSensorTrackMaterializer = class {
5864
+ deps;
5865
+ debounceMs;
5866
+ makeId;
5867
+ now;
5868
+ /** Last snapshot time per `${cameraId}:${kind}`. */
5869
+ lastAt = /* @__PURE__ */ new Map();
5870
+ constructor(deps) {
5871
+ this.deps = deps;
5872
+ this.debounceMs = deps.debounceMs ?? DEFAULT_DEBOUNCE_MS;
5873
+ this.makeId = deps.makeId ?? (() => `pa-synth-${(0, node_crypto.randomUUID)()}`);
5874
+ this.now = deps.now ?? Date.now;
5875
+ }
5876
+ /**
5877
+ * Materialize a synthetic track for a sensor/control event on `cameraId`.
5878
+ * Returns the persisted track, or null when debounced. Snapshot failure
5879
+ * still lands a track (with no media) — it never blocks the sensor record.
5880
+ */
5881
+ async materialize(input) {
5882
+ const key = `${input.cameraId}:${input.kind}`;
5883
+ const last = this.lastAt.get(key);
5884
+ if (last !== void 0 && this.now() - last < this.debounceMs) return null;
5885
+ this.lastAt.set(key, this.now());
5886
+ const trackId = this.makeId();
5887
+ const ts = input.timestamp;
5888
+ let mediaKey = null;
5889
+ try {
5890
+ const snap = await this.deps.snapshot.getSnapshot({
5891
+ deviceId: input.cameraId,
5892
+ force: true
5893
+ });
5894
+ if (snap !== null) {
5895
+ const data = Buffer.from(snap.base64, "base64");
5896
+ mediaKey = await this.deps.media.put({
5897
+ deviceId: input.cameraId,
5898
+ ownerKind: "track",
5899
+ ownerId: trackId,
5900
+ kind: "snapshot",
5901
+ timestamp: ts,
5902
+ data
5903
+ });
5904
+ } else this.deps.onError?.("getSnapshot", /* @__PURE__ */ new Error("snapshot returned null"));
5905
+ } catch (err) {
5906
+ this.deps.onError?.("snapshotMedia", err);
5907
+ }
5908
+ const track = {
5909
+ trackId,
5910
+ deviceId: input.cameraId,
5911
+ className: input.kind,
5912
+ source: "sensor",
5913
+ firstSeen: ts,
5914
+ lastSeen: ts,
5915
+ positions: [],
5916
+ snapshots: mediaKey !== null ? [{
5917
+ timestamp: ts,
5918
+ position: zeroPosition(ts),
5919
+ mediaKey
5920
+ }] : [],
5921
+ zonesVisited: [],
5922
+ totalDistance: 0,
5923
+ state: "idle",
5924
+ active: false
5925
+ };
5926
+ try {
5927
+ await this.deps.tracks.persistSyntheticTrack(track);
5928
+ } catch (err) {
5929
+ this.deps.onError?.("persistSyntheticTrack", err);
5930
+ return null;
5931
+ }
5932
+ return track;
5933
+ }
5934
+ };
5935
+ //#endregion
5633
5936
  //#region src/shared/frame/resolve-frame.ts
5634
5937
  /**
5635
5938
  * Resolve the pixels a `FrameHandle` refers to via the node-routed fetch.
@@ -5677,7 +5980,7 @@ function squareSafeCropRegion(bbox, frame, padding) {
5677
5980
  }
5678
5981
  //#endregion
5679
5982
  //#region src/shared/frame/box-drawer.ts
5680
- var DEFAULT_COLOR = "#22ff55";
5983
+ var DEFAULT_COLOR = require_dist.DEFAULT_EVENT_COLOR;
5681
5984
  var DEFAULT_QUALITY = 80;
5682
5985
  var STROKE_WIDTH = 3;
5683
5986
  function escapeXml(s) {
@@ -10964,11 +11267,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
10964
11267
  stationaryRegistry = null;
10965
11268
  mediaStore = null;
10966
11269
  eventStore = null;
11270
+ /** Events-domain ops-log (footprint/prune/manual-delete audit). Null until onInitialize. */
11271
+ eventsOpsLog = null;
10967
11272
  /** Per-camera history of LINKED-device sensor state changes (Part B). */
10968
11273
  sensorEventStore = null;
10969
11274
  /** Ingest-side reverse index (sensor device → linked camera ids), TTL-cached
10970
11275
  * so the DeviceStateChanged handler stays cheap. */
10971
11276
  linkedCamerasCache = null;
11277
+ /** Projects sensor/control events into synthetic snapshot-carrying tracks. */
11278
+ syntheticSensorTracks = null;
10972
11279
  identityStore = null;
10973
11280
  faceStore = null;
10974
11281
  faceRecognizer = null;
@@ -11126,20 +11433,22 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11126
11433
  await VehicleStore.declare(api.settingsStore);
11127
11434
  await ObjectEmbeddingStore.declare(api.settingsStore);
11128
11435
  await StationaryObjectRegistry.declare(api.settingsStore);
11436
+ await OpsLogStore.declare(api.settingsStore);
11129
11437
  const logger = this.ctx.logger;
11130
11438
  let storage = this.ctx.kernel.storage;
11131
11439
  const mediaRoot = process.env.CAMSTACK_MEDIA_ROOT?.trim();
11132
11440
  if (mediaRoot) {
11133
- const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-E-m2CYYM.js"));
11441
+ const { FilesystemStorageProvider } = await Promise.resolve().then(() => require("../node-BRwocT7C.js"));
11134
11442
  storage = new FilesystemStorageProvider(mediaRoot);
11135
11443
  logger.info("pipeline-analytics: event media rooted at CAMSTACK_MEDIA_ROOT", { meta: { mediaRoot } });
11136
11444
  }
11137
11445
  if (!storage) throw new Error("pipeline-analytics requires ctx.kernel.storage");
11138
- this.trackStore = new TrackStore({
11446
+ const trackStore = new TrackStore({
11139
11447
  store: api.settingsStore,
11140
11448
  logger: logger.child("TrackStore"),
11141
11449
  frameDims: (deviceId) => this.lastFrameDimsByDevice.get(deviceId)
11142
11450
  });
11451
+ this.trackStore = trackStore;
11143
11452
  this.stationaryRegistry = new StationaryObjectRegistry({
11144
11453
  store: api.settingsStore,
11145
11454
  logger: logger.child("StationaryRegistry"),
@@ -11169,11 +11478,12 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11169
11478
  }
11170
11479
  });
11171
11480
  await this.stationaryRegistry.load();
11172
- this.mediaStore = new MediaStore({
11481
+ const mediaStore = new MediaStore({
11173
11482
  storage,
11174
11483
  store: api.settingsStore,
11175
11484
  logger: logger.child("MediaStore")
11176
11485
  });
11486
+ this.mediaStore = mediaStore;
11177
11487
  const eventStore = new EventStore({
11178
11488
  store: api.settingsStore,
11179
11489
  logger: logger.child("EventStore"),
@@ -11233,6 +11543,15 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11233
11543
  error: require_dist.errMsg(err)
11234
11544
  } })
11235
11545
  });
11546
+ this.syntheticSensorTracks = new SyntheticSensorTrackMaterializer({
11547
+ snapshot: { getSnapshot: (i) => api.snapshot.getSnapshot.query(i) },
11548
+ media: mediaStore,
11549
+ tracks: trackStore,
11550
+ onError: (scope, err) => logger.warn("synthetic sensor-track error", { meta: {
11551
+ scope,
11552
+ error: require_dist.errMsg(err)
11553
+ } })
11554
+ });
11236
11555
  this.identityStore = new IdentityStore({
11237
11556
  store: api.settingsStore,
11238
11557
  logger: logger.child("IdentityStore")
@@ -11251,6 +11570,11 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
11251
11570
  });
11252
11571
  const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
11253
11572
  const ownNodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
11573
+ this.eventsOpsLog = new OpsLogStore({
11574
+ store: api.settingsStore,
11575
+ logger: logger.child("ops-log"),
11576
+ nodeId: ownNodeId
11577
+ });
11254
11578
  {
11255
11579
  const designated = await this.postProcessingNodeState.get();
11256
11580
  this.isPostProcessingNode = ownNodeId === designated;
@@ -13465,10 +13789,21 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13465
13789
  */
13466
13790
  async listEventKinds(input) {
13467
13791
  const api = this.ctx.api;
13792
+ const status = await api.pipelineOrchestrator.getCameraStatus.query({ deviceId: input.deviceId }).catch((err) => {
13793
+ this.ctx.logger.warn("listEventKinds: getCameraStatus failed", {
13794
+ tags: { deviceId: input.deviceId },
13795
+ meta: { error: require_dist.errMsg(err) }
13796
+ });
13797
+ return null;
13798
+ });
13799
+ const detectionOn = status?.detection != null;
13800
+ const audioOn = status?.audio?.enabled === true;
13801
+ const detectionMacros = require_dist.MACRO_LABELS.map((l) => l.id).filter((id) => id !== "package");
13468
13802
  return composeEventKinds({
13469
13803
  linkedDevices: { getLinkedDevices: (i) => api.deviceManager.getLinkedDevices.query(i) },
13470
13804
  bindings: { getBindings: (i) => api.deviceManager.getBindings.query(i) },
13471
- observedClassNames: async (deviceId) => this.trackStore?.observedClassNames(deviceId) ?? [],
13805
+ enabledMacroClasses: async () => detectionOn ? detectionMacros : [],
13806
+ audioEnabled: async () => audioOn,
13472
13807
  packageZonesEnabled: async (deviceId) => {
13473
13808
  return (await this.resolveDevicePackageRules(deviceId)).some((r) => r.enabled !== false);
13474
13809
  },
@@ -13508,6 +13843,26 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13508
13843
  }
13509
13844
  });
13510
13845
  }
13846
+ const materializer = this.syntheticSensorTracks;
13847
+ const descriptor = require_dist.EVENT_KIND_BY_CAP[data.capName];
13848
+ if (materializer === null || descriptor === void 0) return;
13849
+ try {
13850
+ const cameraIds = await cache.camerasFor(data.deviceId);
13851
+ for (const cameraId of cameraIds) await materializer.materialize({
13852
+ cameraId,
13853
+ sourceDeviceId: data.deviceId,
13854
+ kind: descriptor.kind,
13855
+ timestamp
13856
+ });
13857
+ } catch (err) {
13858
+ this.ctx.logger.warn("synthetic sensor-track materialize failed", {
13859
+ tags: { deviceId: data.deviceId },
13860
+ meta: {
13861
+ capName: data.capName,
13862
+ error: require_dist.errMsg(err)
13863
+ }
13864
+ });
13865
+ }
13511
13866
  }
13512
13867
  async clearTracks(input) {
13513
13868
  this.trackStore?.clearDevice(input.deviceId);
@@ -13756,6 +14111,120 @@ var PipelineAnalyticsAddon = class extends require_dist.BaseAddon {
13756
14111
  };
13757
14112
  }
13758
14113
  /**
14114
+ * Durable event-store footprint for the events-management UI: event ROWS
14115
+ * (motion + object + audio) counted per camera (indexed `count`) + total, and
14116
+ * event-owned media BYTES on disk per camera (paged `sizeBytes` sum) + total.
14117
+ * The device set is the union of cameras that have media OR persisted tracks;
14118
+ * cameras with neither rows nor bytes are omitted.
14119
+ */
14120
+ async getEventStoreFootprint() {
14121
+ const eventStore = this.eventStore;
14122
+ const mediaStore = this.mediaStore;
14123
+ if (!eventStore || !mediaStore) return {
14124
+ totalRows: 0,
14125
+ totalBytes: 0,
14126
+ devices: []
14127
+ };
14128
+ const mediaByDevice = await mediaStore.footprintByDevice();
14129
+ const deviceIds = new Set(mediaByDevice.keys());
14130
+ if (this.trackStore) for (const id of await this.trackStore.listDeviceIds()) deviceIds.add(id);
14131
+ const devices = [];
14132
+ let totalRows = 0;
14133
+ let totalBytes = 0;
14134
+ for (const deviceId of deviceIds) {
14135
+ const rows = await eventStore.countForDevice(deviceId);
14136
+ const bytes = mediaByDevice.get(deviceId)?.bytes ?? 0;
14137
+ if (rows === 0 && bytes === 0) continue;
14138
+ totalRows += rows;
14139
+ totalBytes += bytes;
14140
+ devices.push({
14141
+ deviceId,
14142
+ rows,
14143
+ bytes
14144
+ });
14145
+ }
14146
+ devices.sort((a, b) => b.bytes - a.bytes);
14147
+ return {
14148
+ totalRows,
14149
+ totalBytes,
14150
+ devices
14151
+ };
14152
+ }
14153
+ /**
14154
+ * Cluster-wide prune of events with `timestamp < olderThanMs` across every
14155
+ * camera (via the device-agnostic `evictBefore`), deleting each pruned event's
14156
+ * media in lockstep. Logs one global (`deviceId:null`) events ops-log row.
14157
+ * `bytesReclaimed` is 0 (media deletion does not surface freed bytes).
14158
+ */
14159
+ async pruneEvents(input) {
14160
+ const eventStore = this.eventStore;
14161
+ if (!eventStore) return {
14162
+ motion: 0,
14163
+ object: 0,
14164
+ audio: 0
14165
+ };
14166
+ const cutoff = input.olderThanMs;
14167
+ const evicted = await eventStore.evictBefore({
14168
+ motionCutoffMs: cutoff,
14169
+ objectCutoffMs: cutoff,
14170
+ audioCutoffMs: cutoff
14171
+ });
14172
+ const ids = [
14173
+ ...evicted.motion,
14174
+ ...evicted.object,
14175
+ ...evicted.audio
14176
+ ];
14177
+ if (ids.length > 0 && this.mediaStore) await this.mediaStore.deleteForEvents(ids);
14178
+ const counts = {
14179
+ motion: evicted.motion.length,
14180
+ object: evicted.object.length,
14181
+ audio: evicted.audio.length
14182
+ };
14183
+ const total = counts.motion + counts.object + counts.audio;
14184
+ await this.eventsOpsLog?.append({
14185
+ op: "prune",
14186
+ reason: input.reason ?? "retention",
14187
+ deviceId: null,
14188
+ itemsAffected: total,
14189
+ bytesReclaimed: 0,
14190
+ detail: `pruned events older than ${cutoff}`
14191
+ });
14192
+ if (total > 0) this.ctx.logger.info("analytics events pruned (cluster)", { meta: {
14193
+ cutoffMs: cutoff,
14194
+ ...counts
14195
+ } });
14196
+ return counts;
14197
+ }
14198
+ /**
14199
+ * Manually delete EVERY event (motion + object + audio) for one camera and its
14200
+ * event-owned media in lockstep. Logs a manual events ops-log row.
14201
+ */
14202
+ async deleteDeviceEvents(input) {
14203
+ if (!this.eventStore) return {
14204
+ motion: 0,
14205
+ object: 0,
14206
+ audio: 0
14207
+ };
14208
+ const counts = await this.pruneEventsBefore({
14209
+ deviceId: input.deviceId,
14210
+ cutoffMs: Number.MAX_SAFE_INTEGER
14211
+ });
14212
+ const total = counts.motion + counts.object + counts.audio;
14213
+ await this.eventsOpsLog?.append({
14214
+ op: "manual-delete",
14215
+ reason: "manual",
14216
+ deviceId: input.deviceId,
14217
+ itemsAffected: total,
14218
+ bytesReclaimed: 0,
14219
+ detail: "deleted all events for device"
14220
+ });
14221
+ return counts;
14222
+ }
14223
+ /** The events ops-log rows (newest-first), optionally scoped to one camera. */
14224
+ async listOpsLog(input) {
14225
+ return await this.eventsOpsLog?.list(input) ?? [];
14226
+ }
14227
+ /**
13759
14228
  * Track-centric time-based retention (design §5.1). Drains every persisted
13760
14229
  * track for the device whose `lastSeen < cutoffMs`, page by page, through the
13761
14230
  * widened cascade — enrolled faces/plates + identity media are exempt (design