@camstack/addon-terminal 0.1.15 → 0.1.16
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 +228 -36
- package/dist/addon.mjs +228 -36
- package/package.json +1 -1
package/dist/addon.js
CHANGED
|
@@ -7047,6 +7047,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7047
7047
|
action: string().min(1),
|
|
7048
7048
|
input: unknown()
|
|
7049
7049
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7050
|
+
//#endregion
|
|
7051
|
+
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7052
|
+
/**
|
|
7053
|
+
import { errMsg } from '@camstack/types'
|
|
7054
|
+
* Extract a human-readable message from an unknown error value.
|
|
7055
|
+
* Replaces the ubiquitous `errMsg(err)` pattern.
|
|
7056
|
+
*/
|
|
7057
|
+
function errMsg(err) {
|
|
7058
|
+
if (err instanceof Error) return err.message;
|
|
7059
|
+
if (typeof err === "string") return err;
|
|
7060
|
+
return String(err);
|
|
7061
|
+
}
|
|
7050
7062
|
var EncodeProfileSchema = object({
|
|
7051
7063
|
video: object({
|
|
7052
7064
|
codec: _enum([
|
|
@@ -7284,8 +7296,31 @@ var AdoptionJobSchema = object({
|
|
|
7284
7296
|
error: string().nullable()
|
|
7285
7297
|
});
|
|
7286
7298
|
/**
|
|
7287
|
-
* Per-camera FUNCTION SWITCHES
|
|
7288
|
-
*
|
|
7299
|
+
* Per-camera FUNCTION SWITCHES.
|
|
7300
|
+
*
|
|
7301
|
+
* ## The aggregate group is being withdrawn — the BADGE is not (D113)
|
|
7302
|
+
*
|
|
7303
|
+
* This file shipped as "the one coherent on/off surface over the pipeline
|
|
7304
|
+
* functions an operator thinks in terms of". The operator's verdict on
|
|
7305
|
+
* 2026-08-12 was that the coherent surface bought complexity and no clarity:
|
|
7306
|
+
* every function already had a settings page of its own, and a second place to
|
|
7307
|
+
* turn it off is a second place to look. Each switch is going back to its own
|
|
7308
|
+
* component's original options — detection to the detection-pipeline wrapper
|
|
7309
|
+
* binding, audio analysis to its own, recording to `RecordingConfig.enabled`
|
|
7310
|
+
* (which was always first-class; the switch was a veneer over
|
|
7311
|
+
* `recording.setDeviceConfig`), notifications to a notification-center
|
|
7312
|
+
* per-device setting, the two camera planes to their own components.
|
|
7313
|
+
*
|
|
7314
|
+
* What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
|
|
7315
|
+
* thing that lets a status surface say DISABLED instead of BROKEN, recomposed
|
|
7316
|
+
* straight from the authorities with no group in the middle. That rule was
|
|
7317
|
+
* never about a control panel.
|
|
7318
|
+
*
|
|
7319
|
+
* Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
|
|
7320
|
+
* {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
|
|
7321
|
+
* `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
|
|
7322
|
+
* viewers (v1.0.305) and the admin UI still call it. It is deleted when they
|
|
7323
|
+
* stop; nothing new may be built on it.
|
|
7289
7324
|
*
|
|
7290
7325
|
* ## This file adds no state
|
|
7291
7326
|
*
|
|
@@ -7447,6 +7482,14 @@ var CameraSwitchGroupSchema = object({
|
|
|
7447
7482
|
fetchedAt: number()
|
|
7448
7483
|
});
|
|
7449
7484
|
/**
|
|
7485
|
+
* The wrapper capability each wrapper-backed switch controls. Named constants
|
|
7486
|
+
* because the same strings appear in `legacy-migrations.ts`, in
|
|
7487
|
+
* `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
|
|
7488
|
+
* them is a switch that silently writes a binding nobody reads.
|
|
7489
|
+
*/
|
|
7490
|
+
var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
|
|
7491
|
+
var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
|
|
7492
|
+
/**
|
|
7450
7493
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7451
7494
|
* recordings and events management surfaces.
|
|
7452
7495
|
*
|
|
@@ -17822,9 +17865,16 @@ var CameraStatusSchema = object({
|
|
|
17822
17865
|
audio: CameraAudioStatusSchema.nullable(),
|
|
17823
17866
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
17824
17867
|
/**
|
|
17825
|
-
* Per-camera
|
|
17868
|
+
* Per-camera functions an OPERATOR has turned off
|
|
17826
17869
|
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
17827
17870
|
*
|
|
17871
|
+
* Composed from the AUTHORITIES themselves — the wrapper bindings,
|
|
17872
|
+
* `RecordingConfig.enabled`, the notification mute, the broker's audio
|
|
17873
|
+
* policy, the camera's own microphone — via `composeSwitchedOff`, not from
|
|
17874
|
+
* the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
|
|
17875
|
+
* The badge outlives the control panel: the panel was a convenience, this is
|
|
17876
|
+
* the difference between a camera being off and a camera being dead.
|
|
17877
|
+
*
|
|
17828
17878
|
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
17829
17879
|
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
17830
17880
|
* `'object-detection'` was switched off by a person; the same camera with an
|
|
@@ -24911,10 +24961,42 @@ method(object({
|
|
|
24911
24961
|
*/
|
|
24912
24962
|
/** Playback-speed multiplier for the render (1 = realtime). */
|
|
24913
24963
|
var ExportSpeedSchema = number().min(.25).max(32);
|
|
24964
|
+
/**
|
|
24965
|
+
* One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
|
|
24966
|
+
*
|
|
24967
|
+
* Relative and not absolute epoch on purpose: the renderer's frame-select
|
|
24968
|
+
* expression sees ffmpeg's `t`, which starts at 0 for the export's source
|
|
24969
|
+
* playlist. Handing it absolute epochs would make every call site responsible
|
|
24970
|
+
* for the same subtraction, and the one that forgot would emit a filter that
|
|
24971
|
+
* selects nothing — silently, as a uniform timelapse.
|
|
24972
|
+
*/
|
|
24973
|
+
var ExportDenseRangeSchema = object({
|
|
24974
|
+
fromSec: number().nonnegative(),
|
|
24975
|
+
toSec: number().nonnegative()
|
|
24976
|
+
}).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
|
|
24977
|
+
/**
|
|
24978
|
+
* Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
|
|
24979
|
+
* listed ranges and at the base `everyMs` everywhere else.
|
|
24980
|
+
*
|
|
24981
|
+
* `everyMs` must be strictly smaller than the base cadence — a dense rate that
|
|
24982
|
+
* is not denser renders a uniform timelapse the operator believes is two-rate.
|
|
24983
|
+
*/
|
|
24984
|
+
var ExportDenseSchema = object({
|
|
24985
|
+
everyMs: number().int().positive(),
|
|
24986
|
+
ranges: array(ExportDenseRangeSchema).min(1).max(200)
|
|
24987
|
+
});
|
|
24914
24988
|
/** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
|
|
24915
24989
|
var ExportTimelapseSchema = object({
|
|
24916
24990
|
everyMs: number().int().positive(),
|
|
24917
|
-
outputFps: number().int().min(1).max(60).optional()
|
|
24991
|
+
outputFps: number().int().min(1).max(60).optional(),
|
|
24992
|
+
/** Optional second, FASTER rate over the intervals that matter. */
|
|
24993
|
+
dense: ExportDenseSchema.optional()
|
|
24994
|
+
}).superRefine((v, ctx) => {
|
|
24995
|
+
if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
|
|
24996
|
+
code: ZodIssueCode.custom,
|
|
24997
|
+
message: "dense.everyMs must be strictly smaller than the base everyMs",
|
|
24998
|
+
path: ["dense", "everyMs"]
|
|
24999
|
+
});
|
|
24918
25000
|
});
|
|
24919
25001
|
/**
|
|
24920
25002
|
* Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
|
|
@@ -24972,6 +25054,19 @@ var ExportDownloadSchema = object({
|
|
|
24972
25054
|
url: string(),
|
|
24973
25055
|
endpoints: array(string())
|
|
24974
25056
|
});
|
|
25057
|
+
/**
|
|
25058
|
+
* A finished export's bytes, inline.
|
|
25059
|
+
*
|
|
25060
|
+
* `bytes` is the DECODED length — the number the caller bounds and logs
|
|
25061
|
+
* against, so nobody has to infer it from the base64 length.
|
|
25062
|
+
*/
|
|
25063
|
+
var ExportBytesSchema = object({
|
|
25064
|
+
base64: string(),
|
|
25065
|
+
contentType: string(),
|
|
25066
|
+
/** Suggested filename, extension included. */
|
|
25067
|
+
name: string(),
|
|
25068
|
+
bytes: number().int().nonnegative()
|
|
25069
|
+
});
|
|
24975
25070
|
method(object({
|
|
24976
25071
|
deviceId: number(),
|
|
24977
25072
|
profile: string(),
|
|
@@ -24996,6 +25091,9 @@ method(object({
|
|
|
24996
25091
|
}), method(object({ exportId: string() }), ExportDownloadSchema, {
|
|
24997
25092
|
kind: "query",
|
|
24998
25093
|
auth: "protected"
|
|
25094
|
+
}), method(object({ exportId: string() }), ExportBytesSchema, {
|
|
25095
|
+
kind: "query",
|
|
25096
|
+
auth: "protected"
|
|
24999
25097
|
});
|
|
25000
25098
|
/**
|
|
25001
25099
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
@@ -31847,6 +31945,12 @@ Object.freeze({
|
|
|
31847
31945
|
addonId: null,
|
|
31848
31946
|
access: "view"
|
|
31849
31947
|
},
|
|
31948
|
+
"recordingExport.readExportBytes": {
|
|
31949
|
+
capName: "recordingExport",
|
|
31950
|
+
capScope: "system",
|
|
31951
|
+
addonId: null,
|
|
31952
|
+
access: "view"
|
|
31953
|
+
},
|
|
31850
31954
|
"sceneMonitor.captureReference": {
|
|
31851
31955
|
capName: "scene-monitor",
|
|
31852
31956
|
capScope: "device",
|
|
@@ -33264,10 +33368,28 @@ TimelapseRuleInputSchema.extend({
|
|
|
33264
33368
|
*/
|
|
33265
33369
|
ownerUserId: string().optional(),
|
|
33266
33370
|
/**
|
|
33267
|
-
* Epoch-ms of the
|
|
33268
|
-
*
|
|
33371
|
+
* Epoch-ms of the NEWEST successful generation across every camera of this
|
|
33372
|
+
* rule. What a UI shows, and the compatibility floor for
|
|
33373
|
+
* {@link readTimelapseGeneratedAt}. Absent = never generated.
|
|
33269
33374
|
*/
|
|
33270
33375
|
lastGeneratedAt: number().optional(),
|
|
33376
|
+
/**
|
|
33377
|
+
* PER-CAMERA generation state, keyed by `String(deviceId)` — the
|
|
33378
|
+
* re-generation guard's real durable state.
|
|
33379
|
+
*
|
|
33380
|
+
* One rule covers several cameras and each renders its own video, so a rule
|
|
33381
|
+
* -wide stamp is wrong in the direction that DESTROYS work: camera A
|
|
33382
|
+
* succeeding at 06:05 tells camera B, whose render failed, that it is
|
|
33383
|
+
* already done — and B's night is gone for good, because the window will not
|
|
33384
|
+
* come back.
|
|
33385
|
+
*
|
|
33386
|
+
* ADDITIVE, so the migration is free: a row written before this field simply
|
|
33387
|
+
* has no map, and {@link readTimelapseGeneratedAt} falls back to
|
|
33388
|
+
* {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
|
|
33389
|
+
* "never generated" would re-render and re-notify every camera of every rule
|
|
33390
|
+
* once, on the deploy that shipped the map.
|
|
33391
|
+
*/
|
|
33392
|
+
generatedByDevice: record(string(), number()).optional(),
|
|
33271
33393
|
/** userId of the caller who created the rule (server-stamped). */
|
|
33272
33394
|
createdBy: string(),
|
|
33273
33395
|
createdAt: number(),
|
|
@@ -33516,6 +33638,91 @@ async function warmNodePty() {
|
|
|
33516
33638
|
await loadNodePty();
|
|
33517
33639
|
}
|
|
33518
33640
|
//#endregion
|
|
33641
|
+
//#region src/silence-analysis.ts
|
|
33642
|
+
/**
|
|
33643
|
+
* Every analyzer a Terminal camera is created with switched OFF.
|
|
33644
|
+
*
|
|
33645
|
+
* `motion-detection` sits first deliberately: it is the one that holds the
|
|
33646
|
+
* decode session open, so it is the one whose absence is the difference between
|
|
33647
|
+
* a terminal costing a screen scrape and costing a decode pipeline.
|
|
33648
|
+
*/
|
|
33649
|
+
var TERMINAL_SILENCED_CAP_NAMES = [
|
|
33650
|
+
"motion-detection",
|
|
33651
|
+
DETECTION_PIPELINE_CAP_NAME,
|
|
33652
|
+
AUDIO_ANALYSIS_CAP_NAME
|
|
33653
|
+
];
|
|
33654
|
+
/**
|
|
33655
|
+
* Resolve the addon currently providing `capName` for this device.
|
|
33656
|
+
*
|
|
33657
|
+
* Inlined rather than reached for through a helper because there is exactly one
|
|
33658
|
+
* honest source: the device's own bindings. `listBindableCapsForDeviceType`
|
|
33659
|
+
* answers for the device TYPE and would happily name a wrapper that is not the
|
|
33660
|
+
* one bound here.
|
|
33661
|
+
*
|
|
33662
|
+
* Returns `null` when the cap is not bound at all — which is not an error: a
|
|
33663
|
+
* deployment with no audio analyzer has nothing to switch off, and demanding one
|
|
33664
|
+
* would make every terminal creation fail on a perfectly valid hub.
|
|
33665
|
+
*/
|
|
33666
|
+
async function resolveBoundWrapper(api, deviceId, capName) {
|
|
33667
|
+
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
33668
|
+
for (const entry of bindings.entries) {
|
|
33669
|
+
if (entry.capName !== capName) continue;
|
|
33670
|
+
if (entry.kind !== "wrapped") continue;
|
|
33671
|
+
if (entry.providerAddonId === "") return null;
|
|
33672
|
+
return entry.providerAddonId;
|
|
33673
|
+
}
|
|
33674
|
+
return null;
|
|
33675
|
+
}
|
|
33676
|
+
/**
|
|
33677
|
+
* Switch every analyzer in {@link TERMINAL_SILENCED_CAP_NAMES} off for one
|
|
33678
|
+
* Terminal camera.
|
|
33679
|
+
*
|
|
33680
|
+
* Called ONLY on creation. An operator who deliberately turns detection back on
|
|
33681
|
+
* for a terminal must win, and a reconcile that re-asserted every pass would
|
|
33682
|
+
* silently overrule them once a minute.
|
|
33683
|
+
*
|
|
33684
|
+
* @throws if a cap IS bound and the write to its authority failed. The camera
|
|
33685
|
+
* would then be running a full analyzer for a screen recording, which is the
|
|
33686
|
+
* exact cost this exists to remove — and a silent version of that failure is
|
|
33687
|
+
* unfindable.
|
|
33688
|
+
*/
|
|
33689
|
+
async function silenceAnalysisFor(deps, deviceId) {
|
|
33690
|
+
const failures = [];
|
|
33691
|
+
for (const capName of TERMINAL_SILENCED_CAP_NAMES) try {
|
|
33692
|
+
const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
|
|
33693
|
+
if (wrapperAddonId === null) {
|
|
33694
|
+
deps.logger.debug("terminal camera: no analyzer bound for this capability", {
|
|
33695
|
+
tags: { deviceId },
|
|
33696
|
+
meta: { capName }
|
|
33697
|
+
});
|
|
33698
|
+
continue;
|
|
33699
|
+
}
|
|
33700
|
+
await deps.api.deviceManager.setWrapperActive.mutate({
|
|
33701
|
+
deviceId,
|
|
33702
|
+
capName,
|
|
33703
|
+
wrapperAddonId,
|
|
33704
|
+
active: false
|
|
33705
|
+
});
|
|
33706
|
+
deps.logger.info("terminal camera: analyzer switched off at its authority", {
|
|
33707
|
+
tags: { deviceId },
|
|
33708
|
+
meta: {
|
|
33709
|
+
capName,
|
|
33710
|
+
wrapperAddonId
|
|
33711
|
+
}
|
|
33712
|
+
});
|
|
33713
|
+
} catch (err) {
|
|
33714
|
+
failures.push(`${capName}: ${errMsg(err)}`);
|
|
33715
|
+
deps.logger.error("terminal camera: could NOT switch an analyzer off", {
|
|
33716
|
+
tags: { deviceId },
|
|
33717
|
+
meta: {
|
|
33718
|
+
capName,
|
|
33719
|
+
error: errMsg(err)
|
|
33720
|
+
}
|
|
33721
|
+
});
|
|
33722
|
+
}
|
|
33723
|
+
if (failures.length > 0) throw new Error(`terminal camera ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
|
|
33724
|
+
}
|
|
33725
|
+
//#endregion
|
|
33519
33726
|
//#region src/terminal-camera-declarations.ts
|
|
33520
33727
|
/**
|
|
33521
33728
|
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
@@ -40049,37 +40256,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
40049
40256
|
}
|
|
40050
40257
|
}
|
|
40051
40258
|
/**
|
|
40052
|
-
* A Terminal camera is a rendered screen.
|
|
40053
|
-
*
|
|
40054
|
-
*
|
|
40055
|
-
*
|
|
40056
|
-
* through the detection pipeline at ~61 ms of inference each, plus 115
|
|
40057
|
-
* capture-scheduler requests a minute — against `detections=0`. Multiply by
|
|
40058
|
-
* one terminal per node and it is a standing tax on a hub that was already
|
|
40059
|
-
* shedding 86 % of its capture queue.
|
|
40259
|
+
* A Terminal camera is a rendered screen. Every analyzer on it finds nothing,
|
|
40260
|
+
* forever, at full cost — see `silence-analysis.ts` for the measurements, the
|
|
40261
|
+
* reason `motion-detection` is in the list, and why a failure THROWS instead
|
|
40262
|
+
* of warning.
|
|
40060
40263
|
*
|
|
40061
|
-
* Written
|
|
40062
|
-
*
|
|
40063
|
-
*
|
|
40064
|
-
*
|
|
40065
|
-
*
|
|
40066
|
-
*/
|
|
40067
|
-
|
|
40068
|
-
|
|
40069
|
-
|
|
40070
|
-
|
|
40071
|
-
|
|
40072
|
-
enabled: false
|
|
40073
|
-
});
|
|
40074
|
-
} catch (err) {
|
|
40075
|
-
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
40076
|
-
tags: { deviceId },
|
|
40077
|
-
meta: {
|
|
40078
|
-
switchId,
|
|
40079
|
-
error: err instanceof Error ? err.message : String(err)
|
|
40080
|
-
}
|
|
40081
|
-
});
|
|
40082
|
-
}
|
|
40264
|
+
* Written to the AUTHORITIES (`deviceManager.setWrapperActive`), not through
|
|
40265
|
+
* the deprecated switch cap the previous version used (D113). Written ONLY on
|
|
40266
|
+
* creation: an operator who deliberately turns detection back on for a
|
|
40267
|
+
* terminal must win, and a reconcile that re-asserted every pass would
|
|
40268
|
+
* silently overrule them once a minute.
|
|
40269
|
+
*/
|
|
40270
|
+
silenceAnalysisFor(deviceId) {
|
|
40271
|
+
return silenceAnalysisFor({
|
|
40272
|
+
api: this.ctx.api,
|
|
40273
|
+
logger: this.ctx.logger
|
|
40274
|
+
}, deviceId);
|
|
40083
40275
|
}
|
|
40084
40276
|
terminalInstanceControl() {
|
|
40085
40277
|
return {
|
package/dist/addon.mjs
CHANGED
|
@@ -7024,6 +7024,18 @@ method(object({ deviceId: number() }), array(StreamSourceEntrySchema)), method(o
|
|
|
7024
7024
|
action: string().min(1),
|
|
7025
7025
|
input: unknown()
|
|
7026
7026
|
}), unknown(), { kind: "mutation" }), method(object({ deviceId: number() }), _void(), { kind: "mutation" }), method(object({ deviceId: number() }), unknown().nullable()), method(object({ deviceId: number() }), RawStateResultSchema.nullable(), { auth: "protected" });
|
|
7027
|
+
//#endregion
|
|
7028
|
+
//#region ../types/dist/err-msg-IQTHeDzc.mjs
|
|
7029
|
+
/**
|
|
7030
|
+
import { errMsg } from '@camstack/types'
|
|
7031
|
+
* Extract a human-readable message from an unknown error value.
|
|
7032
|
+
* Replaces the ubiquitous `errMsg(err)` pattern.
|
|
7033
|
+
*/
|
|
7034
|
+
function errMsg(err) {
|
|
7035
|
+
if (err instanceof Error) return err.message;
|
|
7036
|
+
if (typeof err === "string") return err;
|
|
7037
|
+
return String(err);
|
|
7038
|
+
}
|
|
7027
7039
|
var EncodeProfileSchema = object({
|
|
7028
7040
|
video: object({
|
|
7029
7041
|
codec: _enum([
|
|
@@ -7261,8 +7273,31 @@ var AdoptionJobSchema = object({
|
|
|
7261
7273
|
error: string().nullable()
|
|
7262
7274
|
});
|
|
7263
7275
|
/**
|
|
7264
|
-
* Per-camera FUNCTION SWITCHES
|
|
7265
|
-
*
|
|
7276
|
+
* Per-camera FUNCTION SWITCHES.
|
|
7277
|
+
*
|
|
7278
|
+
* ## The aggregate group is being withdrawn — the BADGE is not (D113)
|
|
7279
|
+
*
|
|
7280
|
+
* This file shipped as "the one coherent on/off surface over the pipeline
|
|
7281
|
+
* functions an operator thinks in terms of". The operator's verdict on
|
|
7282
|
+
* 2026-08-12 was that the coherent surface bought complexity and no clarity:
|
|
7283
|
+
* every function already had a settings page of its own, and a second place to
|
|
7284
|
+
* turn it off is a second place to look. Each switch is going back to its own
|
|
7285
|
+
* component's original options — detection to the detection-pipeline wrapper
|
|
7286
|
+
* binding, audio analysis to its own, recording to `RecordingConfig.enabled`
|
|
7287
|
+
* (which was always first-class; the switch was a veneer over
|
|
7288
|
+
* `recording.setDeviceConfig`), notifications to a notification-center
|
|
7289
|
+
* per-device setting, the two camera planes to their own components.
|
|
7290
|
+
*
|
|
7291
|
+
* What survives is {@link composeSwitchedOff}: `CameraStatus.switchedOff`, the
|
|
7292
|
+
* thing that lets a status surface say DISABLED instead of BROKEN, recomposed
|
|
7293
|
+
* straight from the authorities with no group in the middle. That rule was
|
|
7294
|
+
* never about a control panel.
|
|
7295
|
+
*
|
|
7296
|
+
* Everything else here — {@link CAMERA_SWITCH_CATALOG}, {@link CameraSwitch},
|
|
7297
|
+
* {@link deriveCameraSwitches}, the `pipelineOrchestrator.getCameraSwitches` /
|
|
7298
|
+
* `setCameraSwitch` pair — is a COMPATIBILITY surface for as long as deployed
|
|
7299
|
+
* viewers (v1.0.305) and the admin UI still call it. It is deleted when they
|
|
7300
|
+
* stop; nothing new may be built on it.
|
|
7266
7301
|
*
|
|
7267
7302
|
* ## This file adds no state
|
|
7268
7303
|
*
|
|
@@ -7424,6 +7459,14 @@ var CameraSwitchGroupSchema = object({
|
|
|
7424
7459
|
fetchedAt: number()
|
|
7425
7460
|
});
|
|
7426
7461
|
/**
|
|
7462
|
+
* The wrapper capability each wrapper-backed switch controls. Named constants
|
|
7463
|
+
* because the same strings appear in `legacy-migrations.ts`, in
|
|
7464
|
+
* `isCapActiveForDevice` call sites and in the fake harness — a typo in any of
|
|
7465
|
+
* them is a switch that silently writes a binding nobody reads.
|
|
7466
|
+
*/
|
|
7467
|
+
var DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
|
|
7468
|
+
var AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
|
|
7469
|
+
/**
|
|
7427
7470
|
* Ops-log — the durable, append-only operations audit shared by the
|
|
7428
7471
|
* recordings and events management surfaces.
|
|
7429
7472
|
*
|
|
@@ -17799,9 +17842,16 @@ var CameraStatusSchema = object({
|
|
|
17799
17842
|
audio: CameraAudioStatusSchema.nullable(),
|
|
17800
17843
|
recording: CameraRecordingStatusSchema.nullable(),
|
|
17801
17844
|
/**
|
|
17802
|
-
* Per-camera
|
|
17845
|
+
* Per-camera functions an OPERATOR has turned off
|
|
17803
17846
|
* ([D61](../../../../docs/decisions/adr-0067.md)).
|
|
17804
17847
|
*
|
|
17848
|
+
* Composed from the AUTHORITIES themselves — the wrapper bindings,
|
|
17849
|
+
* `RecordingConfig.enabled`, the notification mute, the broker's audio
|
|
17850
|
+
* policy, the camera's own microphone — via `composeSwitchedOff`, not from
|
|
17851
|
+
* the deprecated `getCameraSwitches` group ([D113](../../../../docs/decisions/adr-0113.md)).
|
|
17852
|
+
* The badge outlives the control panel: the panel was a convenience, this is
|
|
17853
|
+
* the difference between a camera being off and a camera being dead.
|
|
17854
|
+
*
|
|
17805
17855
|
* This is the difference between DISABLED and BROKEN. A camera whose
|
|
17806
17856
|
* `detection` block reports zero fps and whose `switchedOff` contains
|
|
17807
17857
|
* `'object-detection'` was switched off by a person; the same camera with an
|
|
@@ -24888,10 +24938,42 @@ method(object({
|
|
|
24888
24938
|
*/
|
|
24889
24939
|
/** Playback-speed multiplier for the render (1 = realtime). */
|
|
24890
24940
|
var ExportSpeedSchema = number().min(.25).max(32);
|
|
24941
|
+
/**
|
|
24942
|
+
* One dense interval, in SECONDS FROM THE EXPORT'S OWN `fromMs`.
|
|
24943
|
+
*
|
|
24944
|
+
* Relative and not absolute epoch on purpose: the renderer's frame-select
|
|
24945
|
+
* expression sees ffmpeg's `t`, which starts at 0 for the export's source
|
|
24946
|
+
* playlist. Handing it absolute epochs would make every call site responsible
|
|
24947
|
+
* for the same subtraction, and the one that forgot would emit a filter that
|
|
24948
|
+
* selects nothing — silently, as a uniform timelapse.
|
|
24949
|
+
*/
|
|
24950
|
+
var ExportDenseRangeSchema = object({
|
|
24951
|
+
fromSec: number().nonnegative(),
|
|
24952
|
+
toSec: number().nonnegative()
|
|
24953
|
+
}).refine((r) => r.toSec > r.fromSec, { message: "dense range must have toSec > fromSec" });
|
|
24954
|
+
/**
|
|
24955
|
+
* Dense-interval overlay for a timelapse: sample at `dense.everyMs` INSIDE the
|
|
24956
|
+
* listed ranges and at the base `everyMs` everywhere else.
|
|
24957
|
+
*
|
|
24958
|
+
* `everyMs` must be strictly smaller than the base cadence — a dense rate that
|
|
24959
|
+
* is not denser renders a uniform timelapse the operator believes is two-rate.
|
|
24960
|
+
*/
|
|
24961
|
+
var ExportDenseSchema = object({
|
|
24962
|
+
everyMs: number().int().positive(),
|
|
24963
|
+
ranges: array(ExportDenseRangeSchema).min(1).max(200)
|
|
24964
|
+
});
|
|
24891
24965
|
/** Timelapse cadence — sample one source frame per `everyMs`, output at `outputFps`. */
|
|
24892
24966
|
var ExportTimelapseSchema = object({
|
|
24893
24967
|
everyMs: number().int().positive(),
|
|
24894
|
-
outputFps: number().int().min(1).max(60).optional()
|
|
24968
|
+
outputFps: number().int().min(1).max(60).optional(),
|
|
24969
|
+
/** Optional second, FASTER rate over the intervals that matter. */
|
|
24970
|
+
dense: ExportDenseSchema.optional()
|
|
24971
|
+
}).superRefine((v, ctx) => {
|
|
24972
|
+
if (v.dense !== void 0 && v.dense.everyMs >= v.everyMs) ctx.addIssue({
|
|
24973
|
+
code: ZodIssueCode.custom,
|
|
24974
|
+
message: "dense.everyMs must be strictly smaller than the base everyMs",
|
|
24975
|
+
path: ["dense", "everyMs"]
|
|
24976
|
+
});
|
|
24895
24977
|
});
|
|
24896
24978
|
/**
|
|
24897
24979
|
* Render options. `speed` and `timelapse` are mutually exclusive. `includeAudio`
|
|
@@ -24949,6 +25031,19 @@ var ExportDownloadSchema = object({
|
|
|
24949
25031
|
url: string(),
|
|
24950
25032
|
endpoints: array(string())
|
|
24951
25033
|
});
|
|
25034
|
+
/**
|
|
25035
|
+
* A finished export's bytes, inline.
|
|
25036
|
+
*
|
|
25037
|
+
* `bytes` is the DECODED length — the number the caller bounds and logs
|
|
25038
|
+
* against, so nobody has to infer it from the base64 length.
|
|
25039
|
+
*/
|
|
25040
|
+
var ExportBytesSchema = object({
|
|
25041
|
+
base64: string(),
|
|
25042
|
+
contentType: string(),
|
|
25043
|
+
/** Suggested filename, extension included. */
|
|
25044
|
+
name: string(),
|
|
25045
|
+
bytes: number().int().nonnegative()
|
|
25046
|
+
});
|
|
24952
25047
|
method(object({
|
|
24953
25048
|
deviceId: number(),
|
|
24954
25049
|
profile: string(),
|
|
@@ -24973,6 +25068,9 @@ method(object({
|
|
|
24973
25068
|
}), method(object({ exportId: string() }), ExportDownloadSchema, {
|
|
24974
25069
|
kind: "query",
|
|
24975
25070
|
auth: "protected"
|
|
25071
|
+
}), method(object({ exportId: string() }), ExportBytesSchema, {
|
|
25072
|
+
kind: "query",
|
|
25073
|
+
auth: "protected"
|
|
24976
25074
|
});
|
|
24977
25075
|
/**
|
|
24978
25076
|
* scene-monitor — device-scoped reference-region state cap. An operator marks
|
|
@@ -31824,6 +31922,12 @@ Object.freeze({
|
|
|
31824
31922
|
addonId: null,
|
|
31825
31923
|
access: "view"
|
|
31826
31924
|
},
|
|
31925
|
+
"recordingExport.readExportBytes": {
|
|
31926
|
+
capName: "recordingExport",
|
|
31927
|
+
capScope: "system",
|
|
31928
|
+
addonId: null,
|
|
31929
|
+
access: "view"
|
|
31930
|
+
},
|
|
31827
31931
|
"sceneMonitor.captureReference": {
|
|
31828
31932
|
capName: "scene-monitor",
|
|
31829
31933
|
capScope: "device",
|
|
@@ -33241,10 +33345,28 @@ TimelapseRuleInputSchema.extend({
|
|
|
33241
33345
|
*/
|
|
33242
33346
|
ownerUserId: string().optional(),
|
|
33243
33347
|
/**
|
|
33244
|
-
* Epoch-ms of the
|
|
33245
|
-
*
|
|
33348
|
+
* Epoch-ms of the NEWEST successful generation across every camera of this
|
|
33349
|
+
* rule. What a UI shows, and the compatibility floor for
|
|
33350
|
+
* {@link readTimelapseGeneratedAt}. Absent = never generated.
|
|
33246
33351
|
*/
|
|
33247
33352
|
lastGeneratedAt: number().optional(),
|
|
33353
|
+
/**
|
|
33354
|
+
* PER-CAMERA generation state, keyed by `String(deviceId)` — the
|
|
33355
|
+
* re-generation guard's real durable state.
|
|
33356
|
+
*
|
|
33357
|
+
* One rule covers several cameras and each renders its own video, so a rule
|
|
33358
|
+
* -wide stamp is wrong in the direction that DESTROYS work: camera A
|
|
33359
|
+
* succeeding at 06:05 tells camera B, whose render failed, that it is
|
|
33360
|
+
* already done — and B's night is gone for good, because the window will not
|
|
33361
|
+
* come back.
|
|
33362
|
+
*
|
|
33363
|
+
* ADDITIVE, so the migration is free: a row written before this field simply
|
|
33364
|
+
* has no map, and {@link readTimelapseGeneratedAt} falls back to
|
|
33365
|
+
* {@link TimelapseRuleSchema.shape.lastGeneratedAt}. Reading an old row as
|
|
33366
|
+
* "never generated" would re-render and re-notify every camera of every rule
|
|
33367
|
+
* once, on the deploy that shipped the map.
|
|
33368
|
+
*/
|
|
33369
|
+
generatedByDevice: record(string(), number()).optional(),
|
|
33248
33370
|
/** userId of the caller who created the rule (server-stamped). */
|
|
33249
33371
|
createdBy: string(),
|
|
33250
33372
|
createdAt: number(),
|
|
@@ -33493,6 +33615,91 @@ async function warmNodePty() {
|
|
|
33493
33615
|
await loadNodePty();
|
|
33494
33616
|
}
|
|
33495
33617
|
//#endregion
|
|
33618
|
+
//#region src/silence-analysis.ts
|
|
33619
|
+
/**
|
|
33620
|
+
* Every analyzer a Terminal camera is created with switched OFF.
|
|
33621
|
+
*
|
|
33622
|
+
* `motion-detection` sits first deliberately: it is the one that holds the
|
|
33623
|
+
* decode session open, so it is the one whose absence is the difference between
|
|
33624
|
+
* a terminal costing a screen scrape and costing a decode pipeline.
|
|
33625
|
+
*/
|
|
33626
|
+
var TERMINAL_SILENCED_CAP_NAMES = [
|
|
33627
|
+
"motion-detection",
|
|
33628
|
+
DETECTION_PIPELINE_CAP_NAME,
|
|
33629
|
+
AUDIO_ANALYSIS_CAP_NAME
|
|
33630
|
+
];
|
|
33631
|
+
/**
|
|
33632
|
+
* Resolve the addon currently providing `capName` for this device.
|
|
33633
|
+
*
|
|
33634
|
+
* Inlined rather than reached for through a helper because there is exactly one
|
|
33635
|
+
* honest source: the device's own bindings. `listBindableCapsForDeviceType`
|
|
33636
|
+
* answers for the device TYPE and would happily name a wrapper that is not the
|
|
33637
|
+
* one bound here.
|
|
33638
|
+
*
|
|
33639
|
+
* Returns `null` when the cap is not bound at all — which is not an error: a
|
|
33640
|
+
* deployment with no audio analyzer has nothing to switch off, and demanding one
|
|
33641
|
+
* would make every terminal creation fail on a perfectly valid hub.
|
|
33642
|
+
*/
|
|
33643
|
+
async function resolveBoundWrapper(api, deviceId, capName) {
|
|
33644
|
+
const bindings = await api.deviceManager.getBindings.query({ deviceId });
|
|
33645
|
+
for (const entry of bindings.entries) {
|
|
33646
|
+
if (entry.capName !== capName) continue;
|
|
33647
|
+
if (entry.kind !== "wrapped") continue;
|
|
33648
|
+
if (entry.providerAddonId === "") return null;
|
|
33649
|
+
return entry.providerAddonId;
|
|
33650
|
+
}
|
|
33651
|
+
return null;
|
|
33652
|
+
}
|
|
33653
|
+
/**
|
|
33654
|
+
* Switch every analyzer in {@link TERMINAL_SILENCED_CAP_NAMES} off for one
|
|
33655
|
+
* Terminal camera.
|
|
33656
|
+
*
|
|
33657
|
+
* Called ONLY on creation. An operator who deliberately turns detection back on
|
|
33658
|
+
* for a terminal must win, and a reconcile that re-asserted every pass would
|
|
33659
|
+
* silently overrule them once a minute.
|
|
33660
|
+
*
|
|
33661
|
+
* @throws if a cap IS bound and the write to its authority failed. The camera
|
|
33662
|
+
* would then be running a full analyzer for a screen recording, which is the
|
|
33663
|
+
* exact cost this exists to remove — and a silent version of that failure is
|
|
33664
|
+
* unfindable.
|
|
33665
|
+
*/
|
|
33666
|
+
async function silenceAnalysisFor(deps, deviceId) {
|
|
33667
|
+
const failures = [];
|
|
33668
|
+
for (const capName of TERMINAL_SILENCED_CAP_NAMES) try {
|
|
33669
|
+
const wrapperAddonId = await resolveBoundWrapper(deps.api, deviceId, capName);
|
|
33670
|
+
if (wrapperAddonId === null) {
|
|
33671
|
+
deps.logger.debug("terminal camera: no analyzer bound for this capability", {
|
|
33672
|
+
tags: { deviceId },
|
|
33673
|
+
meta: { capName }
|
|
33674
|
+
});
|
|
33675
|
+
continue;
|
|
33676
|
+
}
|
|
33677
|
+
await deps.api.deviceManager.setWrapperActive.mutate({
|
|
33678
|
+
deviceId,
|
|
33679
|
+
capName,
|
|
33680
|
+
wrapperAddonId,
|
|
33681
|
+
active: false
|
|
33682
|
+
});
|
|
33683
|
+
deps.logger.info("terminal camera: analyzer switched off at its authority", {
|
|
33684
|
+
tags: { deviceId },
|
|
33685
|
+
meta: {
|
|
33686
|
+
capName,
|
|
33687
|
+
wrapperAddonId
|
|
33688
|
+
}
|
|
33689
|
+
});
|
|
33690
|
+
} catch (err) {
|
|
33691
|
+
failures.push(`${capName}: ${errMsg(err)}`);
|
|
33692
|
+
deps.logger.error("terminal camera: could NOT switch an analyzer off", {
|
|
33693
|
+
tags: { deviceId },
|
|
33694
|
+
meta: {
|
|
33695
|
+
capName,
|
|
33696
|
+
error: errMsg(err)
|
|
33697
|
+
}
|
|
33698
|
+
});
|
|
33699
|
+
}
|
|
33700
|
+
if (failures.length > 0) throw new Error(`terminal camera ${deviceId}: could not switch off ${failures.length} analyzer(s) — it will run at full detection cost (${failures.join("; ")})`);
|
|
33701
|
+
}
|
|
33702
|
+
//#endregion
|
|
33496
33703
|
//#region src/terminal-camera-declarations.ts
|
|
33497
33704
|
/**
|
|
33498
33705
|
* Feed DeclaredDevices every live declaration plus one deterministic orphan
|
|
@@ -40026,37 +40233,22 @@ var TerminalAddon = class extends BaseAddon {
|
|
|
40026
40233
|
}
|
|
40027
40234
|
}
|
|
40028
40235
|
/**
|
|
40029
|
-
* A Terminal camera is a rendered screen.
|
|
40030
|
-
*
|
|
40031
|
-
*
|
|
40032
|
-
*
|
|
40033
|
-
* through the detection pipeline at ~61 ms of inference each, plus 115
|
|
40034
|
-
* capture-scheduler requests a minute — against `detections=0`. Multiply by
|
|
40035
|
-
* one terminal per node and it is a standing tax on a hub that was already
|
|
40036
|
-
* shedding 86 % of its capture queue.
|
|
40236
|
+
* A Terminal camera is a rendered screen. Every analyzer on it finds nothing,
|
|
40237
|
+
* forever, at full cost — see `silence-analysis.ts` for the measurements, the
|
|
40238
|
+
* reason `motion-detection` is in the list, and why a failure THROWS instead
|
|
40239
|
+
* of warning.
|
|
40037
40240
|
*
|
|
40038
|
-
* Written
|
|
40039
|
-
*
|
|
40040
|
-
*
|
|
40041
|
-
*
|
|
40042
|
-
*
|
|
40043
|
-
*/
|
|
40044
|
-
|
|
40045
|
-
|
|
40046
|
-
|
|
40047
|
-
|
|
40048
|
-
|
|
40049
|
-
enabled: false
|
|
40050
|
-
});
|
|
40051
|
-
} catch (err) {
|
|
40052
|
-
this.ctx.logger.warn("could not switch off analysis for a Terminal camera", {
|
|
40053
|
-
tags: { deviceId },
|
|
40054
|
-
meta: {
|
|
40055
|
-
switchId,
|
|
40056
|
-
error: err instanceof Error ? err.message : String(err)
|
|
40057
|
-
}
|
|
40058
|
-
});
|
|
40059
|
-
}
|
|
40241
|
+
* Written to the AUTHORITIES (`deviceManager.setWrapperActive`), not through
|
|
40242
|
+
* the deprecated switch cap the previous version used (D113). Written ONLY on
|
|
40243
|
+
* creation: an operator who deliberately turns detection back on for a
|
|
40244
|
+
* terminal must win, and a reconcile that re-asserted every pass would
|
|
40245
|
+
* silently overrule them once a minute.
|
|
40246
|
+
*/
|
|
40247
|
+
silenceAnalysisFor(deviceId) {
|
|
40248
|
+
return silenceAnalysisFor({
|
|
40249
|
+
api: this.ctx.api,
|
|
40250
|
+
logger: this.ctx.logger
|
|
40251
|
+
}, deviceId);
|
|
40060
40252
|
}
|
|
40061
40253
|
terminalInstanceControl() {
|
|
40062
40254
|
return {
|