@camstack/system 1.2.262 → 1.2.264
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.
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.js +1 -1
- package/dist/builtins/addon-pages-aggregator/addon-pages-aggregator.addon.mjs +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +1 -1
- package/dist/builtins/alerts/alerts.addon.js +1 -1
- package/dist/builtins/alerts/alerts.addon.mjs +1 -1
- package/dist/builtins/autotrack/index.js +1 -1
- package/dist/builtins/autotrack/index.mjs +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.js +1 -1
- package/dist/builtins/backup-orchestrator/backup-orchestrator.addon.mjs +1 -1
- package/dist/builtins/camera-grid/addon.d.ts +38 -13
- package/dist/builtins/camera-grid/grid-activity-mirror.d.ts +32 -0
- package/dist/builtins/camera-grid/grid-activity.d.ts +50 -0
- package/dist/builtins/camera-grid/grid-camera-device.d.ts +11 -1
- package/dist/builtins/camera-grid/grid-canvas.d.ts +83 -0
- package/dist/builtins/camera-grid/grid-compositor.d.ts +36 -0
- package/dist/builtins/camera-grid/grid-detection-mapping.d.ts +31 -0
- package/dist/builtins/camera-grid/grid-profiles.d.ts +28 -33
- package/dist/builtins/camera-grid/grid-snapshot.d.ts +4 -4
- package/dist/builtins/camera-grid/grid-stream-descriptor.d.ts +4 -6
- package/dist/builtins/camera-grid/grid-stream-relay.d.ts +7 -11
- package/dist/builtins/camera-grid/grid-tile-feed.d.ts +38 -0
- package/dist/builtins/camera-grid/grid-tile-plan.d.ts +32 -0
- package/dist/builtins/camera-grid/index.d.ts +2 -2
- package/dist/builtins/camera-grid/index.js +593 -223
- package/dist/builtins/camera-grid/index.mjs +591 -220
- package/dist/builtins/camera-grid/silence-analysis.d.ts +7 -0
- package/dist/builtins/console-logging/index.js +1 -1
- package/dist/builtins/console-logging/index.mjs +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.js +1 -1
- package/dist/builtins/core-blocks/core-blocks.addon.mjs +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +2 -2
- package/dist/builtins/device-manager/device-manager.addon.mjs +2 -2
- package/dist/builtins/doorbell/virtual-doorbell.addon.js +1 -1
- package/dist/builtins/doorbell/virtual-doorbell.addon.mjs +1 -1
- package/dist/builtins/hub-forwarder/index.js +1 -1
- package/dist/builtins/hub-forwarder/index.mjs +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.js +1 -1
- package/dist/builtins/liveness-monitor/liveness-monitor.addon.mjs +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +1 -1
- package/dist/builtins/local-network/local-network.addon.js +1 -1
- package/dist/builtins/local-network/local-network.addon.mjs +1 -1
- package/dist/builtins/loki-logging/index.js +1 -1
- package/dist/builtins/loki-logging/index.mjs +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.js +1 -1
- package/dist/builtins/native-metrics/native-metrics.addon.mjs +1 -1
- package/dist/builtins/platform-probe/index.js +1 -1
- package/dist/builtins/platform-probe/index.mjs +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +1 -1
- package/dist/builtins/snapshot/index.js +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.js +1 -1
- package/dist/builtins/sqlite-storage/filesystem-storage.addon.mjs +1 -1
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.js +0 -0
- package/dist/builtins/sqlite-storage/sqlite-settings.addon.mjs +0 -0
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.js +1 -1
- package/dist/builtins/storage-orchestrator/storage-orchestrator.addon.mjs +1 -1
- package/dist/builtins/system-config/system-config.addon.js +1 -1
- package/dist/builtins/system-config/system-config.addon.mjs +1 -1
- package/dist/builtins/winston-logging/index.js +1 -1
- package/dist/builtins/winston-logging/index.mjs +1 -1
- package/dist/{dist-6hiXbrNY.js → dist-BhYweLVr.js} +74 -15
- package/dist/{dist-CQ7ItBIG.mjs → dist-CO2O_ddo.mjs} +61 -8
- package/dist/index.js +1 -1
- package/dist/index.mjs +1 -1
- package/dist/{retired-settings-keys-CCXmaFTK.mjs → retired-settings-keys-BnnHWyfj.mjs} +1 -1
- package/dist/{retired-settings-keys-AmmGrCp1.js → retired-settings-keys-CKLI8VmE.js} +1 -1
- package/package.json +4 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { $t as streamCatalogCapability, At as motionCapability, Jt as snapshotCapability, Pn as EventCategory, U as cameraGridLayoutCapability, Y as declarationOwnerNodeId, an as errMsg, b as MOTION_CLOSE_AFTER_MS, bn as createEvent, dn as DeviceFeature, f as DERIVED_CAMERA_SILENCED_CAP_NAMES, i as BaseDevice, on as buildFfmpegArgs, p as DeclaredDevices, pn as DeviceType, sn as BaseAddon, wn as hydrateSchema } from "../../dist-CO2O_ddo.mjs";
|
|
2
2
|
import { z } from "zod";
|
|
3
3
|
import { FfmpegProcess } from "@camstack/types/node";
|
|
4
4
|
import { join } from "node:path";
|
|
@@ -192,6 +192,10 @@ var GridCameraDevice = class extends BaseDevice {
|
|
|
192
192
|
getSnapshot: async ({ deviceId }) => activeRuntime().getSnapshot(deviceId),
|
|
193
193
|
invalidateCache: async () => {}
|
|
194
194
|
});
|
|
195
|
+
this.ctx.registerNativeCap(motionCapability, {
|
|
196
|
+
getStatus: async ({ deviceId }) => activeRuntime().motionStatus(deviceId),
|
|
197
|
+
isDetected: async ({ deviceId }) => activeRuntime().motionStatus(deviceId).detected
|
|
198
|
+
});
|
|
195
199
|
this.markOnline(true);
|
|
196
200
|
}
|
|
197
201
|
catalog() {
|
|
@@ -395,6 +399,330 @@ function normalizeGridRows(raw, mintId) {
|
|
|
395
399
|
};
|
|
396
400
|
}
|
|
397
401
|
//#endregion
|
|
402
|
+
//#region src/builtins/camera-grid/grid-activity-mirror.ts
|
|
403
|
+
/**
|
|
404
|
+
* What a grid says about ITSELF, kept from what its sources said.
|
|
405
|
+
*
|
|
406
|
+
* ## Why the grid answers at all
|
|
407
|
+
*
|
|
408
|
+
* A grid is a camera, and the viewer already knows how to light a camera: the
|
|
409
|
+
* motion ring keys off `motion.on-motion-changed` filtered by `deviceId`, the
|
|
410
|
+
* audio one off `pipeline.audio-inference-result`. So the grid does not need a
|
|
411
|
+
* new channel, a new cap or a line of app code — it needs to answer those
|
|
412
|
+
* questions about its own device id. The operator set both rules: motion is on
|
|
413
|
+
* if AT LEAST ONE source has it, and the audio level is the LOUDEST of them.
|
|
414
|
+
*
|
|
415
|
+
* It is derived from the SOURCES' events and never from the composed picture.
|
|
416
|
+
* Analysing the composite would be a second motion authority for cameras that
|
|
417
|
+
* already have one, and a decode this addon does not pay today.
|
|
418
|
+
*
|
|
419
|
+
* ## The falling edge is the whole difficulty
|
|
420
|
+
*
|
|
421
|
+
* Bus events are telemetry and may be dropped (D8/D11). A lost `detected:
|
|
422
|
+
* false` would leave a grid lit for ever, and the grid has no phase machine to
|
|
423
|
+
* produce a falling edge of its own — a real camera's comes from the pipeline
|
|
424
|
+
* runner, which a grid deliberately does not run.
|
|
425
|
+
*
|
|
426
|
+
* So a source's motion is remembered WITH ITS TIME and expires on its own. That
|
|
427
|
+
* is D49's shape: in-memory state refreshed off the event path, never a gate
|
|
428
|
+
* that re-reads something fallible in order to decide. A dropped clear costs a
|
|
429
|
+
* ring that stays lit until the expiry instead of for ever, and a subject that
|
|
430
|
+
* is genuinely still there keeps renewing it.
|
|
431
|
+
*
|
|
432
|
+
* The expiry is `MOTION_CLOSE_AFTER_MS`, imported and not re-spelled: it is the
|
|
433
|
+
* system's one answer to "how long after the last push is a camera still
|
|
434
|
+
* moving", and a second number here would be a second authority disagreeing
|
|
435
|
+
* with the first on exactly the cameras that make up the grid.
|
|
436
|
+
*/
|
|
437
|
+
/**
|
|
438
|
+
* How long an audio reading counts as current.
|
|
439
|
+
*
|
|
440
|
+
* The analyzer emits one per window per camera, so a source that has gone quiet
|
|
441
|
+
* simply stops arriving — there is no "silence" event to wait for. Without a
|
|
442
|
+
* window the loudest reading of the day would be the grid's level for ever.
|
|
443
|
+
* Chosen to span several analysis windows so an ordinary gap does not blink the
|
|
444
|
+
* level away, and to be far shorter than a person notices a stale ring.
|
|
445
|
+
*/
|
|
446
|
+
var AUDIO_FRESH_MS = 5e3;
|
|
447
|
+
var GridActivityMirror = class {
|
|
448
|
+
motion = /* @__PURE__ */ new Map();
|
|
449
|
+
audio = /* @__PURE__ */ new Map();
|
|
450
|
+
lastDetectedAt = null;
|
|
451
|
+
/** One source's motion push. `detected: false` is as important as `true`. */
|
|
452
|
+
noteMotion(sourceDeviceId, detected, atMs) {
|
|
453
|
+
this.motion.set(sourceDeviceId, {
|
|
454
|
+
detected,
|
|
455
|
+
atMs
|
|
456
|
+
});
|
|
457
|
+
if (detected) this.lastDetectedAt = Math.max(this.lastDetectedAt ?? 0, atMs);
|
|
458
|
+
}
|
|
459
|
+
/** One source's audio window. `dbfs` is the level, on the analyzer's scale. */
|
|
460
|
+
noteAudio(sourceDeviceId, dbfs, atMs) {
|
|
461
|
+
this.audio.set(sourceDeviceId, {
|
|
462
|
+
dbfs,
|
|
463
|
+
atMs
|
|
464
|
+
});
|
|
465
|
+
}
|
|
466
|
+
/**
|
|
467
|
+
* Drop every source this grid no longer composes.
|
|
468
|
+
*
|
|
469
|
+
* A cell removed while its camera was moving would otherwise hold the grid
|
|
470
|
+
* lit until the expiry, for a picture the source is not even in.
|
|
471
|
+
*/
|
|
472
|
+
keepOnly(sourceDeviceIds) {
|
|
473
|
+
const keep = new Set(sourceDeviceIds);
|
|
474
|
+
const motionIds = Array.from(this.motion.keys());
|
|
475
|
+
const audioIds = Array.from(this.audio.keys());
|
|
476
|
+
for (const id of motionIds) if (!keep.has(id)) this.motion.delete(id);
|
|
477
|
+
for (const id of audioIds) if (!keep.has(id)) this.audio.delete(id);
|
|
478
|
+
}
|
|
479
|
+
/** At least one source moving, as of `nowMs`. */
|
|
480
|
+
motionAt(nowMs) {
|
|
481
|
+
let detected = false;
|
|
482
|
+
for (const state of this.motion.values()) {
|
|
483
|
+
if (!state.detected) continue;
|
|
484
|
+
if (nowMs - state.atMs > 15e3) continue;
|
|
485
|
+
detected = true;
|
|
486
|
+
break;
|
|
487
|
+
}
|
|
488
|
+
return {
|
|
489
|
+
detected,
|
|
490
|
+
lastDetectedAt: this.lastDetectedAt
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
/**
|
|
494
|
+
* The loudest CURRENT reading, or `null` when nothing fresh has been heard.
|
|
495
|
+
*
|
|
496
|
+
* Null and not `-Infinity`: "nobody has reported a level" is a different
|
|
497
|
+
* answer from "it is silent", and a number here would have the viewer draw a
|
|
498
|
+
* level for a grid whose sources are not being analysed at all.
|
|
499
|
+
*/
|
|
500
|
+
loudestAt(nowMs) {
|
|
501
|
+
let loudest = null;
|
|
502
|
+
for (const state of this.audio.values()) {
|
|
503
|
+
if (nowMs - state.atMs > AUDIO_FRESH_MS) continue;
|
|
504
|
+
if (loudest === null || state.dbfs > loudest) loudest = state.dbfs;
|
|
505
|
+
}
|
|
506
|
+
return loudest;
|
|
507
|
+
}
|
|
508
|
+
};
|
|
509
|
+
//#endregion
|
|
510
|
+
//#region src/builtins/camera-grid/grid-detection-mapping.ts
|
|
511
|
+
/**
|
|
512
|
+
* Where this detection lands on the canvas — once per cell that shows it.
|
|
513
|
+
*
|
|
514
|
+
* Empty is a real answer: the subject is in a part of the source this grid does
|
|
515
|
+
* not display, or the frame arrived with no size to measure against. Neither is
|
|
516
|
+
* a reason to guess.
|
|
517
|
+
*/
|
|
518
|
+
function mapDetectionToCanvas(input) {
|
|
519
|
+
const { width: frameW, height: frameH } = input.sourceFrame;
|
|
520
|
+
if (frameW <= 0 || frameH <= 0) return [];
|
|
521
|
+
const left = input.bbox.x / frameW;
|
|
522
|
+
const top = input.bbox.y / frameH;
|
|
523
|
+
const right = (input.bbox.x + input.bbox.width) / frameW;
|
|
524
|
+
const bottom = (input.bbox.y + input.bbox.height) / frameH;
|
|
525
|
+
const out = [];
|
|
526
|
+
for (const { source, cell } of input.cells) {
|
|
527
|
+
if (source.width <= 0 || source.height <= 0) continue;
|
|
528
|
+
const l = Math.max(left, source.x);
|
|
529
|
+
const t = Math.max(top, source.y);
|
|
530
|
+
const r = Math.min(right, source.x + source.width);
|
|
531
|
+
const b = Math.min(bottom, source.y + source.height);
|
|
532
|
+
if (r <= l || b <= t) continue;
|
|
533
|
+
const cx = (l - source.x) / source.width;
|
|
534
|
+
const cy = (t - source.y) / source.height;
|
|
535
|
+
const cw = (r - l) / source.width;
|
|
536
|
+
const ch = (b - t) / source.height;
|
|
537
|
+
out.push({
|
|
538
|
+
x: (cell.x + cx * cell.width) * input.canvas.width,
|
|
539
|
+
y: (cell.y + cy * cell.height) * input.canvas.height,
|
|
540
|
+
width: cw * cell.width * input.canvas.width,
|
|
541
|
+
height: ch * cell.height * input.canvas.height
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
return out;
|
|
545
|
+
}
|
|
546
|
+
//#endregion
|
|
547
|
+
//#region src/builtins/camera-grid/grid-activity.ts
|
|
548
|
+
/**
|
|
549
|
+
* The grid's own motion / audio / detection, forwarded from its sources.
|
|
550
|
+
*
|
|
551
|
+
* ## Why the grid speaks a camera's language instead of a new one
|
|
552
|
+
*
|
|
553
|
+
* The operator asked for the grid's tile to light up in the viewer exactly as a
|
|
554
|
+
* camera's card does, with NO app change. The app already knows how: it filters
|
|
555
|
+
* `motion.on-motion-changed` by `deviceId` for the motion ring,
|
|
556
|
+
* `pipeline.audio-inference-result` for the audio one, and `detection.result`
|
|
557
|
+
* for the detection one. So the grid answers those three questions about its
|
|
558
|
+
* own device id, in the same shapes. Nothing new is invented, and nothing in
|
|
559
|
+
* the viewer is touched.
|
|
560
|
+
*
|
|
561
|
+
* The rules are the operator's: motion if AT LEAST ONE source has it, and the
|
|
562
|
+
* LOUDEST source's level for audio.
|
|
563
|
+
*
|
|
564
|
+
* ## What speaking a camera's language costs, and why it is safe here
|
|
565
|
+
*
|
|
566
|
+
* Those categories have other listeners, and emitting on them makes the grid a
|
|
567
|
+
* motion source for the whole system. Checked, one by one, before shipping:
|
|
568
|
+
* the recorder's event capture, the HomeKit and Alexa exports and the Home
|
|
569
|
+
* Assistant export are all per-device and do nothing until an operator enables
|
|
570
|
+
* them for this camera — and if an operator DOES export a grid to HomeKit, a
|
|
571
|
+
* grid reporting motion is correct, not a surprise.
|
|
572
|
+
*
|
|
573
|
+
* The one listener with no gate of its own was `pipeline-analytics`, which
|
|
574
|
+
* would have opened a motion EPISODE per grid, duplicating the rows its sources
|
|
575
|
+
* already write. Its gate turned out to be the `pipeline-analytics` WRAPPER
|
|
576
|
+
* BINDING, so the answer is the one the operator gave: a derived camera is
|
|
577
|
+
* created with the analytics wrappers OFF (`grid-analytics-defaults.ts`).
|
|
578
|
+
* That is a binding, not a new flag, so an operator who wants a grid analysed
|
|
579
|
+
* can simply turn it back on.
|
|
580
|
+
*
|
|
581
|
+
* ## Detection carries boxes whether or not anyone wanted them
|
|
582
|
+
*
|
|
583
|
+
* `detection.result` is the same event the viewer draws BOXES from, so a grid
|
|
584
|
+
* that reports detections reports boxes. Sending the source's own pixel box
|
|
585
|
+
* would draw it against the composed canvas at confidently wrong coordinates,
|
|
586
|
+
* so every box is carried through the cell geometry first
|
|
587
|
+
* (`grid-detection-mapping.ts`) and lands where the subject actually is.
|
|
588
|
+
*/
|
|
589
|
+
/**
|
|
590
|
+
* One mirror per grid, and the emission that follows an update.
|
|
591
|
+
*
|
|
592
|
+
* Deliberately NOT one mirror per source: the question is always "is THIS grid
|
|
593
|
+
* lit", and a source shared by two grids answers it twice, differently, because
|
|
594
|
+
* the other cells differ.
|
|
595
|
+
*/
|
|
596
|
+
var GridActivityForwarder = class {
|
|
597
|
+
deps;
|
|
598
|
+
mirrors = /* @__PURE__ */ new Map();
|
|
599
|
+
constructor(deps) {
|
|
600
|
+
this.deps = deps;
|
|
601
|
+
}
|
|
602
|
+
/** A source camera's motion push. */
|
|
603
|
+
onSourceMotion(sourceDeviceId, detected, atMs) {
|
|
604
|
+
for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
|
|
605
|
+
const mirror = this.mirrorFor(grid);
|
|
606
|
+
const before = mirror.motionAt(atMs).detected;
|
|
607
|
+
mirror.noteMotion(sourceDeviceId, detected, atMs);
|
|
608
|
+
const after = mirror.motionAt(atMs);
|
|
609
|
+
if (after.detected === before) continue;
|
|
610
|
+
this.emitMotion(grid.deviceId, after, atMs);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
/** A source camera's audio window. */
|
|
614
|
+
onSourceAudio(sourceDeviceId, dbfs, atMs) {
|
|
615
|
+
for (const grid of this.deps.gridsForSource(sourceDeviceId)) {
|
|
616
|
+
const mirror = this.mirrorFor(grid);
|
|
617
|
+
mirror.noteAudio(sourceDeviceId, dbfs, atMs);
|
|
618
|
+
const loudest = mirror.loudestAt(atMs);
|
|
619
|
+
if (loudest === null) continue;
|
|
620
|
+
this.emitAudio(grid.deviceId, loudest, atMs);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
/** A source camera's detection frame, with its boxes in SOURCE pixels. */
|
|
624
|
+
onSourceDetections(frame) {
|
|
625
|
+
for (const grid of this.deps.gridsForSource(frame.deviceId)) {
|
|
626
|
+
const cells = grid.cellsForSource(frame.deviceId);
|
|
627
|
+
if (cells.length === 0) continue;
|
|
628
|
+
const detections = [];
|
|
629
|
+
for (const detection of frame.detections) for (const bbox of mapDetectionToCanvas({
|
|
630
|
+
bbox: detection.bbox,
|
|
631
|
+
sourceFrame: {
|
|
632
|
+
width: frame.width,
|
|
633
|
+
height: frame.height
|
|
634
|
+
},
|
|
635
|
+
canvas: grid.canvas,
|
|
636
|
+
cells
|
|
637
|
+
})) {
|
|
638
|
+
const { refinedBbox: _refined, mask: _mask, ...rest } = detection;
|
|
639
|
+
detections.push({
|
|
640
|
+
...rest,
|
|
641
|
+
bbox
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
if (detections.length === 0) continue;
|
|
645
|
+
this.emitDetections(grid, frame, detections);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
/** The grid's own motion answer, for `motion.getStatus`. */
|
|
649
|
+
motionStatusFor(deviceId, nowMs) {
|
|
650
|
+
return this.mirrors.get(deviceId)?.motionAt(nowMs) ?? {
|
|
651
|
+
detected: false,
|
|
652
|
+
lastDetectedAt: null
|
|
653
|
+
};
|
|
654
|
+
}
|
|
655
|
+
/** Drop the mirror of a grid that no longer exists. */
|
|
656
|
+
forget(deviceId) {
|
|
657
|
+
this.mirrors.delete(deviceId);
|
|
658
|
+
}
|
|
659
|
+
mirrorFor(grid) {
|
|
660
|
+
let mirror = this.mirrors.get(grid.deviceId);
|
|
661
|
+
if (mirror === void 0) {
|
|
662
|
+
mirror = new GridActivityMirror();
|
|
663
|
+
this.mirrors.set(grid.deviceId, mirror);
|
|
664
|
+
}
|
|
665
|
+
mirror.keepOnly(grid.sourceDeviceIds);
|
|
666
|
+
return mirror;
|
|
667
|
+
}
|
|
668
|
+
source(deviceId) {
|
|
669
|
+
return {
|
|
670
|
+
type: "device",
|
|
671
|
+
id: deviceId,
|
|
672
|
+
addonId: this.deps.addonId,
|
|
673
|
+
deviceId
|
|
674
|
+
};
|
|
675
|
+
}
|
|
676
|
+
emitMotion(deviceId, state, atMs) {
|
|
677
|
+
const payload = {
|
|
678
|
+
deviceId,
|
|
679
|
+
detected: state.detected,
|
|
680
|
+
timestamp: atMs,
|
|
681
|
+
source: "onboard"
|
|
682
|
+
};
|
|
683
|
+
this.deps.eventBus.emit(createEvent(EventCategory.MotionOnMotionChanged, this.source(deviceId), payload));
|
|
684
|
+
this.deps.logger.debug("camera grid: motion forwarded", {
|
|
685
|
+
tags: { deviceId },
|
|
686
|
+
meta: { detected: state.detected }
|
|
687
|
+
});
|
|
688
|
+
}
|
|
689
|
+
emitAudio(deviceId, dbfs, atMs) {
|
|
690
|
+
const payload = {
|
|
691
|
+
deviceId,
|
|
692
|
+
frame: {
|
|
693
|
+
kind: "audio-window",
|
|
694
|
+
windowId: `grid-${String(deviceId)}-${String(atMs)}`,
|
|
695
|
+
deviceId,
|
|
696
|
+
timestamp: atMs,
|
|
697
|
+
startMs: atMs,
|
|
698
|
+
endMs: atMs,
|
|
699
|
+
level: {
|
|
700
|
+
rms: 0,
|
|
701
|
+
dbfs
|
|
702
|
+
},
|
|
703
|
+
detections: []
|
|
704
|
+
},
|
|
705
|
+
nodeId: "hub"
|
|
706
|
+
};
|
|
707
|
+
this.deps.eventBus.emit(createEvent(EventCategory.PipelineAudioInferenceResult, this.source(deviceId), payload));
|
|
708
|
+
}
|
|
709
|
+
emitDetections(grid, sourceFrame, detections) {
|
|
710
|
+
const payload = {
|
|
711
|
+
frame: {
|
|
712
|
+
kind: "frame",
|
|
713
|
+
frameId: `grid-${String(grid.deviceId)}-${sourceFrame.frameId}`,
|
|
714
|
+
deviceId: grid.deviceId,
|
|
715
|
+
timestamp: sourceFrame.timestamp,
|
|
716
|
+
width: grid.canvas.width,
|
|
717
|
+
height: grid.canvas.height,
|
|
718
|
+
detections
|
|
719
|
+
},
|
|
720
|
+
analysisResults: []
|
|
721
|
+
};
|
|
722
|
+
this.deps.eventBus.emit(createEvent(EventCategory.DetectionResult, this.source(grid.deviceId), payload));
|
|
723
|
+
}
|
|
724
|
+
};
|
|
725
|
+
//#endregion
|
|
398
726
|
//#region src/builtins/camera-grid/grid-wire-format.ts
|
|
399
727
|
/** What the relay answers with, and what the sampler must demux. */
|
|
400
728
|
var GRID_WIRE_CONTENT_TYPE = "video/x-flv";
|
|
@@ -595,82 +923,50 @@ var GridLastFrameFileStore = class {
|
|
|
595
923
|
//#endregion
|
|
596
924
|
//#region src/builtins/camera-grid/grid-profiles.ts
|
|
597
925
|
/**
|
|
598
|
-
* One id, for every grid, forever
|
|
926
|
+
* One id, for every grid, forever.
|
|
599
927
|
*
|
|
600
|
-
* D519's reason
|
|
601
|
-
*
|
|
602
|
-
*
|
|
603
|
-
*
|
|
928
|
+
* D519's reason is what keeps this id bare: a per-instance or newly-spelled id
|
|
929
|
+
* mints a second cam-stream and orphans whatever profile assignment the
|
|
930
|
+
* operator had made. It was `high` on the live hub before the per-profile
|
|
931
|
+
* detour and it is `high` again, so nothing an operator assigned is disturbed.
|
|
604
932
|
*/
|
|
605
933
|
var GRID_CAM_STREAM_ID = "grid";
|
|
606
|
-
/** Every profile a grid could conceivably compose, in descending quality. */
|
|
607
|
-
var GRID_PROFILES = [
|
|
608
|
-
"high",
|
|
609
|
-
"mid",
|
|
610
|
-
"low"
|
|
611
|
-
];
|
|
612
934
|
/**
|
|
613
|
-
*
|
|
614
|
-
*
|
|
615
|
-
*
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
low: .25
|
|
621
|
-
};
|
|
622
|
-
/**
|
|
623
|
-
* The smallest canvas a composition is allowed to shrink to. A grid of four
|
|
624
|
-
* cells at 40x30 is not a cheaper picture, it is no picture.
|
|
935
|
+
* The slot the single composition claims.
|
|
936
|
+
*
|
|
937
|
+
* NOT decoration. A device publishing one stream with no hint is ranked by
|
|
938
|
+
* pixel count and lands in `mid` — the slot `recordings` does not select on its
|
|
939
|
+
* own, that `recordingsLow` ignores, and that scrub never reads. The Dreame
|
|
940
|
+
* robot's single 720p relay landed exactly there and its footage was written
|
|
941
|
+
* where nothing looked.
|
|
625
942
|
*/
|
|
626
|
-
var
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
943
|
+
var GRID_OUTPUT_PROFILE = "high";
|
|
944
|
+
/** Cheapest first — the order a source is read in. */
|
|
945
|
+
var CHEAPEST_FIRST = [
|
|
946
|
+
"low",
|
|
947
|
+
"mid",
|
|
948
|
+
"high"
|
|
949
|
+
];
|
|
950
|
+
/** The cheapest stream this source serves, or `null` when it serves none. */
|
|
951
|
+
function lowestServedProfile(served) {
|
|
952
|
+
return CHEAPEST_FIRST.find((profile) => served.includes(profile)) ?? null;
|
|
631
953
|
}
|
|
632
|
-
/**
|
|
633
|
-
function
|
|
634
|
-
const
|
|
954
|
+
/** Which stream to read each source at, and who cannot be read at all. */
|
|
955
|
+
function gridSourceProfiles(input) {
|
|
956
|
+
const profileBySource = /* @__PURE__ */ new Map();
|
|
957
|
+
const missingSources = [];
|
|
958
|
+
for (const deviceId of input.sourceDeviceIds) {
|
|
959
|
+
const served = input.profilesServedBy(deviceId);
|
|
960
|
+
const chosen = served === null ? null : lowestServedProfile(served);
|
|
961
|
+
if (chosen === null) missingSources.push(deviceId);
|
|
962
|
+
else profileBySource.set(deviceId, chosen);
|
|
963
|
+
}
|
|
635
964
|
return {
|
|
636
|
-
|
|
637
|
-
|
|
965
|
+
offered: input.sourceDeviceIds.length > 0 && missingSources.length === 0,
|
|
966
|
+
profileBySource,
|
|
967
|
+
missingSources
|
|
638
968
|
};
|
|
639
969
|
}
|
|
640
|
-
function evenAtLeast(value, floor) {
|
|
641
|
-
const rounded = Math.max(floor, Math.round(value));
|
|
642
|
-
return rounded % 2 === 0 ? rounded : rounded + 1;
|
|
643
|
-
}
|
|
644
|
-
/**
|
|
645
|
-
* What this grid can honestly publish, per profile, with the refusal named.
|
|
646
|
-
*
|
|
647
|
-
* Every profile gets a row whether it is offered or not: an absent row says
|
|
648
|
-
* nothing, and the caller logging a refusal needs the reason more than the
|
|
649
|
-
* caller publishing an offer needs the offer.
|
|
650
|
-
*/
|
|
651
|
-
function offeredGridProfiles(input) {
|
|
652
|
-
const served = /* @__PURE__ */ new Map();
|
|
653
|
-
for (const deviceId of input.sourceDeviceIds) served.set(deviceId, input.profilesServedBy(deviceId));
|
|
654
|
-
return GRID_PROFILES.map((profile) => {
|
|
655
|
-
if (input.sourceDeviceIds.length === 0) return {
|
|
656
|
-
profile,
|
|
657
|
-
offered: false,
|
|
658
|
-
missingSources: []
|
|
659
|
-
};
|
|
660
|
-
const missingSources = input.sourceDeviceIds.filter((deviceId) => {
|
|
661
|
-
const profiles = served.get(deviceId);
|
|
662
|
-
return profiles === null || profiles === void 0 || !profiles.includes(profile);
|
|
663
|
-
});
|
|
664
|
-
return missingSources.length === 0 ? {
|
|
665
|
-
profile,
|
|
666
|
-
offered: true
|
|
667
|
-
} : {
|
|
668
|
-
profile,
|
|
669
|
-
offered: false,
|
|
670
|
-
missingSources
|
|
671
|
-
};
|
|
672
|
-
});
|
|
673
|
-
}
|
|
674
970
|
//#endregion
|
|
675
971
|
//#region src/builtins/camera-grid/grid-sentry-sources.ts
|
|
676
972
|
function gridSourceDial(acquired) {
|
|
@@ -689,6 +985,43 @@ function gridSourceDial(acquired) {
|
|
|
689
985
|
//#endregion
|
|
690
986
|
//#region src/builtins/camera-grid/grid-snapshot.ts
|
|
691
987
|
/**
|
|
988
|
+
* A grid's snapshot — a frame of the COMPOSITION, never a dial.
|
|
989
|
+
*
|
|
990
|
+
* ## The decision, and the two things it rejects
|
|
991
|
+
*
|
|
992
|
+
* The operator: *"Lo snapshot possiamo sempre farlo dallo stream sempre, non
|
|
993
|
+
* c'e' bisogno di comporre gli snapshots"*. So:
|
|
994
|
+
*
|
|
995
|
+
* - **Not composed from source snapshots.** That would be a SECOND compositor,
|
|
996
|
+
* with its own geometry, its own aspect handling and its own bugs, producing
|
|
997
|
+
* a picture that differs from the real one in ways nobody would ever notice
|
|
998
|
+
* until they mattered.
|
|
999
|
+
* - **Not produced by starting the composition.** The whole of D519 is "a grid
|
|
1000
|
+
* costs nothing when nobody watches". A thumbnail that dials N cameras — and
|
|
1001
|
+
* on a battery camera, wakes one — spends the entire budget of the feature on
|
|
1002
|
+
* a 160-pixel-wide picture in a list.
|
|
1003
|
+
*
|
|
1004
|
+
* ## The three states, all answered
|
|
1005
|
+
*
|
|
1006
|
+
* | state | answer |
|
|
1007
|
+
* | --- | --- |
|
|
1008
|
+
* | a composition is running | one frame sampled out of the bytes it is already emitting |
|
|
1009
|
+
* | nothing running, a frame was kept | that frame — stale, and honest about it |
|
|
1010
|
+
* | nothing running, nothing ever kept | `null`, logged with the reason |
|
|
1011
|
+
*
|
|
1012
|
+
* `null` is the courtesy card, which is the correct picture of "this grid has
|
|
1013
|
+
* never been watched". What it must never be is a reason to dial.
|
|
1014
|
+
*
|
|
1015
|
+
* ## Why this is a NATIVE snapshot provider
|
|
1016
|
+
*
|
|
1017
|
+
* The wrapper's ladder is native-first: a native that produces a frame
|
|
1018
|
+
* short-circuits before the broker frame-grab, and the broker frame-grab is the
|
|
1019
|
+
* thing that would wake the sources. A grid with a stored frame or a live
|
|
1020
|
+
* composition therefore never reaches it. The residual — a grid that has never
|
|
1021
|
+
* once been composed — is documented in D519; it cannot wake a battery camera,
|
|
1022
|
+
* because a grid never dials one live at all (`grid-sentry-sources.ts`).
|
|
1023
|
+
*/
|
|
1024
|
+
/**
|
|
692
1025
|
* Which of the three answers applies. Pure, so the rule can be read — and
|
|
693
1026
|
* sabotaged — without a socket, a file or an ffmpeg.
|
|
694
1027
|
*/
|
|
@@ -721,10 +1054,7 @@ var GridSnapshotSource = class {
|
|
|
721
1054
|
return toImage(sampled);
|
|
722
1055
|
}
|
|
723
1056
|
if (stored !== null) return toImage(stored);
|
|
724
|
-
this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", {
|
|
725
|
-
tags: { deviceId },
|
|
726
|
-
meta: { profile: live.profile }
|
|
727
|
-
});
|
|
1057
|
+
this.deps.logger.warn("camera grid: the composition is running but produced no frame, and none was kept", { tags: { deviceId } });
|
|
728
1058
|
return null;
|
|
729
1059
|
}
|
|
730
1060
|
if (answer.kind === "stored" && stored !== null) return toImage(stored);
|
|
@@ -732,7 +1062,7 @@ var GridSnapshotSource = class {
|
|
|
732
1062
|
tags: { deviceId },
|
|
733
1063
|
meta: {
|
|
734
1064
|
reason: "never-composed",
|
|
735
|
-
|
|
1065
|
+
compositions: sessions.length
|
|
736
1066
|
}
|
|
737
1067
|
});
|
|
738
1068
|
return null;
|
|
@@ -741,18 +1071,12 @@ var GridSnapshotSource = class {
|
|
|
741
1071
|
try {
|
|
742
1072
|
const jpeg = await this.deps.sampleFrame(session);
|
|
743
1073
|
if (jpeg !== null && jpeg.length > 0) return jpeg;
|
|
744
|
-
this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
|
|
745
|
-
tags: { deviceId },
|
|
746
|
-
meta: { profile: session.profile }
|
|
747
|
-
});
|
|
1074
|
+
this.deps.logger.warn("camera grid: could not sample a frame from the live composition", { tags: { deviceId } });
|
|
748
1075
|
return null;
|
|
749
1076
|
} catch (error) {
|
|
750
1077
|
this.deps.logger.warn("camera grid: could not sample a frame from the live composition", {
|
|
751
1078
|
tags: { deviceId },
|
|
752
|
-
meta: {
|
|
753
|
-
profile: session.profile,
|
|
754
|
-
error: errMsg(error)
|
|
755
|
-
}
|
|
1079
|
+
meta: { error: errMsg(error) }
|
|
756
1080
|
});
|
|
757
1081
|
return null;
|
|
758
1082
|
}
|
|
@@ -806,24 +1130,15 @@ var PATH_PREFIX = "/grid/";
|
|
|
806
1130
|
var PATH_SUFFIX = GRID_WIRE_PATH_SUFFIX;
|
|
807
1131
|
/** How long a reader may refuse to drain before it is cut. */
|
|
808
1132
|
var SLOW_READER_MS = 5e3;
|
|
809
|
-
|
|
810
|
-
return GRID_PROFILES.find((profile) => profile === raw) ?? null;
|
|
811
|
-
}
|
|
812
|
-
/** `/grid/<instanceId>/<profile>.flv`, or `null` when it is anything else. */
|
|
1133
|
+
/** `/grid/<instanceId>.flv`, or `null` when it is anything else. */
|
|
813
1134
|
function parseGridStreamPath(url) {
|
|
814
1135
|
const path = (url ?? "").split("?")[0] ?? "";
|
|
815
1136
|
if (!path.startsWith(PATH_PREFIX) || !path.endsWith(PATH_SUFFIX)) return null;
|
|
816
1137
|
const raw = path.slice(6, path.length - PATH_SUFFIX.length);
|
|
817
|
-
|
|
818
|
-
if (at <= 0) return null;
|
|
819
|
-
const profile = asProfile(raw.slice(at + 1));
|
|
820
|
-
if (profile === null) return null;
|
|
1138
|
+
if (raw.length === 0 || raw.includes("/")) return null;
|
|
821
1139
|
try {
|
|
822
|
-
const instanceId = decodeURIComponent(raw
|
|
823
|
-
return instanceId.length > 0 ? {
|
|
824
|
-
instanceId,
|
|
825
|
-
profile
|
|
826
|
-
} : null;
|
|
1140
|
+
const instanceId = decodeURIComponent(raw);
|
|
1141
|
+
return instanceId.length > 0 ? { instanceId } : null;
|
|
827
1142
|
} catch {
|
|
828
1143
|
return null;
|
|
829
1144
|
}
|
|
@@ -862,16 +1177,13 @@ var GridStreamRelay = class {
|
|
|
862
1177
|
streamUrl(request) {
|
|
863
1178
|
if (!this.baseUrl) throw new Error("camera-grid: the stream relay is not started");
|
|
864
1179
|
const instance = encodeURIComponent(request.instanceId);
|
|
865
|
-
return `${this.baseUrl}${PATH_PREFIX}${instance}
|
|
1180
|
+
return `${this.baseUrl}${PATH_PREFIX}${instance}${PATH_SUFFIX}`;
|
|
866
1181
|
}
|
|
867
1182
|
async serve(request, res) {
|
|
868
|
-
const { instanceId
|
|
1183
|
+
const { instanceId } = request;
|
|
869
1184
|
const session = this.deps.sessionFor(request);
|
|
870
1185
|
if (session === null) {
|
|
871
|
-
this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: {
|
|
872
|
-
instanceId,
|
|
873
|
-
profile
|
|
874
|
-
} });
|
|
1186
|
+
this.deps.logger.warn("camera grid: a stream was requested for an unknown grid", { meta: { instanceId } });
|
|
875
1187
|
res.writeHead(404).end();
|
|
876
1188
|
return;
|
|
877
1189
|
}
|
|
@@ -905,7 +1217,6 @@ var GridStreamRelay = class {
|
|
|
905
1217
|
tags: { deviceId: session.deviceId },
|
|
906
1218
|
meta: {
|
|
907
1219
|
instanceId,
|
|
908
|
-
profile,
|
|
909
1220
|
stalledForMs: SLOW_READER_MS
|
|
910
1221
|
}
|
|
911
1222
|
});
|
|
@@ -918,7 +1229,6 @@ var GridStreamRelay = class {
|
|
|
918
1229
|
tags: { deviceId: session.deviceId },
|
|
919
1230
|
meta: {
|
|
920
1231
|
instanceId,
|
|
921
|
-
profile,
|
|
922
1232
|
reason
|
|
923
1233
|
}
|
|
924
1234
|
});
|
|
@@ -931,7 +1241,6 @@ var GridStreamRelay = class {
|
|
|
931
1241
|
tags: { deviceId: session.deviceId },
|
|
932
1242
|
meta: {
|
|
933
1243
|
instanceId,
|
|
934
|
-
profile,
|
|
935
1244
|
error: errMsg(error)
|
|
936
1245
|
}
|
|
937
1246
|
});
|
|
@@ -967,13 +1276,13 @@ var GridStreamRelay = class {
|
|
|
967
1276
|
* scrub never reads. The Dreame robot's one 720p relay landed exactly there and
|
|
968
1277
|
* its footage was written nowhere anything looked.
|
|
969
1278
|
*
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
1279
|
+
* So the single composition claims `high` EXPLICITLY (`GRID_OUTPUT_PROFILE`).
|
|
1280
|
+
* It is also the slot this stream held before the per-profile detour, which is
|
|
1281
|
+
* what keeps an operator's existing assignment intact.
|
|
973
1282
|
*/
|
|
974
1283
|
function gridStreamDescriptor(input) {
|
|
975
1284
|
return {
|
|
976
|
-
camStreamId:
|
|
1285
|
+
camStreamId: GRID_CAM_STREAM_ID,
|
|
977
1286
|
kind: GRID_WIRE_STREAM_KIND,
|
|
978
1287
|
url: input.url,
|
|
979
1288
|
codec: "h264",
|
|
@@ -982,8 +1291,8 @@ function gridStreamDescriptor(input) {
|
|
|
982
1291
|
height: input.height
|
|
983
1292
|
},
|
|
984
1293
|
fps: input.fps,
|
|
985
|
-
label:
|
|
986
|
-
profileHint:
|
|
1294
|
+
label: "Grid composition",
|
|
1295
|
+
profileHint: GRID_OUTPUT_PROFILE,
|
|
987
1296
|
autoEligible: true
|
|
988
1297
|
};
|
|
989
1298
|
}
|
|
@@ -1416,11 +1725,14 @@ var GridStreamSession = class {
|
|
|
1416
1725
|
* for a grid must win, and a reconcile that re-asserted every pass would silently
|
|
1417
1726
|
* overrule them once a minute.
|
|
1418
1727
|
*/
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1728
|
+
/**
|
|
1729
|
+
* The list lives in `@camstack/types` now, and this is an alias rather than a
|
|
1730
|
+
* second copy. There WERE two — this one and the Terminal addon's — and they
|
|
1731
|
+
* had drifted: neither named `pipeline-analytics`, the one cap with no gate of
|
|
1732
|
+
* its own, which is what a grid forwarding its sources' motion would have used
|
|
1733
|
+
* to write a duplicate motion episode per event.
|
|
1734
|
+
*/
|
|
1735
|
+
var GRID_SILENCED_CAP_NAMES = DERIVED_CAMERA_SILENCED_CAP_NAMES;
|
|
1424
1736
|
/**
|
|
1425
1737
|
* Resolve the addon currently providing `capName` for this device, from the
|
|
1426
1738
|
* device's OWN bindings. `listBindableCapsForDeviceType` answers for the device
|
|
@@ -1761,21 +2073,18 @@ var DEFAULTS = {
|
|
|
1761
2073
|
cameraGrids: [],
|
|
1762
2074
|
cameraGridTombstones: []
|
|
1763
2075
|
};
|
|
1764
|
-
/** One session per (grid, profile). A grid composes each profile separately. */
|
|
1765
|
-
function sessionKey(instanceId, profile) {
|
|
1766
|
-
return `${instanceId}::${profile}`;
|
|
1767
|
-
}
|
|
1768
2076
|
/**
|
|
1769
|
-
* One grid's composition
|
|
1770
|
-
*
|
|
2077
|
+
* One grid's composition — there is exactly one, keyed by the grid itself.
|
|
2078
|
+
*
|
|
2079
|
+
* It used to be one per profile, and the key carried the profile. That shape
|
|
2080
|
+
* is what made a viewer's adaptive step tear a composition down and build
|
|
2081
|
+
* another; see `grid-profiles.ts` for the log that showed it.
|
|
1771
2082
|
*/
|
|
1772
2083
|
var GridSessionEntry = class {
|
|
1773
2084
|
instanceId;
|
|
1774
|
-
profile;
|
|
1775
2085
|
session;
|
|
1776
|
-
constructor(instanceId,
|
|
2086
|
+
constructor(instanceId, session) {
|
|
1777
2087
|
this.instanceId = instanceId;
|
|
1778
|
-
this.profile = profile;
|
|
1779
2088
|
this.session = session;
|
|
1780
2089
|
}
|
|
1781
2090
|
isRunning() {
|
|
@@ -1785,7 +2094,7 @@ var GridSessionEntry = class {
|
|
|
1785
2094
|
var CameraGridAddon = class extends BaseAddon {
|
|
1786
2095
|
relay = null;
|
|
1787
2096
|
reconcileTimer = null;
|
|
1788
|
-
/** Keyed by
|
|
2097
|
+
/** Keyed by instance id — ONE composition per grid. */
|
|
1789
2098
|
sessions = /* @__PURE__ */ new Map();
|
|
1790
2099
|
deviceIdByInstanceId = /* @__PURE__ */ new Map();
|
|
1791
2100
|
tombstones = /* @__PURE__ */ new Set();
|
|
@@ -1799,7 +2108,8 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
1799
2108
|
* (D224), and 60 s is the window for a fact — "does 615 have a low assigned"
|
|
1800
2109
|
* — that only changes when an operator changes it.
|
|
1801
2110
|
*/
|
|
1802
|
-
|
|
2111
|
+
planByInstanceId = /* @__PURE__ */ new Map();
|
|
2112
|
+
activity = null;
|
|
1803
2113
|
snapshots = null;
|
|
1804
2114
|
frameSampler = null;
|
|
1805
2115
|
ffmpegBinaryPath = "ffmpeg";
|
|
@@ -1828,11 +2138,29 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
1828
2138
|
sampleFrame: async (session) => this.sampleFrame(session),
|
|
1829
2139
|
store: new GridLastFrameFileStore({ dataDir: this.ctx.dataDir })
|
|
1830
2140
|
});
|
|
2141
|
+
this.activity = new GridActivityForwarder({
|
|
2142
|
+
eventBus: this.ctx.eventBus,
|
|
2143
|
+
logger: this.ctx.logger.child("activity"),
|
|
2144
|
+
addonId: this.ctx.id,
|
|
2145
|
+
gridsForSource: (sourceDeviceId) => this.gridsForSource(sourceDeviceId),
|
|
2146
|
+
now: () => Date.now()
|
|
2147
|
+
});
|
|
2148
|
+
this.subscribeSourceActivity();
|
|
1831
2149
|
installGridCameraRuntime({
|
|
1832
2150
|
descriptorsFor: (instanceId) => this.descriptorsFor(instanceId),
|
|
1833
2151
|
getLayout: (deviceId) => this.gridViewFor(deviceId),
|
|
1834
2152
|
saveLayout: async (patch) => this.saveGridLayout(patch),
|
|
1835
|
-
getSnapshot: async (deviceId) => this.getSnapshot(deviceId)
|
|
2153
|
+
getSnapshot: async (deviceId) => this.getSnapshot(deviceId),
|
|
2154
|
+
motionStatus: (deviceId) => {
|
|
2155
|
+
const state = this.activity?.motionStatusFor(deviceId, Date.now()) ?? {
|
|
2156
|
+
detected: false,
|
|
2157
|
+
lastDetectedAt: null
|
|
2158
|
+
};
|
|
2159
|
+
return {
|
|
2160
|
+
...state,
|
|
2161
|
+
autoClearAfterMs: state.detected ? MOTION_CLOSE_AFTER_MS : null
|
|
2162
|
+
};
|
|
2163
|
+
}
|
|
1836
2164
|
});
|
|
1837
2165
|
await this.reconcile().catch((error) => {
|
|
1838
2166
|
this.ctx.logger.warn("camera grid: initial reconciliation failed — will retry", { meta: { error: errMsg(error) } });
|
|
@@ -1902,7 +2230,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
1902
2230
|
if (!this.relay) return;
|
|
1903
2231
|
const instances = this.instances();
|
|
1904
2232
|
const declarations = buildGridCameraDeclarations(instances);
|
|
1905
|
-
await this.
|
|
2233
|
+
await this.refreshSourcePlans(instances);
|
|
1906
2234
|
await this.retireWithdrawnSessions(instances);
|
|
1907
2235
|
const result = await new DeclaredDevices({
|
|
1908
2236
|
logger: this.ctx.logger.child("declaration"),
|
|
@@ -1969,15 +2297,73 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
1969
2297
|
}
|
|
1970
2298
|
}
|
|
1971
2299
|
/**
|
|
1972
|
-
*
|
|
2300
|
+
* Listen to the SOURCES, so the grid can say the same things about itself.
|
|
2301
|
+
*
|
|
2302
|
+
* Filtered by source id inside the handler rather than by subscribing per
|
|
2303
|
+
* camera: the bus filter is per category, the set of sources changes whenever
|
|
2304
|
+
* an operator drags a cell, and a per-camera subscription would have to be
|
|
2305
|
+
* torn down and rebuilt on every layout save.
|
|
2306
|
+
*
|
|
2307
|
+
* Every handler is TOTAL — a malformed payload is ignored, never thrown out
|
|
2308
|
+
* of. These are telemetry events on a shared bus (D8/D11), and a throw here
|
|
2309
|
+
* would take the grid's forwarding down for every grid because one camera
|
|
2310
|
+
* emitted something unexpected.
|
|
2311
|
+
*/
|
|
2312
|
+
subscribeSourceActivity() {
|
|
2313
|
+
this.subscribe({ category: EventCategory.MotionOnMotionChanged }, (event) => {
|
|
2314
|
+
const data = event.data;
|
|
2315
|
+
if (typeof data.deviceId !== "number" || typeof data.detected !== "boolean") return;
|
|
2316
|
+
const at = typeof data.timestamp === "number" ? data.timestamp : Date.now();
|
|
2317
|
+
this.activity?.onSourceMotion(data.deviceId, data.detected, at);
|
|
2318
|
+
});
|
|
2319
|
+
this.subscribe({ category: EventCategory.PipelineAudioInferenceResult }, (event) => {
|
|
2320
|
+
const data = event.data;
|
|
2321
|
+
const dbfs = data.frame?.level?.dbfs;
|
|
2322
|
+
if (typeof data.deviceId !== "number" || typeof dbfs !== "number") return;
|
|
2323
|
+
this.activity?.onSourceAudio(data.deviceId, dbfs, Date.now());
|
|
2324
|
+
});
|
|
2325
|
+
this.subscribe({ category: EventCategory.DetectionResult }, (event) => {
|
|
2326
|
+
const frame = event.data.frame;
|
|
2327
|
+
if (frame === void 0 || frame.kind !== "frame") return;
|
|
2328
|
+
this.activity?.onSourceDetections(frame);
|
|
2329
|
+
});
|
|
2330
|
+
}
|
|
2331
|
+
/**
|
|
2332
|
+
* Which grids show this camera. Empty for the overwhelming majority of
|
|
2333
|
+
* events, which is why it is the FIRST thing every handler asks.
|
|
2334
|
+
*/
|
|
2335
|
+
gridsForSource(sourceDeviceId) {
|
|
2336
|
+
const targets = [];
|
|
2337
|
+
for (const [instanceId, deviceId] of this.deviceIdByInstanceId) {
|
|
2338
|
+
const instance = this.instanceById(instanceId);
|
|
2339
|
+
if (!instance || !instance.enabled) continue;
|
|
2340
|
+
if (sourceDeviceId === deviceId) continue;
|
|
2341
|
+
if (instance.cells.filter((cell) => cell.deviceId === sourceDeviceId).length === 0) continue;
|
|
2342
|
+
targets.push({
|
|
2343
|
+
deviceId,
|
|
2344
|
+
canvas: {
|
|
2345
|
+
width: instance.width,
|
|
2346
|
+
height: instance.height
|
|
2347
|
+
},
|
|
2348
|
+
cellsForSource: (id) => instance.cells.filter((cell) => cell.deviceId === id).map((cell) => ({
|
|
2349
|
+
source: cell.source,
|
|
2350
|
+
cell: cell.cell
|
|
2351
|
+
})),
|
|
2352
|
+
sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))]
|
|
2353
|
+
});
|
|
2354
|
+
}
|
|
2355
|
+
return targets;
|
|
2356
|
+
}
|
|
2357
|
+
/**
|
|
2358
|
+
* Which stream each grid reads each of its sources at, asked of the BROKER.
|
|
1973
2359
|
*
|
|
1974
2360
|
* `listAllProfileSlots` is the one authority on "does 615 have a low
|
|
1975
2361
|
* assigned", and `sourceCamStreamId === null` is its way of saying no. A read
|
|
1976
|
-
* that FAILS leaves the previous answer in place and says so
|
|
2362
|
+
* that FAILS leaves the previous answer in place and says so — it is not an
|
|
1977
2363
|
* empty catalog, and turning it into one would retract the streams of every
|
|
1978
2364
|
* grid on the hub.
|
|
1979
2365
|
*/
|
|
1980
|
-
async
|
|
2366
|
+
async refreshSourcePlans(instances) {
|
|
1981
2367
|
let slots;
|
|
1982
2368
|
try {
|
|
1983
2369
|
slots = await this.ctx.api.streamBroker.listAllProfileSlots.query();
|
|
@@ -1996,69 +2382,70 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
1996
2382
|
servedBy.set(slot.deviceId, profiles);
|
|
1997
2383
|
}
|
|
1998
2384
|
for (const instance of instances) {
|
|
1999
|
-
const
|
|
2385
|
+
const plan = gridSourceProfiles({
|
|
2000
2386
|
sourceDeviceIds: [...new Set(instance.cells.map((cell) => cell.deviceId))],
|
|
2001
2387
|
profilesServedBy: (deviceId) => servedBy.get(deviceId) ?? null
|
|
2002
2388
|
});
|
|
2003
|
-
const previous = this.
|
|
2004
|
-
this.
|
|
2005
|
-
this.
|
|
2389
|
+
const previous = this.planByInstanceId.get(instance.id);
|
|
2390
|
+
this.planByInstanceId.set(instance.id, plan);
|
|
2391
|
+
this.logSourcePlan(instance, plan, previous);
|
|
2006
2392
|
}
|
|
2007
2393
|
}
|
|
2008
2394
|
/**
|
|
2009
|
-
* Say
|
|
2395
|
+
* Say WHICH stream each cell is read at, and who could not be read - once per
|
|
2396
|
+
* change.
|
|
2010
2397
|
*
|
|
2011
|
-
* A
|
|
2012
|
-
* broker that swept it
|
|
2013
|
-
*
|
|
2014
|
-
*
|
|
2398
|
+
* Both halves earn their line. A grid silently missing from a catalog is
|
|
2399
|
+
* indistinguishable from a broker that swept it, so the refusal names the
|
|
2400
|
+
* sources. And the chosen profiles are the answer to "why is this tile
|
|
2401
|
+
* soft?" — a camera whose only assigned slot is `high` is read at `high`, and
|
|
2402
|
+
* nothing else in the system would ever say so.
|
|
2403
|
+
*
|
|
2404
|
+
* Logged on the EDGE: a grid that cannot be composed would otherwise write a
|
|
2405
|
+
* line a minute for ever.
|
|
2015
2406
|
*/
|
|
2016
|
-
|
|
2017
|
-
|
|
2407
|
+
logSourcePlan(instance, plan, previous) {
|
|
2408
|
+
const shape = (value) => JSON.stringify([
|
|
2409
|
+
value.offered,
|
|
2410
|
+
[...value.profileBySource],
|
|
2411
|
+
value.missingSources
|
|
2412
|
+
]);
|
|
2413
|
+
if (previous !== void 0 && shape(previous) === shape(plan)) return;
|
|
2018
2414
|
const deviceId = this.deviceIdByInstanceId.get(instance.id) ?? 0;
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2027
|
-
missingSources: [...offer.missingSources]
|
|
2028
|
-
}
|
|
2029
|
-
});
|
|
2030
|
-
}
|
|
2031
|
-
this.ctx.logger.info("camera grid: published profiles", {
|
|
2415
|
+
if (plan.missingSources.length > 0) this.ctx.logger.info("camera grid: a source cannot be read — the grid is NOT on offer", {
|
|
2416
|
+
tags: { deviceId },
|
|
2417
|
+
meta: {
|
|
2418
|
+
instanceId: instance.id,
|
|
2419
|
+
missingSources: [...plan.missingSources]
|
|
2420
|
+
}
|
|
2421
|
+
});
|
|
2422
|
+
this.ctx.logger.info("camera grid: composing", {
|
|
2032
2423
|
tags: { deviceId },
|
|
2033
2424
|
meta: {
|
|
2034
2425
|
instanceId: instance.id,
|
|
2035
|
-
offered:
|
|
2426
|
+
offered: plan.offered,
|
|
2427
|
+
readAt: Object.fromEntries([...plan.profileBySource].map(([id, p]) => [String(id), p]))
|
|
2036
2428
|
}
|
|
2037
2429
|
});
|
|
2038
2430
|
}
|
|
2039
|
-
|
|
2040
|
-
|
|
2041
|
-
if (offers === void 0) return null;
|
|
2042
|
-
return offers.filter((offer) => offer.offered).map((offer) => offer.profile);
|
|
2431
|
+
planFor(instanceId) {
|
|
2432
|
+
return this.planByInstanceId.get(instanceId) ?? null;
|
|
2043
2433
|
}
|
|
2044
2434
|
ensureSessions(instanceId, deviceId) {
|
|
2045
|
-
const
|
|
2046
|
-
|
|
2047
|
-
const
|
|
2048
|
-
if (
|
|
2049
|
-
|
|
2050
|
-
|
|
2051
|
-
this.sessions.delete(key);
|
|
2052
|
-
stale.session.dispose();
|
|
2053
|
-
}
|
|
2054
|
-
continue;
|
|
2435
|
+
const plan = this.planFor(instanceId);
|
|
2436
|
+
if (plan === null || !plan.offered) {
|
|
2437
|
+
const stale = this.sessions.get(instanceId);
|
|
2438
|
+
if (stale) {
|
|
2439
|
+
this.sessions.delete(instanceId);
|
|
2440
|
+
stale.session.dispose();
|
|
2055
2441
|
}
|
|
2056
|
-
|
|
2057
|
-
const session = this.makeSession(instanceId, deviceId, profile);
|
|
2058
|
-
this.sessions.set(key, new GridSessionEntry(instanceId, profile, session));
|
|
2442
|
+
return;
|
|
2059
2443
|
}
|
|
2444
|
+
if (this.sessions.has(instanceId)) return;
|
|
2445
|
+
const session = this.makeSession(instanceId, deviceId);
|
|
2446
|
+
this.sessions.set(instanceId, new GridSessionEntry(instanceId, session));
|
|
2060
2447
|
}
|
|
2061
|
-
makeSession(instanceId, deviceId
|
|
2448
|
+
makeSession(instanceId, deviceId) {
|
|
2062
2449
|
const childContext = {
|
|
2063
2450
|
binaryPath: this.ffmpegBinaryPath,
|
|
2064
2451
|
logger: this.ctx.logger,
|
|
@@ -2069,6 +2456,8 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2069
2456
|
deps: {
|
|
2070
2457
|
logger: this.ctx.logger,
|
|
2071
2458
|
acquireSource: async (sourceDeviceId) => {
|
|
2459
|
+
const profile = this.planFor(instanceId)?.profileBySource.get(sourceDeviceId);
|
|
2460
|
+
if (profile === void 0) throw new Error(`camera-grid: no stream is assigned for source ${String(sourceDeviceId)}`);
|
|
2072
2461
|
const dial = gridSourceDial(await acquireWithRetry(() => this.ctx.api.streamBroker.getStreamWithCodec.mutate({
|
|
2073
2462
|
deviceId: sourceDeviceId,
|
|
2074
2463
|
video: "copy",
|
|
@@ -2118,11 +2507,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2118
2507
|
if (!instance) return null;
|
|
2119
2508
|
return gridPlanFor(instance, {
|
|
2120
2509
|
encoder: "libx264",
|
|
2121
|
-
decodeHwAccel: null
|
|
2122
|
-
canvas: gridCanvasFor({
|
|
2123
|
-
width: instance.width,
|
|
2124
|
-
height: instance.height
|
|
2125
|
-
}, profile)
|
|
2510
|
+
decodeHwAccel: null
|
|
2126
2511
|
});
|
|
2127
2512
|
}
|
|
2128
2513
|
});
|
|
@@ -2130,18 +2515,14 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2130
2515
|
}
|
|
2131
2516
|
/** The session a dial is asking for, or `null` - which the relay answers 404 to. */
|
|
2132
2517
|
sessionFor(request) {
|
|
2133
|
-
return this.sessions.get(
|
|
2518
|
+
return this.sessions.get(request.instanceId)?.session ?? null;
|
|
2134
2519
|
}
|
|
2135
|
-
/** This grid's
|
|
2520
|
+
/** This grid's composition, as the list the snapshot source expects. */
|
|
2136
2521
|
sessionsForDevice(deviceId) {
|
|
2137
2522
|
const instanceId = this.instanceIdForDevice(deviceId);
|
|
2138
2523
|
if (instanceId === null) return [];
|
|
2139
|
-
const
|
|
2140
|
-
|
|
2141
|
-
const entry = this.sessions.get(sessionKey(instanceId, profile));
|
|
2142
|
-
if (entry) entries.push(entry);
|
|
2143
|
-
}
|
|
2144
|
-
return entries;
|
|
2524
|
+
const entry = this.sessions.get(instanceId);
|
|
2525
|
+
return entry ? [entry] : [];
|
|
2145
2526
|
}
|
|
2146
2527
|
/** A grid that is gone stops immediately - it must not keep N sources dialled. */
|
|
2147
2528
|
async retireWithdrawnSessions(instances) {
|
|
@@ -2150,18 +2531,16 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2150
2531
|
if (live.has(entry.instanceId)) continue;
|
|
2151
2532
|
this.ctx.logger.info("camera grid: retiring a withdrawn grid", {
|
|
2152
2533
|
tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
|
|
2153
|
-
meta: {
|
|
2154
|
-
instanceId: entry.instanceId,
|
|
2155
|
-
profile: entry.profile
|
|
2156
|
-
}
|
|
2534
|
+
meta: { instanceId: entry.instanceId }
|
|
2157
2535
|
});
|
|
2158
2536
|
this.sessions.delete(key);
|
|
2159
2537
|
await entry.session.dispose();
|
|
2160
2538
|
}
|
|
2161
2539
|
for (const instanceId of [...this.deviceIdByInstanceId.keys()]) {
|
|
2162
2540
|
if (live.has(instanceId)) continue;
|
|
2541
|
+
this.activity?.forget(this.deviceIdByInstanceId.get(instanceId) ?? 0);
|
|
2163
2542
|
this.deviceIdByInstanceId.delete(instanceId);
|
|
2164
|
-
this.
|
|
2543
|
+
this.planByInstanceId.delete(instanceId);
|
|
2165
2544
|
}
|
|
2166
2545
|
}
|
|
2167
2546
|
/**
|
|
@@ -2174,10 +2553,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2174
2553
|
if (!entry.session.isRunning()) continue;
|
|
2175
2554
|
this.ctx.logger.info("camera grid: the layout changed - restarting the composition", {
|
|
2176
2555
|
tags: { deviceId: this.deviceIdByInstanceId.get(entry.instanceId) ?? 0 },
|
|
2177
|
-
meta: {
|
|
2178
|
-
instanceId: entry.instanceId,
|
|
2179
|
-
profile: entry.profile
|
|
2180
|
-
}
|
|
2556
|
+
meta: { instanceId: entry.instanceId }
|
|
2181
2557
|
});
|
|
2182
2558
|
entry.session.onChildEnded("layout-changed");
|
|
2183
2559
|
}
|
|
@@ -2212,24 +2588,15 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2212
2588
|
descriptorsFor(instanceId) {
|
|
2213
2589
|
const instance = this.instanceById(instanceId);
|
|
2214
2590
|
const relay = this.relay;
|
|
2215
|
-
const
|
|
2216
|
-
if (!instance || !relay ||
|
|
2217
|
-
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
width: canvas.width,
|
|
2225
|
-
height: canvas.height,
|
|
2226
|
-
fps: instance.fps,
|
|
2227
|
-
url: relay.streamUrl({
|
|
2228
|
-
instanceId,
|
|
2229
|
-
profile
|
|
2230
|
-
})
|
|
2231
|
-
});
|
|
2232
|
-
});
|
|
2591
|
+
const plan = this.planFor(instanceId);
|
|
2592
|
+
if (!instance || !relay || plan === null) return null;
|
|
2593
|
+
if (!plan.offered) return [];
|
|
2594
|
+
return [gridStreamDescriptor({
|
|
2595
|
+
width: instance.width,
|
|
2596
|
+
height: instance.height,
|
|
2597
|
+
fps: instance.fps,
|
|
2598
|
+
url: relay.streamUrl({ instanceId })
|
|
2599
|
+
})];
|
|
2233
2600
|
}
|
|
2234
2601
|
instanceIdForDevice(deviceId) {
|
|
2235
2602
|
for (const [instanceId, id] of this.deviceIdByInstanceId) if (id === deviceId) return instanceId;
|
|
@@ -2254,11 +2621,15 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2254
2621
|
source: cell.source,
|
|
2255
2622
|
cell: cell.cell
|
|
2256
2623
|
})),
|
|
2257
|
-
profiles: (
|
|
2258
|
-
|
|
2259
|
-
|
|
2260
|
-
|
|
2261
|
-
|
|
2624
|
+
profiles: (() => {
|
|
2625
|
+
const plan = this.planByInstanceId.get(instance.id);
|
|
2626
|
+
if (plan === void 0) return [];
|
|
2627
|
+
return [{
|
|
2628
|
+
profile: GRID_OUTPUT_PROFILE,
|
|
2629
|
+
offered: plan.offered,
|
|
2630
|
+
missingSources: [...plan.missingSources]
|
|
2631
|
+
}];
|
|
2632
|
+
})()
|
|
2262
2633
|
};
|
|
2263
2634
|
}
|
|
2264
2635
|
async saveGridLayout(input) {
|
|
@@ -2284,7 +2655,7 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2284
2655
|
...row,
|
|
2285
2656
|
...updated
|
|
2286
2657
|
} : row) });
|
|
2287
|
-
await this.
|
|
2658
|
+
await this.refreshSourcePlans(this.instances());
|
|
2288
2659
|
this.ensureSessions(instance.id, input.deviceId);
|
|
2289
2660
|
const view = this.gridViewFor(input.deviceId);
|
|
2290
2661
|
if (!view) throw new Error("camera-grid: the grid vanished while it was being saved");
|
|
@@ -2350,4 +2721,4 @@ var CameraGridAddon = class extends BaseAddon {
|
|
|
2350
2721
|
}
|
|
2351
2722
|
};
|
|
2352
2723
|
//#endregion
|
|
2353
|
-
export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID,
|
|
2724
|
+
export { CameraGridAddon, CameraGridAddon as default, GRID_CAM_STREAM_ID, GRID_OUTPUT_PROFILE, GRID_SILENCED_CAP_NAMES, GridCameraDevice, GridFrameSampler, GridLastFrameFileStore, GridSnapshotSource, GridStreamRelay, GridStreamSession, buildGridCameraDeclarations, buildGridFilterGraph, gridCameraReconciliationIndex, gridPlanFor, gridSnapshotAnswerFor, gridSourceDial, gridSourceProfiles, gridStreamDescriptor, gridStreamInvocation, installGridCameraRuntime, lowestServedProfile, newGridCameraStableId, normalizeGridRows, parseGridStreamPath, readGridInstances, silenceAnalysisFor, startGridChild };
|