@camstack/types 1.2.42 → 1.2.43
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/addon.js +1 -1
- package/dist/addon.mjs +1 -1
- package/dist/cap-call-context.d.ts +26 -0
- package/dist/capabilities/index.d.ts +3 -3
- package/dist/capabilities/pipeline-orchestrator.cap.d.ts +26 -0
- package/dist/capabilities/privacy-mask.cap.d.ts +69 -9
- package/dist/capabilities/recording.cap.d.ts +27 -0
- package/dist/capabilities/snapshot.cap.d.ts +1 -1
- package/dist/capabilities/stream-broker.cap.d.ts +4 -0
- package/dist/capabilities/stream-params.cap.d.ts +8 -4
- package/dist/device/device-profile.d.ts +12 -4
- package/dist/device/system-mirror.d.ts +11 -0
- package/dist/ffmpeg/encode-defaults.d.ts +18 -0
- package/dist/ffmpeg/invocation.d.ts +100 -2
- package/dist/ffmpeg/sharing-key.d.ts +54 -2
- package/dist/generated/addon-api.d.ts +32 -0
- package/dist/generated/device-proxy.d.ts +1 -1
- package/dist/generated/method-access-map.d.ts +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +749 -56
- package/dist/index.mjs +725 -57
- package/dist/interfaces/camera-switches.d.ts +163 -5
- package/dist/interfaces/inference-engine.d.ts +24 -3
- package/dist/pipeline/native-lease.d.ts +150 -0
- package/dist/{sleep-Cvi1JxZp.js → sleep-Bx9IIoT0.js} +38 -1
- package/dist/{sleep-BmNKsY7v.mjs → sleep-DtstvzWm.mjs} +33 -2
- package/dist/utils/addon-id.d.ts +30 -0
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Object.defineProperty(exports, Symbol.toStringTag, { value: "Module" });
|
|
2
2
|
const require_event_category = require("./event-category-BE4PDZ_3.js");
|
|
3
|
-
const require_sleep = require("./sleep-
|
|
3
|
+
const require_sleep = require("./sleep-Bx9IIoT0.js");
|
|
4
4
|
const require_canonical_hash = require("./canonical-hash-BcZHRHIx.js");
|
|
5
5
|
const require_enums = require("./enums.js");
|
|
6
6
|
const require_err_msg = require("./err-msg-COpsHMw2.js");
|
|
@@ -289,6 +289,8 @@ function buildInputArgs(input, decodeHwAccel) {
|
|
|
289
289
|
const args = [];
|
|
290
290
|
if (!isSoftwareDecode(decodeHwAccel)) args.push("-hwaccel", String(decodeHwAccel));
|
|
291
291
|
if (input.extraArgs?.length) args.push(...input.extraArgs);
|
|
292
|
+
if (input.analyzeDurationUs !== void 0) args.push("-analyzeduration", String(input.analyzeDurationUs));
|
|
293
|
+
if (input.probeSizeBytes !== void 0) args.push("-probesize", String(input.probeSizeBytes));
|
|
292
294
|
if (input.fflags?.length) for (const flag of input.fflags) args.push("-fflags", flag);
|
|
293
295
|
if (input.rtspTransport) args.push("-rtsp_transport", input.rtspTransport);
|
|
294
296
|
args.push("-i", input.url);
|
|
@@ -337,6 +339,7 @@ function buildVideoArgs(video, outputArgs) {
|
|
|
337
339
|
if (video.pixelFormat !== void 0) args.push("-pix_fmt", video.pixelFormat);
|
|
338
340
|
if (video.fps !== void 0) args.push("-r", String(video.fps));
|
|
339
341
|
if (video.gopFrames !== void 0) args.push("-g", String(video.gopFrames));
|
|
342
|
+
if (video.forceKeyFramesSeconds !== void 0) args.push("-force_key_frames", `expr:gte(t,n_forced*${video.forceKeyFramesSeconds})`);
|
|
340
343
|
if (video.bf !== void 0) args.push("-bf", String(video.bf));
|
|
341
344
|
args.push(...buildRateControlArgs(video));
|
|
342
345
|
if (video.bitstreamFilter !== void 0) args.push("-bsf:v", video.bitstreamFilter);
|
|
@@ -372,6 +375,56 @@ function isElementaryVideoSink(sink) {
|
|
|
372
375
|
return sink.kind === "stdout" && (sink.container === "h264" || sink.container === "hevc");
|
|
373
376
|
}
|
|
374
377
|
/**
|
|
378
|
+
* The fragmented-MP4 muxer flags, in the order the recorder has proven them
|
|
379
|
+
* (`recorder/addon/ffmpeg-args.ts` passes the same `movflags` string through
|
|
380
|
+
* `-segment_format_options`, across every vendor in the fleet):
|
|
381
|
+
*
|
|
382
|
+
* - `frag_keyframe` — cut a fragment at each key frame, so every fragment
|
|
383
|
+
* opens on a sync sample. HKSV's whole requirement.
|
|
384
|
+
* - `empty_moov` — write `ftyp`+`moov` up front with no samples in it, which
|
|
385
|
+
* is what makes the head a standalone INITIALISATION segment.
|
|
386
|
+
* - `default_base_moof` — fragment offsets are self-relative, so a fragment is
|
|
387
|
+
* demuxable without the bytes that preceded it. D31's byte-range read path
|
|
388
|
+
* depends on exactly this property of the recorder's segments.
|
|
389
|
+
*/
|
|
390
|
+
var FMP4_MOVFLAGS = "+frag_keyframe+empty_moov+default_base_moof";
|
|
391
|
+
/**
|
|
392
|
+
* The terminal sink args for every non-`rtp-outputs` sink. Exhaustive over the
|
|
393
|
+
* union so a new member cannot fall through to `['-f', container, 'pipe:1']`,
|
|
394
|
+
* which is what a plain `container` read would have done for `mp4` — a valid
|
|
395
|
+
* argv that writes a NON-fragmented, unseekable-to-a-pipe MP4 and produces one
|
|
396
|
+
* unusable byte stream.
|
|
397
|
+
*/
|
|
398
|
+
function buildStdoutOrRtspSinkArgs(sink) {
|
|
399
|
+
if (sink.kind === "rtsp-listen") return [
|
|
400
|
+
"-f",
|
|
401
|
+
"rtsp",
|
|
402
|
+
"-rtsp_transport",
|
|
403
|
+
"tcp",
|
|
404
|
+
"-rtsp_flags",
|
|
405
|
+
"listen",
|
|
406
|
+
sink.url
|
|
407
|
+
];
|
|
408
|
+
if (sink.kind === "rtp-outputs") return [];
|
|
409
|
+
return sink.container === "mp4" ? buildFmp4SinkArgs(sink) : [
|
|
410
|
+
"-f",
|
|
411
|
+
sink.container,
|
|
412
|
+
"pipe:1"
|
|
413
|
+
];
|
|
414
|
+
}
|
|
415
|
+
/** `-movflags … -min_frag_duration <us> -f mp4 pipe:1`. */
|
|
416
|
+
function buildFmp4SinkArgs(sink) {
|
|
417
|
+
return [
|
|
418
|
+
"-movflags",
|
|
419
|
+
FMP4_MOVFLAGS,
|
|
420
|
+
"-min_frag_duration",
|
|
421
|
+
String(Math.max(0, Math.round(sink.fragmentMs * 1e3))),
|
|
422
|
+
"-f",
|
|
423
|
+
"mp4",
|
|
424
|
+
"pipe:1"
|
|
425
|
+
];
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
375
428
|
* A second output mapping source audio to RTP-over-UDP. `0:a:0?` makes the
|
|
376
429
|
* audio optional so a source with no audio skips it instead of failing the
|
|
377
430
|
* whole invocation.
|
|
@@ -432,19 +485,7 @@ function buildFfmpegArgs(inv) {
|
|
|
432
485
|
];
|
|
433
486
|
}
|
|
434
487
|
const audioArgs = isElementaryVideoSink(inv.sink) ? ["-an"] : buildAudioArgs(inv.audio);
|
|
435
|
-
const sinkArgs = inv.sink
|
|
436
|
-
"-f",
|
|
437
|
-
inv.sink.container,
|
|
438
|
-
"pipe:1"
|
|
439
|
-
] : [
|
|
440
|
-
"-f",
|
|
441
|
-
"rtsp",
|
|
442
|
-
"-rtsp_transport",
|
|
443
|
-
"tcp",
|
|
444
|
-
"-rtsp_flags",
|
|
445
|
-
"listen",
|
|
446
|
-
inv.sink.url
|
|
447
|
-
];
|
|
488
|
+
const sinkArgs = buildStdoutOrRtspSinkArgs(inv.sink);
|
|
448
489
|
return [
|
|
449
490
|
...head,
|
|
450
491
|
...buildVideoArgs(inv.video, inv.outputArgs),
|
|
@@ -529,7 +570,10 @@ function invocationFromEncodeProfile(input) {
|
|
|
529
570
|
height: v.height
|
|
530
571
|
} : null;
|
|
531
572
|
const target = v.codec === "h265" ? "h265" : "h264";
|
|
532
|
-
const video = shouldCopy ? {
|
|
573
|
+
const video = shouldCopy ? {
|
|
574
|
+
kind: "copy",
|
|
575
|
+
...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
|
|
576
|
+
} : {
|
|
533
577
|
kind: "encode",
|
|
534
578
|
encoder: pickVideoEncoder(target, input.decodeHwAccel, input.hardwareEncoders === true),
|
|
535
579
|
scale,
|
|
@@ -537,10 +581,14 @@ function invocationFromEncodeProfile(input) {
|
|
|
537
581
|
...v.tune !== void 0 ? { tune: v.tune } : {},
|
|
538
582
|
...v.profile !== void 0 ? { profile: v.profile } : {},
|
|
539
583
|
...v.level !== void 0 ? { level: v.level } : {},
|
|
584
|
+
...input.pixelFormat !== void 0 ? { pixelFormat: input.pixelFormat } : {},
|
|
540
585
|
...v.fps !== void 0 ? { fps: v.fps } : {},
|
|
541
586
|
...v.gopFrames !== void 0 ? { gopFrames: v.gopFrames } : {},
|
|
587
|
+
...input.forceKeyFramesSeconds !== void 0 ? { forceKeyFramesSeconds: input.forceKeyFramesSeconds } : {},
|
|
542
588
|
...v.bf !== void 0 ? { bf: v.bf } : {},
|
|
543
|
-
...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {}
|
|
589
|
+
...v.bitrateKbps !== void 0 ? { bitrateKbps: v.bitrateKbps } : {},
|
|
590
|
+
...input.rateControl !== void 0 ? { rateControl: input.rateControl } : {},
|
|
591
|
+
...input.bitstreamFilter !== void 0 ? { bitstreamFilter: input.bitstreamFilter } : {}
|
|
544
592
|
};
|
|
545
593
|
return {
|
|
546
594
|
logLevel: input.logLevel ?? "error",
|
|
@@ -604,9 +652,27 @@ var WEBRTC_EGRESS_PROFILE = {
|
|
|
604
652
|
* audio plane (out-of-band), so this is `passthrough` exactly like the browser
|
|
605
653
|
* — see the ADR for why the previous in-band Opus was encoded and discarded.
|
|
606
654
|
*/
|
|
655
|
+
/**
|
|
656
|
+
* Alexa's egress asks for OPUS, and that is a change with a history.
|
|
657
|
+
*
|
|
658
|
+
* The previous profile encoded Opus IN-BAND into an MPEG-TS, where it was
|
|
659
|
+
* discarded: Opus is `stream_type 0x06` and the broker's demuxer maps only
|
|
660
|
+
* `0x0f` (aac), `0x03`/`0x04` (mp2) and `0x81` (ac3). Every frame it produced
|
|
661
|
+
* died at the demuxer, so the encode was replaced with `passthrough` — correct,
|
|
662
|
+
* because paying libopus for nothing is worse than silence.
|
|
663
|
+
*
|
|
664
|
+
* But `passthrough` means the egress emits NO audio at all, and Alexa's audio
|
|
665
|
+
* does not arrive by magic: it rides the published stream, which is what the
|
|
666
|
+
* WebRTC session dials. So the Echo had video and silence either way.
|
|
667
|
+
*
|
|
668
|
+
* Opus here now reaches the egress AUDIO SIDECAR — a separate RTP leg the
|
|
669
|
+
* restreamer grafts onto its SDP — which never touches the MPEG-TS demuxer that
|
|
670
|
+
* killed the in-band attempt. Same codec, different plane, and this one the
|
|
671
|
+
* consumer can actually negotiate.
|
|
672
|
+
*/
|
|
607
673
|
var ALEXA_EGRESS_PROFILE = {
|
|
608
674
|
...BASE_LIVE_EGRESS_PROFILE,
|
|
609
|
-
audio: "
|
|
675
|
+
audio: { codec: "opus" }
|
|
610
676
|
};
|
|
611
677
|
/** VBV window for a consumer whose budget is enforced per second (HomeKit). */
|
|
612
678
|
var RATE_CONTROL_TIGHT = {
|
|
@@ -727,6 +793,14 @@ async function resolveEgressDecodeHwAccel(deps) {
|
|
|
727
793
|
* Here every knob is a named field, and defaults are APPLIED before hashing so
|
|
728
794
|
* an omitted field and its explicit default land on the same key.
|
|
729
795
|
*/
|
|
796
|
+
/**
|
|
797
|
+
* The transport a CAP request describes. `fragments` is deliberately
|
|
798
|
+
* unreachable from here — the request schema has no way to ask for it, so the
|
|
799
|
+
* cap path can never be handed a fragment child by accident.
|
|
800
|
+
*/
|
|
801
|
+
function egressTransportFromRequest(request) {
|
|
802
|
+
return { transport: request.publishLocally === true ? "push" : "dial" };
|
|
803
|
+
}
|
|
730
804
|
/** Absent optional ⇒ this sentinel, so `undefined` and "not set" agree. */
|
|
731
805
|
var UNSET = "\0unset";
|
|
732
806
|
function canonicalVideo(video) {
|
|
@@ -758,10 +832,12 @@ function canonicalAudio(audio) {
|
|
|
758
832
|
* future operator-facing "why are these two not sharing?" surface — can diff
|
|
759
833
|
* two requests without reversing a hash.
|
|
760
834
|
*/
|
|
761
|
-
function canonicalEgressPlan(request) {
|
|
835
|
+
function canonicalEgressPlan(request, delivery = egressTransportFromRequest(request)) {
|
|
762
836
|
return {
|
|
763
837
|
deviceId: request.deviceId,
|
|
764
838
|
source: request.source.kind === "profile" ? `profile:${request.source.profile}` : `cam-stream:${request.source.camStreamId}`,
|
|
839
|
+
transport: delivery.transport,
|
|
840
|
+
fragmentMs: delivery.fragmentMs ?? -1,
|
|
765
841
|
video: canonicalVideo(request.encode.video),
|
|
766
842
|
audio: canonicalAudio(request.encode.audio),
|
|
767
843
|
rateControl: request.rateControl ?? "relaxed",
|
|
@@ -781,8 +857,8 @@ function canonicalEgressPlan(request) {
|
|
|
781
857
|
* exactly a mutable shared object, where one consumer's downgrade dragged
|
|
782
858
|
* every other consumer to 360p.
|
|
783
859
|
*/
|
|
784
|
-
function egressTranscodeSharingKey(request) {
|
|
785
|
-
return `egress:${require_canonical_hash.canonicalHash(canonicalEgressPlan(request))}`;
|
|
860
|
+
function egressTranscodeSharingKey(request, delivery = egressTransportFromRequest(request)) {
|
|
861
|
+
return `egress:${require_canonical_hash.canonicalHash(canonicalEgressPlan(request, delivery))}`;
|
|
786
862
|
}
|
|
787
863
|
//#endregion
|
|
788
864
|
//#region src/health/wiring-health.ts
|
|
@@ -902,7 +978,7 @@ var DEFAULT_RETENTION = {
|
|
|
902
978
|
* ## This file adds no state
|
|
903
979
|
*
|
|
904
980
|
* Every switch here is a VIEW onto an authority that already existed
|
|
905
|
-
* ([
|
|
981
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
|
|
906
982
|
* group is that there is exactly one place each function is turned off, and
|
|
907
983
|
* the group routes to it:
|
|
908
984
|
*
|
|
@@ -913,6 +989,40 @@ var DEFAULT_RETENTION = {
|
|
|
913
989
|
* | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
|
|
914
990
|
* | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
|
|
915
991
|
* | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
|
|
992
|
+
* | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
|
|
993
|
+
* | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
|
|
994
|
+
*
|
|
995
|
+
* ## The two switches whose authority is not on this server
|
|
996
|
+
*
|
|
997
|
+
* `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
|
|
998
|
+
* in "the group stores nothing" — it is the purest form of it: the camera
|
|
999
|
+
* holds the fact, every read is a read-through, and there is no server-side
|
|
1000
|
+
* copy that could drift. Their availability therefore cannot come from
|
|
1001
|
+
* `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
|
|
1002
|
+
* is filtered out there); it comes from the cap's own camera-probed
|
|
1003
|
+
* `privacyMask.getOptions()`, which is strictly more honest — it answers for
|
|
1004
|
+
* THIS camera rather than for the device type
|
|
1005
|
+
* ([D74](../../../../docs/decisions/adr-0074.md)).
|
|
1006
|
+
*
|
|
1007
|
+
* ## `privacy-mask` is the one row whose ON is not "the function is working"
|
|
1008
|
+
*
|
|
1009
|
+
* Every other switch means *this camera's function is doing its job*, so
|
|
1010
|
+
* `enabled: false` is a thing an operator took away. `privacy-mask` means **the
|
|
1011
|
+
* MASK is active** — `enabled: true` is video deliberately obscured. The
|
|
1012
|
+
* polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
|
|
1013
|
+
* (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
|
|
1014
|
+
* a HomeKit toggle that disagreed with the app's toggle for the same camera is
|
|
1015
|
+
* worse than either surface not having one.
|
|
1016
|
+
*
|
|
1017
|
+
* Two consequences follow and both are load-bearing:
|
|
1018
|
+
*
|
|
1019
|
+
* - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
|
|
1020
|
+
* exactly this row. With the polarity above, every camera that has NOT drawn
|
|
1021
|
+
* a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
|
|
1022
|
+
* normal, healthy state of most cameras rendered as an operator disablement.
|
|
1023
|
+
* - **Its cost line names BOTH directions.** `costWhenOff` is rendered
|
|
1024
|
+
* unconditionally by both clients, so for this row it has to read correctly
|
|
1025
|
+
* whichever way the switch is sitting.
|
|
916
1026
|
*
|
|
917
1027
|
* The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
|
|
918
1028
|
* migrated the legacy `audioEnabled` / `pipelineEnabled` /
|
|
@@ -931,22 +1041,38 @@ var DEFAULT_RETENTION = {
|
|
|
931
1041
|
* `CameraStatus.switchedOff`.
|
|
932
1042
|
*/
|
|
933
1043
|
/**
|
|
934
|
-
* The
|
|
935
|
-
*
|
|
1044
|
+
* The functions the operator named — five on 2026-08-05, plus the camera's own
|
|
1045
|
+
* microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
|
|
1046
|
+
* recognition and plate/LPR are per-step toggles on
|
|
936
1047
|
* `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
|
|
937
|
-
* editor, not in a
|
|
1048
|
+
* editor, not in a safety group.
|
|
938
1049
|
*/
|
|
939
1050
|
var CameraSwitchIdSchema = zod.z.enum([
|
|
940
1051
|
"stream-broker",
|
|
941
1052
|
"object-detection",
|
|
1053
|
+
"privacy-mask",
|
|
1054
|
+
"device-audio",
|
|
942
1055
|
"audio-analysis",
|
|
943
1056
|
"recording",
|
|
944
1057
|
"notifications"
|
|
945
1058
|
]);
|
|
946
|
-
/**
|
|
1059
|
+
/**
|
|
1060
|
+
* Stable render order — broadest blast radius first, and a source before the
|
|
1061
|
+
* thing that consumes it. `device-audio` sits ABOVE `audio-analysis` because
|
|
1062
|
+
* turning the microphone off leaves the analyzer with nothing to analyse; the
|
|
1063
|
+
* reverse is not true.
|
|
1064
|
+
*
|
|
1065
|
+
* `privacy-mask` sits directly ABOVE `device-audio` because they are literal
|
|
1066
|
+
* siblings — one cap, one device plane, video then audio — and NOT above
|
|
1067
|
+
* `object-detection` despite feeding it: a mask blanks REGIONS, so its blast
|
|
1068
|
+
* radius is partial, and the "broadest first" rule does not rank a partial
|
|
1069
|
+
* control above a whole-function one.
|
|
1070
|
+
*/
|
|
947
1071
|
var CAMERA_SWITCH_ORDER = [
|
|
948
1072
|
"stream-broker",
|
|
949
1073
|
"object-detection",
|
|
1074
|
+
"privacy-mask",
|
|
1075
|
+
"device-audio",
|
|
950
1076
|
"audio-analysis",
|
|
951
1077
|
"recording",
|
|
952
1078
|
"notifications"
|
|
@@ -964,14 +1090,26 @@ var CameraSwitchAuthoritySchema = zod.z.discriminatedUnion("kind", [
|
|
|
964
1090
|
capName: zod.z.string()
|
|
965
1091
|
}),
|
|
966
1092
|
zod.z.object({ kind: zod.z.literal("recording-config") }),
|
|
967
|
-
zod.z.object({ kind: zod.z.literal("notification-mute") })
|
|
1093
|
+
zod.z.object({ kind: zod.z.literal("notification-mute") }),
|
|
1094
|
+
zod.z.object({
|
|
1095
|
+
kind: zod.z.literal("camera-audio"),
|
|
1096
|
+
capName: zod.z.string()
|
|
1097
|
+
}),
|
|
1098
|
+
zod.z.object({
|
|
1099
|
+
kind: zod.z.literal("camera-mask"),
|
|
1100
|
+
capName: zod.z.string()
|
|
1101
|
+
})
|
|
968
1102
|
]);
|
|
969
1103
|
/**
|
|
970
1104
|
* Why a switch is not offered for this camera. Rendered instead of the
|
|
971
1105
|
* control, never as a dead control — an absent function and a broken one must
|
|
972
1106
|
* not look the same.
|
|
973
1107
|
*/
|
|
974
|
-
var CameraSwitchUnavailableReasonSchema = zod.z.enum([
|
|
1108
|
+
var CameraSwitchUnavailableReasonSchema = zod.z.enum([
|
|
1109
|
+
"no-provider",
|
|
1110
|
+
"source-unreachable",
|
|
1111
|
+
"not-configured"
|
|
1112
|
+
]);
|
|
975
1113
|
/**
|
|
976
1114
|
* One switch, resolved for one camera.
|
|
977
1115
|
*
|
|
@@ -1011,6 +1149,13 @@ var CameraSwitchGroupSchema = zod.z.object({
|
|
|
1011
1149
|
var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
|
|
1012
1150
|
var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
|
|
1013
1151
|
/**
|
|
1152
|
+
* The device-NATIVE cap that owns what the camera does not capture — masked
|
|
1153
|
+
* video regions and, since 2026-08-07, the microphone. Named here because the
|
|
1154
|
+
* `camera-audio` authority, the orchestrator's gather and the fake harness all
|
|
1155
|
+
* have to agree on it.
|
|
1156
|
+
*/
|
|
1157
|
+
var PRIVACY_MASK_CAP_NAME = "privacy-mask";
|
|
1158
|
+
/**
|
|
1014
1159
|
* THE catalog. One entry per switch; the cost lines are the operator-facing
|
|
1015
1160
|
* contract and are written to be true rather than reassuring.
|
|
1016
1161
|
*/
|
|
@@ -1019,7 +1164,8 @@ var CAMERA_SWITCH_CATALOG = {
|
|
|
1019
1164
|
id: "stream-broker",
|
|
1020
1165
|
label: "Camera",
|
|
1021
1166
|
costWhenOff: "Off: the whole camera stops. No live view, no recording, no detection and no notifications — its streams are released and nothing dials it again until you turn it back on.",
|
|
1022
|
-
authority: { kind: "device-disabled" }
|
|
1167
|
+
authority: { kind: "device-disabled" },
|
|
1168
|
+
countsAsSwitchedOff: true
|
|
1023
1169
|
},
|
|
1024
1170
|
"object-detection": {
|
|
1025
1171
|
id: "object-detection",
|
|
@@ -1028,7 +1174,28 @@ var CAMERA_SWITCH_CATALOG = {
|
|
|
1028
1174
|
authority: {
|
|
1029
1175
|
kind: "wrapper-binding",
|
|
1030
1176
|
capName: DETECTION_PIPELINE_CAP_NAME
|
|
1031
|
-
}
|
|
1177
|
+
},
|
|
1178
|
+
countsAsSwitchedOff: true
|
|
1179
|
+
},
|
|
1180
|
+
"privacy-mask": {
|
|
1181
|
+
id: "privacy-mask",
|
|
1182
|
+
label: "Privacy mask",
|
|
1183
|
+
costWhenOff: "On: the zones you drew on this camera are blacked out by the camera itself — live view, playback, exports and detection all see the black boxes, and nothing behind them was ever recorded. Off: the camera captures the whole frame, and your zones are kept for when you turn it back on.",
|
|
1184
|
+
authority: {
|
|
1185
|
+
kind: "camera-mask",
|
|
1186
|
+
capName: PRIVACY_MASK_CAP_NAME
|
|
1187
|
+
},
|
|
1188
|
+
countsAsSwitchedOff: false
|
|
1189
|
+
},
|
|
1190
|
+
"device-audio": {
|
|
1191
|
+
id: "device-audio",
|
|
1192
|
+
label: "Camera microphone",
|
|
1193
|
+
costWhenOff: "Off: the camera captures no sound at all. Live view and recordings become silent video, and audio detection and classification have nothing left to analyse — turning them back on will not recover it. The picture, motion and object detection are unaffected, and two-way talk still works. Applies at the camera, so every consumer sees the same silence.",
|
|
1194
|
+
authority: {
|
|
1195
|
+
kind: "camera-audio",
|
|
1196
|
+
capName: PRIVACY_MASK_CAP_NAME
|
|
1197
|
+
},
|
|
1198
|
+
countsAsSwitchedOff: true
|
|
1032
1199
|
},
|
|
1033
1200
|
"audio-analysis": {
|
|
1034
1201
|
id: "audio-analysis",
|
|
@@ -1037,19 +1204,22 @@ var CAMERA_SWITCH_CATALOG = {
|
|
|
1037
1204
|
authority: {
|
|
1038
1205
|
kind: "wrapper-binding",
|
|
1039
1206
|
capName: AUDIO_ANALYSIS_CAP_NAME
|
|
1040
|
-
}
|
|
1207
|
+
},
|
|
1208
|
+
countsAsSwitchedOff: true
|
|
1041
1209
|
},
|
|
1042
1210
|
recording: {
|
|
1043
1211
|
id: "recording",
|
|
1044
1212
|
label: "Recording",
|
|
1045
1213
|
costWhenOff: "Off: nothing new is written to disk. Footage already recorded stays, but retention keeps deleting it — so this camera’s history shrinks and is not replaced. Your recording schedule is kept and resumes when you turn it back on.",
|
|
1046
|
-
authority: { kind: "recording-config" }
|
|
1214
|
+
authority: { kind: "recording-config" },
|
|
1215
|
+
countsAsSwitchedOff: true
|
|
1047
1216
|
},
|
|
1048
1217
|
notifications: {
|
|
1049
1218
|
id: "notifications",
|
|
1050
1219
|
label: "Notifications",
|
|
1051
1220
|
costWhenOff: "Off: this camera never notifies anyone, on any rule, with no expiry. Detection, events and recording carry on exactly as before — you simply stop being told about them.",
|
|
1052
|
-
authority: { kind: "notification-mute" }
|
|
1221
|
+
authority: { kind: "notification-mute" },
|
|
1222
|
+
countsAsSwitchedOff: true
|
|
1053
1223
|
}
|
|
1054
1224
|
};
|
|
1055
1225
|
/** Resolve one switch's `{ available, enabled }` pair. */
|
|
@@ -1096,6 +1266,55 @@ function resolveState(descriptor, input) {
|
|
|
1096
1266
|
available: true,
|
|
1097
1267
|
enabled: !input.notificationsMuted
|
|
1098
1268
|
};
|
|
1269
|
+
case "camera-audio": {
|
|
1270
|
+
const audio = input.deviceAudio;
|
|
1271
|
+
if (audio === null) return {
|
|
1272
|
+
available: false,
|
|
1273
|
+
enabled: true,
|
|
1274
|
+
unavailableReason: "source-unreachable"
|
|
1275
|
+
};
|
|
1276
|
+
if (!audio.supported) return {
|
|
1277
|
+
available: false,
|
|
1278
|
+
enabled: true,
|
|
1279
|
+
unavailableReason: "no-provider"
|
|
1280
|
+
};
|
|
1281
|
+
if (audio.enabled === null) return {
|
|
1282
|
+
available: false,
|
|
1283
|
+
enabled: true,
|
|
1284
|
+
unavailableReason: "source-unreachable"
|
|
1285
|
+
};
|
|
1286
|
+
return {
|
|
1287
|
+
available: true,
|
|
1288
|
+
enabled: audio.enabled
|
|
1289
|
+
};
|
|
1290
|
+
}
|
|
1291
|
+
case "camera-mask": {
|
|
1292
|
+
const mask = input.privacyMask;
|
|
1293
|
+
if (mask === null) return {
|
|
1294
|
+
available: false,
|
|
1295
|
+
enabled: false,
|
|
1296
|
+
unavailableReason: "source-unreachable"
|
|
1297
|
+
};
|
|
1298
|
+
if (!mask.supported) return {
|
|
1299
|
+
available: false,
|
|
1300
|
+
enabled: false,
|
|
1301
|
+
unavailableReason: "no-provider"
|
|
1302
|
+
};
|
|
1303
|
+
if (mask.configuredRegions === null || mask.enabled === null) return {
|
|
1304
|
+
available: false,
|
|
1305
|
+
enabled: false,
|
|
1306
|
+
unavailableReason: "source-unreachable"
|
|
1307
|
+
};
|
|
1308
|
+
if (mask.configuredRegions === 0) return {
|
|
1309
|
+
available: false,
|
|
1310
|
+
enabled: false,
|
|
1311
|
+
unavailableReason: "not-configured"
|
|
1312
|
+
};
|
|
1313
|
+
return {
|
|
1314
|
+
available: true,
|
|
1315
|
+
enabled: mask.enabled
|
|
1316
|
+
};
|
|
1317
|
+
}
|
|
1099
1318
|
}
|
|
1100
1319
|
}
|
|
1101
1320
|
/**
|
|
@@ -1129,9 +1348,15 @@ function deriveCameraSwitches(input) {
|
|
|
1129
1348
|
* `switchedOff: ['object-detection']` was turned off; the same camera with an
|
|
1130
1349
|
* empty list is broken. Unavailable switches never appear — a function nobody
|
|
1131
1350
|
* provides was not switched off by anyone.
|
|
1351
|
+
*
|
|
1352
|
+
* `privacy-mask` never appears either, whichever way it is sitting, because its
|
|
1353
|
+
* ON means "the mask is active" rather than "the function works"
|
|
1354
|
+
* ({@link CameraSwitchDescriptor.countsAsSwitchedOff}). Without that filter the
|
|
1355
|
+
* ordinary state of every camera nobody has masked would carry a
|
|
1356
|
+
* "switched off" badge, and the badge that matters would be lost in it.
|
|
1132
1357
|
*/
|
|
1133
1358
|
function switchedOffIds(switches) {
|
|
1134
|
-
return switches.filter((s) => s.available && !s.enabled).map((s) => s.id);
|
|
1359
|
+
return switches.filter((s) => CAMERA_SWITCH_CATALOG[s.id].countsAsSwitchedOff && s.available && !s.enabled).map((s) => s.id);
|
|
1135
1360
|
}
|
|
1136
1361
|
//#endregion
|
|
1137
1362
|
//#region src/interfaces/device-capabilities/camera.ts
|
|
@@ -4167,6 +4392,26 @@ var EgressTranscodeRequestSchema = zod.z.object({
|
|
|
4167
4392
|
"h264_mp4toannexb",
|
|
4168
4393
|
"hevc_mp4toannexb"
|
|
4169
4394
|
]).optional(),
|
|
4395
|
+
/**
|
|
4396
|
+
* Publish the transcode as a LOCAL push cam stream, instead of leaving the
|
|
4397
|
+
* consumer to dial the returned url. The broker picks the id and returns it
|
|
4398
|
+
* as `camStreamId` — a caller-supplied one would be circular, since the
|
|
4399
|
+
* sharing key is computed FROM this request.
|
|
4400
|
+
*
|
|
4401
|
+
* The url is still returned and still the contract for a transcode pinned to
|
|
4402
|
+
* another node. But dialling it locally costs an RTSP round trip that changes
|
|
4403
|
+
* the transport underneath the consumer: a dialled stream is an RTP source,
|
|
4404
|
+
* so `isRtpSource()` is true and the session takes the RTP-passthrough +
|
|
4405
|
+
* repacketizer branch. The push branch — the one the derived mechanism has
|
|
4406
|
+
* live hours on — is never reached. Measured on Alexa: broker registered, RTP
|
|
4407
|
+
* arriving, key frame arriving, black screen, on a chain healthy at every
|
|
4408
|
+
* other point.
|
|
4409
|
+
*
|
|
4410
|
+
* Same idea the transport already applies to CALLS, where `classifyCapRoute`
|
|
4411
|
+
* gives priority to `hub-in-process` so a local call never leaves the node.
|
|
4412
|
+
* This is that rule for media.
|
|
4413
|
+
*/
|
|
4414
|
+
publishLocally: zod.z.boolean().optional(),
|
|
4170
4415
|
pixelFormat: zod.z.enum(["yuv420p", "nv12"]).optional(),
|
|
4171
4416
|
/**
|
|
4172
4417
|
* Operator/consumer override for decode hardware. ABSENT is the normal case
|
|
@@ -4211,7 +4456,13 @@ var EgressTranscodeSchema = zod.z.object({
|
|
|
4211
4456
|
* Returned rather than assumed: a consumer that asked for hardware and got
|
|
4212
4457
|
* software needs to be able to see that without reading the broker's logs.
|
|
4213
4458
|
*/
|
|
4214
|
-
decodeHwAccel: zod.z.string().nullable()
|
|
4459
|
+
decodeHwAccel: zod.z.string().nullable(),
|
|
4460
|
+
/**
|
|
4461
|
+
* Set when `publishLocally` was honoured: attach to THIS instead of dialling
|
|
4462
|
+
* `url`, and the session takes the push/deframe transport rather than the
|
|
4463
|
+
* RTP-passthrough one. `null` means the consumer must dial.
|
|
4464
|
+
*/
|
|
4465
|
+
camStreamId: zod.z.string().nullable()
|
|
4215
4466
|
});
|
|
4216
4467
|
var streamBrokerCapability = {
|
|
4217
4468
|
name: "stream-broker",
|
|
@@ -15863,9 +16114,15 @@ var snapshotCapability = {
|
|
|
15863
16114
|
* Bypass the cache freshness check and fetch directly from the
|
|
15864
16115
|
* native (or stream-broker fallback). Triggered by the UI's
|
|
15865
16116
|
* "refresh" button so an operator can force a fresh frame
|
|
15866
|
-
* even when the cache is well within
|
|
15867
|
-
*
|
|
15868
|
-
*
|
|
16117
|
+
* even when the cache is well within the device's
|
|
16118
|
+
* `snapshotMaxAgeS` window.
|
|
16119
|
+
*
|
|
16120
|
+
* **`force` is an OPERATOR signal, not a freshness preference.** On a
|
|
16121
|
+
* battery camera it is the one thing that walks past the wrapper's
|
|
16122
|
+
* sleep gate and wakes the camera, so a background caller — a poller,
|
|
16123
|
+
* an event handler, a thumbnail — must NEVER set it. Every such caller
|
|
16124
|
+
* gets the cached frame, which on a sleeping battery camera is the
|
|
16125
|
+
* correct answer: stale but honest beats woken.
|
|
15869
16126
|
*/
|
|
15870
16127
|
force: zod.z.boolean().optional()
|
|
15871
16128
|
}), SnapshotImageSchema.nullable()),
|
|
@@ -22488,12 +22745,30 @@ var pressureSensorCapability = {
|
|
|
22488
22745
|
//#endregion
|
|
22489
22746
|
//#region src/capabilities/privacy-mask.cap.ts
|
|
22490
22747
|
/**
|
|
22491
|
-
*
|
|
22492
|
-
*
|
|
22493
|
-
*
|
|
22494
|
-
*
|
|
22495
|
-
*
|
|
22496
|
-
*
|
|
22748
|
+
* PRIVACY — what the camera deliberately does not capture. Two planes:
|
|
22749
|
+
*
|
|
22750
|
+
* - **video**: up to `maxRegions` SHAPES the camera blanks out (NOT a cell
|
|
22751
|
+
* grid). Reolink `<shelterList>` zones are rectangles; Hikvision ISAPI
|
|
22752
|
+
* `<RegionCoordinatesList>` zones are free polygons (this camera: exactly
|
|
22753
|
+
* 4 vertices, not necessarily axis-aligned). The cap composes the shared
|
|
22754
|
+
* rect|polygon subset of the MaskShape vocabulary. All coords are
|
|
22755
|
+
* normalized 0..1 (top-left origin).
|
|
22756
|
+
* - **audio**: the camera's microphone. `setAudioEnabled(false)` stops the
|
|
22757
|
+
* camera encoding an audio track at all, so EVERY consumer — live view,
|
|
22758
|
+
* recording, the audio analyzer, an export — sees silent video. There is
|
|
22759
|
+
* no server-side copy of this fact; the camera is the store and every read
|
|
22760
|
+
* is a read-through, which is why a switch over it cannot drift
|
|
22761
|
+
* ([D62](../../../../docs/decisions/adr-0062.md)).
|
|
22762
|
+
*
|
|
22763
|
+
* Both belong here for one reason: they are the two things an operator turns
|
|
22764
|
+
* off when the answer to "what is this camera allowed to record" changes, and
|
|
22765
|
+
* both are applied ON the device, before anything leaves it.
|
|
22766
|
+
*
|
|
22767
|
+
* **The audio flag has exactly one writer.** `stream-params` used to carry a
|
|
22768
|
+
* per-profile `audio` in its patch schema — reachable from no UI and honoured
|
|
22769
|
+
* by one provider — and it was removed when this landed. A second writer onto
|
|
22770
|
+
* one device register is the shape of every knob this repo has shipped that
|
|
22771
|
+
* disagreed with the one the reader read.
|
|
22497
22772
|
*/
|
|
22498
22773
|
/** A privacy-mask region's geometry — rectangle or free polygon. */
|
|
22499
22774
|
var PrivacyMaskShapeSchema = zod.z.discriminatedUnion("kind", [MaskRectShapeSchema, MaskPolygonShapeSchema]);
|
|
@@ -22505,21 +22780,45 @@ var PrivacyMaskRegionSchema = zod.z.object({
|
|
|
22505
22780
|
enabled: zod.z.boolean(),
|
|
22506
22781
|
shape: PrivacyMaskShapeSchema
|
|
22507
22782
|
});
|
|
22508
|
-
/** Current on-camera privacy
|
|
22783
|
+
/** Current on-camera privacy state — mask master enable + zones + microphone. */
|
|
22509
22784
|
var PrivacyMaskStatusSchema = zod.z.object({
|
|
22510
22785
|
enabled: zod.z.boolean(),
|
|
22511
22786
|
/** Active zones (normalized 0..1). Length ≤ maxRegions. */
|
|
22512
22787
|
regions: zod.z.array(PrivacyMaskRegionSchema),
|
|
22788
|
+
/**
|
|
22789
|
+
* Is the camera capturing sound right now? Read from the camera, never from
|
|
22790
|
+
* a server-side mirror.
|
|
22791
|
+
*
|
|
22792
|
+
* `null` means "no answer" — either this camera exposes no controllable
|
|
22793
|
+
* microphone (`getOptions().supportsAudioMute === false`) or the read
|
|
22794
|
+
* failed. A consumer must render `null` as UNKNOWN and never as `false`:
|
|
22795
|
+
* "the microphone is off" and "we could not ask" look identical to an
|
|
22796
|
+
* operator only until one of them is wrong.
|
|
22797
|
+
*
|
|
22798
|
+
* On a camera whose profiles carry the flag independently (Reolink writes
|
|
22799
|
+
* it per stream), `true` means AT LEAST ONE profile still carries audio —
|
|
22800
|
+
* privacy is only satisfied when every one of them is silent.
|
|
22801
|
+
*/
|
|
22802
|
+
audioEnabled: zod.z.boolean().nullable(),
|
|
22513
22803
|
lastFetchedAt: zod.z.number()
|
|
22514
22804
|
});
|
|
22515
|
-
/** Per-camera availability. */
|
|
22805
|
+
/** Per-camera availability. Probed, never assumed from the model name. */
|
|
22516
22806
|
var PrivacyMaskOptionsSchema = zod.z.object({
|
|
22517
22807
|
/** Maximum number of supported zones. */
|
|
22518
22808
|
maxRegions: zod.z.number(),
|
|
22519
22809
|
/** Shape kinds this camera accepts — Reolink: ['rect']; Hikvision: ['rect','polygon']. */
|
|
22520
22810
|
supportedShapes: zod.z.array(MaskShapeKindSchema),
|
|
22521
22811
|
/** Polygon vertex bounds when 'polygon' is supported (Hikvision: {min:4,max:4}). */
|
|
22522
|
-
polygonVertices: MaskPolygonVerticesSchema.optional()
|
|
22812
|
+
polygonVertices: MaskPolygonVerticesSchema.optional(),
|
|
22813
|
+
/**
|
|
22814
|
+
* Does this camera expose a microphone switch we can actually write?
|
|
22815
|
+
*
|
|
22816
|
+
* Camera-probed: `true` only when the firmware answered with an audio flag
|
|
22817
|
+
* we know how to patch. A camera that never answered is `false` — a control
|
|
22818
|
+
* the operator can press that changes nothing is worse than no control, and
|
|
22819
|
+
* the switch group renders "not available" instead.
|
|
22820
|
+
*/
|
|
22821
|
+
supportsAudioMute: zod.z.boolean()
|
|
22523
22822
|
});
|
|
22524
22823
|
/** Partial change — every field optional. */
|
|
22525
22824
|
var PrivacyMaskPatchSchema = zod.z.object({
|
|
@@ -22546,6 +22845,27 @@ var privacyMaskCapability = {
|
|
|
22546
22845
|
}), zod.z.void(), {
|
|
22547
22846
|
kind: "mutation",
|
|
22548
22847
|
auth: "admin"
|
|
22848
|
+
}),
|
|
22849
|
+
/**
|
|
22850
|
+
* Turn the camera's microphone on or off, at the camera.
|
|
22851
|
+
*
|
|
22852
|
+
* Deliberately its OWN mutation rather than a field on
|
|
22853
|
+
* {@link PrivacyMaskPatchSchema}: `patch.enabled` already means "the video
|
|
22854
|
+
* mask master switch", and overloading it would make one boolean mean two
|
|
22855
|
+
* unrelated things on the same call. It is also the only method here whose
|
|
22856
|
+
* write leaves the device in a state a later `getStatus` reads back
|
|
22857
|
+
* verbatim, which is what makes it safe as a switch authority.
|
|
22858
|
+
*
|
|
22859
|
+
* A camera whose `getOptions().supportsAudioMute` is false must REJECT
|
|
22860
|
+
* this rather than silently accept it — a write nothing applies is exactly
|
|
22861
|
+
* what the switch group exists to remove.
|
|
22862
|
+
*/
|
|
22863
|
+
setAudioEnabled: require_sleep.method(zod.z.object({
|
|
22864
|
+
deviceId: zod.z.number(),
|
|
22865
|
+
enabled: zod.z.boolean()
|
|
22866
|
+
}), zod.z.void(), {
|
|
22867
|
+
kind: "mutation",
|
|
22868
|
+
auth: "admin"
|
|
22549
22869
|
})
|
|
22550
22870
|
},
|
|
22551
22871
|
status: {
|
|
@@ -22554,6 +22874,26 @@ var privacyMaskCapability = {
|
|
|
22554
22874
|
},
|
|
22555
22875
|
runtimeState: PrivacyMaskStatusSchema
|
|
22556
22876
|
};
|
|
22877
|
+
/**
|
|
22878
|
+
* Collapse a camera's PER-PROFILE audio flags into the one answer
|
|
22879
|
+
* {@link PrivacyMaskStatusSchema.shape.audioEnabled} promises.
|
|
22880
|
+
*
|
|
22881
|
+
* Both firmwares this cap talks to store the flag per stream profile, and
|
|
22882
|
+
* both let those profiles disagree. The rule is `some`, not `every`: privacy
|
|
22883
|
+
* is only satisfied when NOTHING is carrying sound, so a camera whose sub
|
|
22884
|
+
* stream is still audible must read as `true` and be switchable off — not as
|
|
22885
|
+
* `false` because the main stream happens to be muted already.
|
|
22886
|
+
*
|
|
22887
|
+
* An empty list is `null` ("this camera reported no audio flag at all"),
|
|
22888
|
+
* never `false`.
|
|
22889
|
+
*
|
|
22890
|
+
* Lives here rather than in each provider so the rule the schema documents
|
|
22891
|
+
* and the rule the providers apply cannot drift apart.
|
|
22892
|
+
*/
|
|
22893
|
+
function summarisePrivacyAudio(profiles) {
|
|
22894
|
+
if (profiles.length === 0) return null;
|
|
22895
|
+
return profiles.some((p) => p.audioEnabled);
|
|
22896
|
+
}
|
|
22557
22897
|
//#endregion
|
|
22558
22898
|
//#region src/capabilities/ptz.cap.ts
|
|
22559
22899
|
var PtzPresetSchema = zod.z.object({
|
|
@@ -22938,6 +23278,21 @@ var LocateSegmentResultSchema = zod.z.discriminatedUnion("kind", [zod.z.object({
|
|
|
22938
23278
|
})]);
|
|
22939
23279
|
/** Raw bytes of one finalized footage segment (read off disk on the recording node). */
|
|
22940
23280
|
var ReadSegmentBytesResultSchema = zod.z.object({ data: zod.z.instanceof(Uint8Array) });
|
|
23281
|
+
/**
|
|
23282
|
+
* One GOP of a finalized segment, cut by byte range through the segment's own
|
|
23283
|
+
* `mfra` (D31 on the D42 feeder path). `data` is the `ftyp`+`moov` head plus
|
|
23284
|
+
* the single `moof`+`mdat` covering the requested instant — standalone-
|
|
23285
|
+
* demuxable, never the whole file. When the segment's index cannot be parsed
|
|
23286
|
+
* the provider degrades INSIDE the mechanism to the whole segment (still one
|
|
23287
|
+
* `data`, `gopStartMs` = the segment start) — a worse read, not another path.
|
|
23288
|
+
*/
|
|
23289
|
+
var ReadGopBytesResultSchema = zod.z.object({
|
|
23290
|
+
data: zod.z.instanceof(Uint8Array),
|
|
23291
|
+
/** Absolute epoch ms of the returned fragment's first sample. */
|
|
23292
|
+
gopStartMs: zod.z.number(),
|
|
23293
|
+
/** Media ms the returned fragment covers. */
|
|
23294
|
+
gopDurMs: zod.z.number()
|
|
23295
|
+
});
|
|
22941
23296
|
var recordingCapability = {
|
|
22942
23297
|
name: "recording",
|
|
22943
23298
|
scope: "system",
|
|
@@ -23005,6 +23360,18 @@ var recordingCapability = {
|
|
|
23005
23360
|
kind: "query",
|
|
23006
23361
|
auth: "admin"
|
|
23007
23362
|
}),
|
|
23363
|
+
/** Read the single GOP of segment `startMs` covering `epochMs`, by mfra
|
|
23364
|
+
* byte range — the scrub-granular read (D31 letter on the feeder path).
|
|
23365
|
+
* See {@link ReadGopBytesResultSchema} for the degradation contract. */
|
|
23366
|
+
readGopBytes: require_sleep.method(zod.z.object({
|
|
23367
|
+
deviceId: zod.z.number(),
|
|
23368
|
+
profile: zod.z.string(),
|
|
23369
|
+
startMs: zod.z.number(),
|
|
23370
|
+
epochMs: zod.z.number()
|
|
23371
|
+
}), ReadGopBytesResultSchema, {
|
|
23372
|
+
kind: "query",
|
|
23373
|
+
auth: "admin"
|
|
23374
|
+
}),
|
|
23008
23375
|
setDeviceConfig: require_sleep.method(zod.z.object({
|
|
23009
23376
|
deviceId: zod.z.number(),
|
|
23010
23377
|
config: RecordingConfigSchema
|
|
@@ -23666,6 +24033,16 @@ var StreamProfileConfigSchema = zod.z.object({
|
|
|
23666
24033
|
"baseline"
|
|
23667
24034
|
]).optional(),
|
|
23668
24035
|
gop: zod.z.number().optional(),
|
|
24036
|
+
/**
|
|
24037
|
+
* Whether THIS profile currently carries an audio track. READ-ONLY here.
|
|
24038
|
+
*
|
|
24039
|
+
* There is no matching field on {@link StreamProfilePatchSchema}: the
|
|
24040
|
+
* camera's microphone is owned by `privacy-mask` (`setAudioEnabled`), which
|
|
24041
|
+
* writes every profile at once so "audio off" means silent everywhere. A
|
|
24042
|
+
* per-profile writer beside it would let a camera be half-muted and would be
|
|
24043
|
+
* a second knob onto one device register — the failure D62 exists to
|
|
24044
|
+
* prevent. Absent when the firmware does not report the flag.
|
|
24045
|
+
*/
|
|
23669
24046
|
audio: zod.z.boolean().optional()
|
|
23670
24047
|
});
|
|
23671
24048
|
var StreamParamsStatusSchema = zod.z.object({
|
|
@@ -23706,7 +24083,13 @@ var StreamParamsOptionsSchema = zod.z.object({
|
|
|
23706
24083
|
ext: StreamProfileOptionsSchema.optional()
|
|
23707
24084
|
});
|
|
23708
24085
|
/** A partial change to one profile — every field optional; a provider
|
|
23709
|
-
* ignores fields it doesn't support.
|
|
24086
|
+
* ignores fields it doesn't support.
|
|
24087
|
+
*
|
|
24088
|
+
* There is deliberately NO `audio` here. It existed until 2026-08-07,
|
|
24089
|
+
* reachable from no form and honoured by exactly one provider, while the
|
|
24090
|
+
* camera's microphone is a whole-device fact. It now has one writer,
|
|
24091
|
+
* `privacyMask.setAudioEnabled`, which writes every profile — see
|
|
24092
|
+
* `privacy-mask.cap.ts`. */
|
|
23710
24093
|
var StreamProfilePatchSchema = zod.z.object({
|
|
23711
24094
|
width: zod.z.number().optional(),
|
|
23712
24095
|
height: zod.z.number().optional(),
|
|
@@ -23719,8 +24102,7 @@ var StreamProfilePatchSchema = zod.z.object({
|
|
|
23719
24102
|
"main",
|
|
23720
24103
|
"baseline"
|
|
23721
24104
|
]).optional(),
|
|
23722
|
-
gop: zod.z.number().optional()
|
|
23723
|
-
audio: zod.z.boolean().optional()
|
|
24105
|
+
gop: zod.z.number().optional()
|
|
23724
24106
|
});
|
|
23725
24107
|
var streamParamsCapability = {
|
|
23726
24108
|
name: "stream-params",
|
|
@@ -26639,10 +27021,7 @@ var BATTERY_DEVICE_PROFILE = {
|
|
|
26639
27021
|
audioMode: "disabled",
|
|
26640
27022
|
detectionMode: "on-motion"
|
|
26641
27023
|
},
|
|
26642
|
-
settings: {
|
|
26643
|
-
"snapshot.minRefreshIntervalSec": 3600,
|
|
26644
|
-
"streamBroker.preBufferEnabled": false
|
|
26645
|
-
}
|
|
27024
|
+
settings: {}
|
|
26646
27025
|
};
|
|
26647
27026
|
/**
|
|
26648
27027
|
* Profile registry — order matters when multiple profiles match the
|
|
@@ -27494,7 +27873,7 @@ var SystemMirror = class {
|
|
|
27494
27873
|
}
|
|
27495
27874
|
async refreshDeviceMetadata(deviceId, kind) {
|
|
27496
27875
|
try {
|
|
27497
|
-
const info =
|
|
27876
|
+
const info = await this.api.deviceManager.getDevice.query({ deviceId });
|
|
27498
27877
|
if (!info) return;
|
|
27499
27878
|
const wasNew = !this.devices.has(deviceId);
|
|
27500
27879
|
this.devices.set(deviceId, info);
|
|
@@ -32386,6 +32765,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
|
|
|
32386
32765
|
addonId: null,
|
|
32387
32766
|
access: "view"
|
|
32388
32767
|
},
|
|
32768
|
+
"privacyMask.setAudioEnabled": {
|
|
32769
|
+
capName: "privacy-mask",
|
|
32770
|
+
capScope: "device",
|
|
32771
|
+
addonId: null,
|
|
32772
|
+
access: "create"
|
|
32773
|
+
},
|
|
32389
32774
|
"privacyMask.setMask": {
|
|
32390
32775
|
capName: "privacy-mask",
|
|
32391
32776
|
capScope: "device",
|
|
@@ -32554,6 +32939,12 @@ var METHOD_ACCESS_MAP = Object.freeze({
|
|
|
32554
32939
|
addonId: null,
|
|
32555
32940
|
access: "create"
|
|
32556
32941
|
},
|
|
32942
|
+
"recording.readGopBytes": {
|
|
32943
|
+
capName: "recording",
|
|
32944
|
+
capScope: "system",
|
|
32945
|
+
addonId: null,
|
|
32946
|
+
access: "view"
|
|
32947
|
+
},
|
|
32557
32948
|
"recording.readSegmentBytes": {
|
|
32558
32949
|
capName: "recording",
|
|
32559
32950
|
capScope: "system",
|
|
@@ -35572,6 +35963,44 @@ function bestLocationMatch(externalName, existing, threshold = .8) {
|
|
|
35572
35963
|
return best;
|
|
35573
35964
|
}
|
|
35574
35965
|
//#endregion
|
|
35966
|
+
//#region src/utils/addon-id.ts
|
|
35967
|
+
/**
|
|
35968
|
+
* The addon id as the REGISTRY, the wire and the durable stores spell it.
|
|
35969
|
+
*
|
|
35970
|
+
* `AddonContext.id` does not agree with itself across the two context
|
|
35971
|
+
* factories:
|
|
35972
|
+
*
|
|
35973
|
+
* - a FORKED addon gets the bare manifest id
|
|
35974
|
+
* (`kernel/moleculer/addon-context-factory.ts` → `id: addonId`);
|
|
35975
|
+
* - an addon co-located in hub-main gets it PREFIXED
|
|
35976
|
+
* (`server/backend/src/core/addon/addon-registry.service.ts` →
|
|
35977
|
+
* ``id: `addon:${addonId}` ``).
|
|
35978
|
+
*
|
|
35979
|
+
* Everything an addon might compare `ctx.id` AGAINST carries the bare form:
|
|
35980
|
+
* `DeviceBindingEntry.providerAddonId`, the `device-manager` bindings store's
|
|
35981
|
+
* `wrapperAddonId`, `CapabilityRegistry` provider keys, manifest ids.
|
|
35982
|
+
*
|
|
35983
|
+
* So `entry.providerAddonId === this.ctx.id` is silently, permanently false
|
|
35984
|
+
* for a builtin — and only for a builtin, which is why it survives a green
|
|
35985
|
+
* suite whose fake supplies the bare id. That is exactly how camera 615's
|
|
35986
|
+
* virtual doorbell latched `unbound` for twelve hours on 2026-08-07 while its
|
|
35987
|
+
* binding was intact ([D72](../../../../docs/decisions/adr-0072.md)).
|
|
35988
|
+
*
|
|
35989
|
+
* Route every comparison between `ctx.id` and a registry/store addon id
|
|
35990
|
+
* through {@link isSameAddonId}. `scripts/check-addon-id-comparison.ts`
|
|
35991
|
+
* enforces it in the processes where the prefix exists.
|
|
35992
|
+
*/
|
|
35993
|
+
/** The prefix the hub-main context factory prepends to the manifest id. */
|
|
35994
|
+
var ADDON_ID_PREFIX = "addon:";
|
|
35995
|
+
/** The manifest id, whichever spelling of `ctx.id` you were handed. */
|
|
35996
|
+
function bareAddonId(id) {
|
|
35997
|
+
return id.startsWith(ADDON_ID_PREFIX) ? id.slice(6) : id;
|
|
35998
|
+
}
|
|
35999
|
+
/** True when both ids name the same addon, prefixed or not. */
|
|
36000
|
+
function isSameAddonId(a, b) {
|
|
36001
|
+
return bareAddonId(a) === bareAddonId(b);
|
|
36002
|
+
}
|
|
36003
|
+
//#endregion
|
|
35575
36004
|
//#region src/utils/cosine-similarity.ts
|
|
35576
36005
|
/** Cosine similarity between two embedding vectors */
|
|
35577
36006
|
function cosineSimilarity(a, b) {
|
|
@@ -36298,7 +36727,7 @@ function readDetailCropConvention(config) {
|
|
|
36298
36727
|
square: square.success ? square.data : DEFAULT_DETAIL_CROP_CONVENTION.square
|
|
36299
36728
|
};
|
|
36300
36729
|
}
|
|
36301
|
-
function isHydratedField(entry) {
|
|
36730
|
+
function isHydratedField$1(entry) {
|
|
36302
36731
|
return typeof entry === "object" && entry !== null && "key" in entry;
|
|
36303
36732
|
}
|
|
36304
36733
|
/**
|
|
@@ -36313,7 +36742,7 @@ function pickDetailCropConvention(view) {
|
|
|
36313
36742
|
if (view === null) return DEFAULT_DETAIL_CROP_CONVENTION;
|
|
36314
36743
|
const flat = {};
|
|
36315
36744
|
for (const section of view.sections) for (const entry of section.fields) {
|
|
36316
|
-
if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
|
|
36745
|
+
if (!isHydratedField$1(entry) || typeof entry.key !== "string") continue;
|
|
36317
36746
|
if (entry.key === "detailCropPaddingRatio" || entry.key === "detailCropSquare") flat[entry.key] = entry.value;
|
|
36318
36747
|
}
|
|
36319
36748
|
return readDetailCropConvention(flat);
|
|
@@ -36400,6 +36829,245 @@ function slideInsideFrame(rect, frameWidth, frameHeight) {
|
|
|
36400
36829
|
};
|
|
36401
36830
|
}
|
|
36402
36831
|
//#endregion
|
|
36832
|
+
//#region src/pipeline/native-lease.ts
|
|
36833
|
+
/**
|
|
36834
|
+
* THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
|
|
36835
|
+
* decode worker's native-resolution frame retention.
|
|
36836
|
+
*
|
|
36837
|
+
* ## Why they live here and not in the addon that reads them
|
|
36838
|
+
*
|
|
36839
|
+
* The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
|
|
36840
|
+
* the READER is a private child process of `addon-pipeline`'s pipeline-runner.
|
|
36841
|
+
* Addons never import each other, so a key owned by either side would have to
|
|
36842
|
+
* be hand-copied by the other — and a hand-copied key is how a setting silently
|
|
36843
|
+
* stops arriving while both sides still look correct. Same reasoning, same
|
|
36844
|
+
* placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
|
|
36845
|
+
*
|
|
36846
|
+
* ## Why cluster-wide and not per-node
|
|
36847
|
+
*
|
|
36848
|
+
* The lease is a per-decode-worker RAM window. Its purpose — the late
|
|
36849
|
+
* cross-process native crop landing on a full-resolution frame rather than the
|
|
36850
|
+
* ≤640 detection fallback — is a property of the PIPELINE, not of a node's
|
|
36851
|
+
* hardware: a per-node TTL would mean the same camera produces different crop
|
|
36852
|
+
* quality depending on which node the balancer placed it on, and nobody could
|
|
36853
|
+
* tell that from the stored media. Node-level RAM pressure is already handled
|
|
36854
|
+
* by the per-session budget ceiling, which is itself one of these knobs.
|
|
36855
|
+
*
|
|
36856
|
+
* ## What each knob costs
|
|
36857
|
+
*
|
|
36858
|
+
* A retained frame is a full NATIVE-resolution copy in system RAM. With the
|
|
36859
|
+
* default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
|
|
36860
|
+
* 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
|
|
36861
|
+
* for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
|
|
36862
|
+
* resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
|
|
36863
|
+
* clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
|
|
36864
|
+
* admission" for what actually gets admitted.
|
|
36865
|
+
*/
|
|
36866
|
+
/**
|
|
36867
|
+
* Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
|
|
36868
|
+
* (cluster-wide) settings. Keys are unique across that addon's whole schema, so
|
|
36869
|
+
* the reader can walk every section instead of trusting the section id.
|
|
36870
|
+
*/
|
|
36871
|
+
var NATIVE_LEASE_SECTION_ID = "native-lease";
|
|
36872
|
+
var NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
|
|
36873
|
+
var NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
|
|
36874
|
+
var NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
|
|
36875
|
+
var NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
|
|
36876
|
+
/**
|
|
36877
|
+
* WHICH delivered frames the decode worker retains a native copy of.
|
|
36878
|
+
*
|
|
36879
|
+
* - `all` — every frame the worker delivered to the runner. The shipped
|
|
36880
|
+
* behaviour, and the only correct one if something can ask for a crop of a
|
|
36881
|
+
* frame the runner never sent to inference.
|
|
36882
|
+
* - `inferred` — only the frames the runner ADMITTED to its detection queue.
|
|
36883
|
+
* A native-crop request always names a `frameId` that rode an inference
|
|
36884
|
+
* result, so that is the only set a request can name. How much it drops is
|
|
36885
|
+
* the two-plane governor's admit ratio and nothing else: measured at ~50% on
|
|
36886
|
+
* this cluster, not the ~80% the design sketch assumed, because the governor
|
|
36887
|
+
* was not throttling as hard as the sketch supposed. Read
|
|
36888
|
+
* `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
|
|
36889
|
+
* of you rather than quoting a number from here. The newest delivered frame is
|
|
36890
|
+
* croppable regardless — it is still the worker's reserved slot, not a lease —
|
|
36891
|
+
* which covers the one-frame race between a mark and the supersede that
|
|
36892
|
+
* consumes it.
|
|
36893
|
+
*/
|
|
36894
|
+
var NativeLeaseAdmissionSchema = zod.z.enum(["all", "inferred"]);
|
|
36895
|
+
/**
|
|
36896
|
+
* Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
|
|
36897
|
+
* the slider) because the value also travels to a forked child process, where a
|
|
36898
|
+
* junk number would silently become a 0-length or unbounded retention window.
|
|
36899
|
+
*/
|
|
36900
|
+
var NativeLeaseSettingsSchema = zod.z.object({
|
|
36901
|
+
/**
|
|
36902
|
+
* How long a retained native frame is served before it counts as a miss.
|
|
36903
|
+
*
|
|
36904
|
+
* Must cover the FULL late-crop horizon: detection inference + the
|
|
36905
|
+
* cross-process inference-result hop to hub post-analysis + tracking + the
|
|
36906
|
+
* tRPC crop round-trip back. Below ~500 ms the busiest cameras' subject crops
|
|
36907
|
+
* outrun it and fall back to the ≤640 detection frame; above ~3 s the resident
|
|
36908
|
+
* RAM per busy camera grows linearly with no measured hit-rate gain.
|
|
36909
|
+
*/
|
|
36910
|
+
ttlMs: zod.z.number().int().min(250).max(1e4),
|
|
36911
|
+
/**
|
|
36912
|
+
* Hard per-decode-worker RAM ceiling for retained native frames, in MB.
|
|
36913
|
+
*
|
|
36914
|
+
* Intended as a SAFETY ceiling with the TTL as the effective cap — but check
|
|
36915
|
+
* which one is actually binding before reasoning from that. At the shipped
|
|
36916
|
+
* 1024 MB and a 2 800 ms TTL, a 4K camera hits the CEILING first (~43 frames
|
|
36917
|
+
* at ~24 MB each) and the TTL never gets to expire anything; `leaseMb` /
|
|
36918
|
+
* `leaseFrames` on the metrics line say which. When the ceiling binds, a
|
|
36919
|
+
* change that admits fewer frames buys retention WINDOW at constant RAM
|
|
36920
|
+
* rather than giving RAM back — lower this knob if RAM is what you wanted.
|
|
36921
|
+
* `0` DISABLES the lease entirely and falls the worker back to the tiny
|
|
36922
|
+
* leak-prone GPU surface ring (~85% crop miss; that is what the lease exists
|
|
36923
|
+
* to replace).
|
|
36924
|
+
*/
|
|
36925
|
+
budgetMb: zod.z.number().int().min(0).max(4096),
|
|
36926
|
+
/**
|
|
36927
|
+
* Demand window: eager per-frame native retention runs only within this many
|
|
36928
|
+
* ms of the last native-crop request (or of the dial starting).
|
|
36929
|
+
*
|
|
36930
|
+
* `0` means ALWAYS ON — it disables the gate, it does not disable retention.
|
|
36931
|
+
* That is the legacy behaviour that saturated an N100 (24 native-4K downloads
|
|
36932
|
+
* per second on a camera with zero crop demand), so leave it non-zero unless
|
|
36933
|
+
* you are reproducing that.
|
|
36934
|
+
*/
|
|
36935
|
+
activityMs: zod.z.number().int().min(0).max(12e4),
|
|
36936
|
+
/**
|
|
36937
|
+
* Which delivered frames are retained at all — see
|
|
36938
|
+
* {@link NativeLeaseAdmissionSchema}. This is the only knob of the four that
|
|
36939
|
+
* changes WHAT is kept rather than for how long, so it is also the only one
|
|
36940
|
+
* that can turn a crop that used to hit into a miss. The worker counts every
|
|
36941
|
+
* crop request naming a frame it did NOT see marked
|
|
36942
|
+
* (`leaseUnmarkedCrops` on the session-decode metrics line): a non-zero value
|
|
36943
|
+
* there is the signal that some caller names frames outside the inference set
|
|
36944
|
+
* and that this must go back to `all`.
|
|
36945
|
+
*/
|
|
36946
|
+
admission: NativeLeaseAdmissionSchema
|
|
36947
|
+
});
|
|
36948
|
+
/**
|
|
36949
|
+
* The values in force when the operator has set nothing — byte-for-byte the
|
|
36950
|
+
* constants the decode worker shipped with as env-var defaults, so making these
|
|
36951
|
+
* settings changed no behaviour on the day it landed.
|
|
36952
|
+
*/
|
|
36953
|
+
var DEFAULT_NATIVE_LEASE_SETTINGS = {
|
|
36954
|
+
ttlMs: 1200,
|
|
36955
|
+
budgetMb: 1024,
|
|
36956
|
+
activityMs: 15e3,
|
|
36957
|
+
admission: "inferred"
|
|
36958
|
+
};
|
|
36959
|
+
/** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
|
|
36960
|
+
var NATIVE_LEASE_TTL_FIELD = {
|
|
36961
|
+
min: 250,
|
|
36962
|
+
max: 1e4,
|
|
36963
|
+
step: 50,
|
|
36964
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.ttlMs
|
|
36965
|
+
};
|
|
36966
|
+
var NATIVE_LEASE_BUDGET_FIELD = {
|
|
36967
|
+
min: 0,
|
|
36968
|
+
max: 4096,
|
|
36969
|
+
step: 64,
|
|
36970
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.budgetMb
|
|
36971
|
+
};
|
|
36972
|
+
var NATIVE_LEASE_ACTIVITY_FIELD = {
|
|
36973
|
+
min: 0,
|
|
36974
|
+
max: 12e4,
|
|
36975
|
+
step: 1e3,
|
|
36976
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.activityMs
|
|
36977
|
+
};
|
|
36978
|
+
/** Select options for the admission knob (orchestrator settings UI). */
|
|
36979
|
+
var NATIVE_LEASE_ADMISSION_FIELD = {
|
|
36980
|
+
options: [{
|
|
36981
|
+
value: "all",
|
|
36982
|
+
label: "Every delivered frame"
|
|
36983
|
+
}, {
|
|
36984
|
+
value: "inferred",
|
|
36985
|
+
label: "Only frames sent to inference"
|
|
36986
|
+
}],
|
|
36987
|
+
default: DEFAULT_NATIVE_LEASE_SETTINGS.admission
|
|
36988
|
+
};
|
|
36989
|
+
/**
|
|
36990
|
+
* Parse one knob, reporting `null` for absent, junk, out-of-bounds — AND for a
|
|
36991
|
+
* value equal to the shipped default.
|
|
36992
|
+
*
|
|
36993
|
+
* That last rule is not tidiness, it is the difference between the documented
|
|
36994
|
+
* precedence being true and being a lie. `addon-settings.getGlobalSettings`
|
|
36995
|
+
* returns a HYDRATED payload, and `hydrateField` fills an unstored field with
|
|
36996
|
+
* the schema's own `default` (verified live on the hub: a cluster that has never
|
|
36997
|
+
* opened the form still reports `nativeLeaseTtlMs = 1200`). A reader that took
|
|
36998
|
+
* that at face value would report all three knobs as "set" on every cluster on
|
|
36999
|
+
* the day this shipped, permanently retiring the `CAMSTACK_SESSION_NATIVE_LEASE_*`
|
|
37000
|
+
* emergency override that the precedence promises. There is no raw-store read on
|
|
37001
|
+
* this cap to distinguish the two, so the default value itself is treated as
|
|
37002
|
+
* "the operator has expressed no preference" — which is also what leaving a
|
|
37003
|
+
* slider untouched means.
|
|
37004
|
+
*
|
|
37005
|
+
* The cost is one honest edge: an operator who deliberately selects the default
|
|
37006
|
+
* value in order to overrule an env var does not get it. Clear the env var
|
|
37007
|
+
* instead; the worker's spawn line names the source, so this is visible rather
|
|
37008
|
+
* than mysterious.
|
|
37009
|
+
*/
|
|
37010
|
+
function readKnob(knob, raw) {
|
|
37011
|
+
const parsed = NativeLeaseSettingsSchema.shape[knob].safeParse(raw);
|
|
37012
|
+
if (!parsed.success) return null;
|
|
37013
|
+
return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS[knob] ? null : parsed.data;
|
|
37014
|
+
}
|
|
37015
|
+
/** {@link readKnob} for the one non-numeric knob. Same default-means-unset rule. */
|
|
37016
|
+
function readAdmissionKnob(raw) {
|
|
37017
|
+
const parsed = NativeLeaseAdmissionSchema.safeParse(raw);
|
|
37018
|
+
if (!parsed.success) return null;
|
|
37019
|
+
return parsed.data === DEFAULT_NATIVE_LEASE_SETTINGS.admission ? null : parsed.data;
|
|
37020
|
+
}
|
|
37021
|
+
/**
|
|
37022
|
+
* Narrow a FLAT settings record to the knobs the operator set.
|
|
37023
|
+
*
|
|
37024
|
+
* Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
|
|
37025
|
+
* budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
|
|
37026
|
+
* clamped, not defaulted) so the caller can still fall through to the env
|
|
37027
|
+
* override — clamping here would turn a typo into a value nobody chose. See
|
|
37028
|
+
* {@link readKnob} for why the default counts as unset.
|
|
37029
|
+
*/
|
|
37030
|
+
function readNativeLeaseOverride(config) {
|
|
37031
|
+
const ttlMs = readKnob("ttlMs", config[NATIVE_LEASE_TTL_KEY]);
|
|
37032
|
+
const budgetMb = readKnob("budgetMb", config[NATIVE_LEASE_BUDGET_KEY]);
|
|
37033
|
+
const activityMs = readKnob("activityMs", config[NATIVE_LEASE_ACTIVITY_KEY]);
|
|
37034
|
+
const admission = readAdmissionKnob(config[NATIVE_LEASE_ADMISSION_KEY]);
|
|
37035
|
+
return {
|
|
37036
|
+
...ttlMs === null ? {} : { ttlMs },
|
|
37037
|
+
...budgetMb === null ? {} : { budgetMb },
|
|
37038
|
+
...activityMs === null ? {} : { activityMs },
|
|
37039
|
+
...admission === null ? {} : { admission }
|
|
37040
|
+
};
|
|
37041
|
+
}
|
|
37042
|
+
function isHydratedField(entry) {
|
|
37043
|
+
return typeof entry === "object" && entry !== null && "key" in entry;
|
|
37044
|
+
}
|
|
37045
|
+
var LEASE_KEYS = [
|
|
37046
|
+
NATIVE_LEASE_TTL_KEY,
|
|
37047
|
+
NATIVE_LEASE_BUDGET_KEY,
|
|
37048
|
+
NATIVE_LEASE_ACTIVITY_KEY,
|
|
37049
|
+
NATIVE_LEASE_ADMISSION_KEY
|
|
37050
|
+
];
|
|
37051
|
+
/**
|
|
37052
|
+
* Extract the operator's lease overrides from an
|
|
37053
|
+
* `addon-settings.getGlobalSettings` payload.
|
|
37054
|
+
*
|
|
37055
|
+
* Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
|
|
37056
|
+
* alone: the keys are unique across the addon's schema, and a section rename
|
|
37057
|
+
* must not silently revert the whole cluster to the defaults. A `null` payload
|
|
37058
|
+
* (addon mid-boot) means "operator set nothing" — the env/default fallback then
|
|
37059
|
+
* applies, which is the correct read of "I could not ask".
|
|
37060
|
+
*/
|
|
37061
|
+
function pickNativeLeaseOverride(view) {
|
|
37062
|
+
if (view === null) return {};
|
|
37063
|
+
const flat = {};
|
|
37064
|
+
for (const section of view.sections) for (const entry of section.fields) {
|
|
37065
|
+
if (!isHydratedField(entry) || typeof entry.key !== "string") continue;
|
|
37066
|
+
if (LEASE_KEYS.includes(entry.key)) flat[entry.key] = entry.value;
|
|
37067
|
+
}
|
|
37068
|
+
return readNativeLeaseOverride(flat);
|
|
37069
|
+
}
|
|
37070
|
+
//#endregion
|
|
36403
37071
|
//#region src/helpers/bind-addon-actions.ts
|
|
36404
37072
|
/**
|
|
36405
37073
|
* Bind an addon's custom-action catalog to its tRPC surface, returning a
|
|
@@ -36833,6 +37501,7 @@ exports.DEFAULT_DECODER_HWACCEL_CONFIG = DEFAULT_DECODER_HWACCEL_CONFIG;
|
|
|
36833
37501
|
exports.DEFAULT_DETAIL_CROP_CONVENTION = DEFAULT_DETAIL_CROP_CONVENTION;
|
|
36834
37502
|
exports.DEFAULT_EVENT_COLOR = DEFAULT_EVENT_COLOR;
|
|
36835
37503
|
exports.DEFAULT_FEATURES = DEFAULT_FEATURES;
|
|
37504
|
+
exports.DEFAULT_NATIVE_LEASE_SETTINGS = DEFAULT_NATIVE_LEASE_SETTINGS;
|
|
36836
37505
|
exports.DEFAULT_RETENTION = DEFAULT_RETENTION;
|
|
36837
37506
|
exports.DEFAULT_SCRUB_THUMBNAIL_PRESET = DEFAULT_SCRUB_THUMBNAIL_PRESET;
|
|
36838
37507
|
exports.DETAIL_CROP_PADDING_FIELD = DETAIL_CROP_PADDING_FIELD;
|
|
@@ -36891,6 +37560,10 @@ exports.EXPRESSION_BUILTIN_NAMES = EXPRESSION_BUILTIN_NAMES;
|
|
|
36891
37560
|
exports.EXPRESSION_COMPILE_CACHE_CAPACITY = EXPRESSION_COMPILE_CACHE_CAPACITY;
|
|
36892
37561
|
exports.EXPRESSION_IDENTIFIER_RE = EXPRESSION_IDENTIFIER_RE;
|
|
36893
37562
|
exports.EXPRESSION_INJECTED_NOW = EXPRESSION_INJECTED_NOW;
|
|
37563
|
+
exports.EgressEncodeSchema = EgressEncodeSchema;
|
|
37564
|
+
exports.EgressRateControlSchema = EgressRateControlSchema;
|
|
37565
|
+
exports.EgressTranscodeRequestSchema = EgressTranscodeRequestSchema;
|
|
37566
|
+
exports.EgressTranscodeSchema = EgressTranscodeSchema;
|
|
36894
37567
|
exports.ElementConfigStore = ElementConfigStore;
|
|
36895
37568
|
exports.EmbeddingInfoSchema = EmbeddingInfoSchema;
|
|
36896
37569
|
exports.EmbeddingResultSchema = EmbeddingResultSchema;
|
|
@@ -37046,6 +37719,15 @@ exports.MotionZoneRegionSchema = MotionZoneRegionSchema;
|
|
|
37046
37719
|
exports.MotionZoneStatusSchema = MotionZoneStatusSchema;
|
|
37047
37720
|
exports.MqttBrokerStatusSchema = StatusSchema;
|
|
37048
37721
|
exports.MutationFilterSchema = MutationFilterSchema;
|
|
37722
|
+
exports.NATIVE_LEASE_ACTIVITY_FIELD = NATIVE_LEASE_ACTIVITY_FIELD;
|
|
37723
|
+
exports.NATIVE_LEASE_ACTIVITY_KEY = NATIVE_LEASE_ACTIVITY_KEY;
|
|
37724
|
+
exports.NATIVE_LEASE_ADMISSION_FIELD = NATIVE_LEASE_ADMISSION_FIELD;
|
|
37725
|
+
exports.NATIVE_LEASE_ADMISSION_KEY = NATIVE_LEASE_ADMISSION_KEY;
|
|
37726
|
+
exports.NATIVE_LEASE_BUDGET_FIELD = NATIVE_LEASE_BUDGET_FIELD;
|
|
37727
|
+
exports.NATIVE_LEASE_BUDGET_KEY = NATIVE_LEASE_BUDGET_KEY;
|
|
37728
|
+
exports.NATIVE_LEASE_SECTION_ID = NATIVE_LEASE_SECTION_ID;
|
|
37729
|
+
exports.NATIVE_LEASE_TTL_FIELD = NATIVE_LEASE_TTL_FIELD;
|
|
37730
|
+
exports.NATIVE_LEASE_TTL_KEY = NATIVE_LEASE_TTL_KEY;
|
|
37049
37731
|
exports.NC_BASE_CONDITION_KEYS = NC_BASE_CONDITION_KEYS;
|
|
37050
37732
|
exports.NC_CONDITION_CATALOG = NC_CONDITION_CATALOG;
|
|
37051
37733
|
exports.NC_HISTORY_LIMIT_DEFAULT = NC_HISTORY_LIMIT_DEFAULT;
|
|
@@ -37057,6 +37739,8 @@ exports.NativeCropBboxSchema = NativeCropBboxSchema;
|
|
|
37057
37739
|
exports.NativeCropRefSchema = NativeCropRefSchema;
|
|
37058
37740
|
exports.NativeCropResultSchema = NativeCropResultSchema;
|
|
37059
37741
|
exports.NativeDetectionSchema = NativeDetectionSchema;
|
|
37742
|
+
exports.NativeLeaseAdmissionSchema = NativeLeaseAdmissionSchema;
|
|
37743
|
+
exports.NativeLeaseSettingsSchema = NativeLeaseSettingsSchema;
|
|
37060
37744
|
exports.NativeObjectClassEnum = NativeObjectClassEnum;
|
|
37061
37745
|
exports.NativeObjectDetectionRuntimeStateSchema = NativeObjectDetectionRuntimeStateSchema;
|
|
37062
37746
|
exports.NativeObjectDetectionStatusSchema = NativeObjectDetectionStatusSchema;
|
|
@@ -37126,6 +37810,7 @@ exports.PET_FEEDER_MANUAL_FEED_MAX = PET_FEEDER_MANUAL_FEED_MAX;
|
|
|
37126
37810
|
exports.PET_FEEDER_MANUAL_FEED_MIN = PET_FEEDER_MANUAL_FEED_MIN;
|
|
37127
37811
|
exports.PIPELINE_FLOW_CAPABILITY_NAMES = PIPELINE_FLOW_CAPABILITY_NAMES;
|
|
37128
37812
|
exports.PIPELINE_OWNER_CAPABILITY_NAMES = PIPELINE_OWNER_CAPABILITY_NAMES;
|
|
37813
|
+
exports.PRIVACY_MASK_CAP_NAME = PRIVACY_MASK_CAP_NAME;
|
|
37129
37814
|
exports.PROVIDER_KIND_CAP_NAMES = PROVIDER_KIND_CAP_NAMES;
|
|
37130
37815
|
exports.PYTHON_SCRIPT = PYTHON_SCRIPT;
|
|
37131
37816
|
exports.PackageUpdateSchema = PackageUpdateSchema;
|
|
@@ -37179,6 +37864,7 @@ exports.RESERVED_BINDING_NAMES = RESERVED_BINDING_NAMES;
|
|
|
37179
37864
|
exports.RUNTIME_DEFAULTS = RUNTIME_DEFAULTS;
|
|
37180
37865
|
exports.RUNTIME_TO_FORMAT = RUNTIME_TO_FORMAT;
|
|
37181
37866
|
exports.RawStateResultSchema = require_sleep.RawStateResultSchema;
|
|
37867
|
+
exports.ReadGopBytesResultSchema = ReadGopBytesResultSchema;
|
|
37182
37868
|
exports.ReadSegmentBytesResultSchema = ReadSegmentBytesResultSchema;
|
|
37183
37869
|
exports.ReadinessRegistry = require_sleep.ReadinessRegistry;
|
|
37184
37870
|
exports.ReadinessTimeoutError = require_sleep.ReadinessTimeoutError;
|
|
@@ -37413,6 +38099,7 @@ exports.authProviderCapability = authProviderCapability;
|
|
|
37413
38099
|
exports.autoAssignProfiles = autoAssignProfiles;
|
|
37414
38100
|
exports.automationControlCapability = automationControlCapability;
|
|
37415
38101
|
exports.backupCapability = backupCapability;
|
|
38102
|
+
exports.bareAddonId = bareAddonId;
|
|
37416
38103
|
exports.batteryCapability = batteryCapability;
|
|
37417
38104
|
exports.bestLocationMatch = bestLocationMatch;
|
|
37418
38105
|
exports.binaryCapability = binaryCapability;
|
|
@@ -37489,6 +38176,7 @@ exports.deviceStateCapability = deviceStateCapability;
|
|
|
37489
38176
|
exports.deviceStatusCapability = deviceStatusCapability;
|
|
37490
38177
|
exports.doorbellCapability = doorbellCapability;
|
|
37491
38178
|
exports.egressTranscodeSharingKey = egressTranscodeSharingKey;
|
|
38179
|
+
exports.egressTransportFromRequest = egressTransportFromRequest;
|
|
37492
38180
|
exports.embeddingEncoderCapability = embeddingEncoderCapability;
|
|
37493
38181
|
exports.emitDownForOwnedCaps = require_sleep.emitDownForOwnedCaps;
|
|
37494
38182
|
exports.emitReadiness = require_sleep.emitReadiness;
|
|
@@ -37542,6 +38230,7 @@ exports.isDeviceScopedCap = require_sleep.isDeviceScopedCap;
|
|
|
37542
38230
|
exports.isEvent = require_sleep.isEvent;
|
|
37543
38231
|
exports.isNode = isNode;
|
|
37544
38232
|
exports.isObjectInput = isObjectInput;
|
|
38233
|
+
exports.isSameAddonId = isSameAddonId;
|
|
37545
38234
|
exports.isSoftwareDecode = isSoftwareDecode;
|
|
37546
38235
|
exports.isVoidInput = isVoidInput;
|
|
37547
38236
|
exports.jobKindSchema = jobKindSchema;
|
|
@@ -37605,6 +38294,7 @@ exports.parseStreamParamsFormPatch = parseStreamParamsFormPatch;
|
|
|
37605
38294
|
exports.petFeederCapability = petFeederCapability;
|
|
37606
38295
|
exports.pickAccessoryControl = pickAccessoryControl;
|
|
37607
38296
|
exports.pickDetailCropConvention = pickDetailCropConvention;
|
|
38297
|
+
exports.pickNativeLeaseOverride = pickNativeLeaseOverride;
|
|
37608
38298
|
exports.pickPreferredRtspEntry = pickPreferredRtspEntry;
|
|
37609
38299
|
exports.pickVideoEncoder = pickVideoEncoder;
|
|
37610
38300
|
exports.pickerForCondition = pickerForCondition;
|
|
@@ -37625,6 +38315,7 @@ exports.ptzCapability = ptzCapability;
|
|
|
37625
38315
|
exports.pythonScriptForBackend = pythonScriptForBackend;
|
|
37626
38316
|
exports.readDetailCropConvention = readDetailCropConvention;
|
|
37627
38317
|
exports.readDeviceStateFrom = readDeviceStateFrom;
|
|
38318
|
+
exports.readNativeLeaseOverride = readNativeLeaseOverride;
|
|
37628
38319
|
exports.readNodePin = require_sleep.readNodePin;
|
|
37629
38320
|
exports.readinessKey = require_sleep.readinessKey;
|
|
37630
38321
|
exports.rebootCapability = rebootCapability;
|
|
@@ -37675,6 +38366,7 @@ exports.streamParamsCapability = streamParamsCapability;
|
|
|
37675
38366
|
exports.streamPixels = streamPixels;
|
|
37676
38367
|
exports.streamQualityLabel = streamQualityLabel;
|
|
37677
38368
|
exports.subKindsOf = subKindsOf;
|
|
38369
|
+
exports.summarisePrivacyAudio = summarisePrivacyAudio;
|
|
37678
38370
|
exports.supportedRuntimes = supportedRuntimes;
|
|
37679
38371
|
exports.switchCapability = switchCapability;
|
|
37680
38372
|
exports.switchedOffIds = switchedOffIds;
|
|
@@ -37689,6 +38381,7 @@ exports.terminalSessionCapability = terminalSessionCapability;
|
|
|
37689
38381
|
exports.textToHtml = textToHtml;
|
|
37690
38382
|
exports.toDeviceSummary = toDeviceSummary;
|
|
37691
38383
|
exports.toExpressionValue = toExpressionValue;
|
|
38384
|
+
exports.toNodeId = require_sleep.toNodeId;
|
|
37692
38385
|
exports.toStreamSourceEntry = toStreamSourceEntry;
|
|
37693
38386
|
exports.toastCapability = toastCapability;
|
|
37694
38387
|
exports.tokenize = tokenize;
|