@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.
@@ -5,7 +5,7 @@
5
5
  * ## This file adds no state
6
6
  *
7
7
  * Every switch here is a VIEW onto an authority that already existed
8
- * ([D61](../../../../docs/decisions/adr-0062.md)). The whole point of the
8
+ * ([D62](../../../../docs/decisions/adr-0062.md)). The whole point of the
9
9
  * group is that there is exactly one place each function is turned off, and
10
10
  * the group routes to it:
11
11
  *
@@ -16,6 +16,40 @@
16
16
  * | `audio-analysis` | `deviceManager.setWrapperActive('audio-analysis')` | `AudioSubscriptionController.subscribeAudioStream` returns `null` before opening the stream |
17
17
  * | `recording` | `recording.setDeviceConfig` → `RecordingConfig.enabled` | `band-decision.shouldRecord` returns false; the controller detaches the device |
18
18
  * | `notifications` | `notificationRules.setDeviceMuted` | `NotificationCenter.evaluateAndEnqueue` returns before any rule is evaluated |
19
+ * | `privacy-mask` | `privacyMask.setMask({ enabled })` → the CAMERA | the camera blanks the masked regions itself; every stream and recording carries the black boxes |
20
+ * | `device-audio` | `privacyMask.setAudioEnabled` → the CAMERA | the camera stops encoding an audio track at all; every consumer sees silent video |
21
+ *
22
+ * ## The two switches whose authority is not on this server
23
+ *
24
+ * `privacy-mask` and `device-audio` write the CAMERA. That is not a loophole
25
+ * in "the group stores nothing" — it is the purest form of it: the camera
26
+ * holds the fact, every read is a read-through, and there is no server-side
27
+ * copy that could drift. Their availability therefore cannot come from
28
+ * `listBindableCapsForDeviceType` (a device-NATIVE cap carries no wrappers and
29
+ * is filtered out there); it comes from the cap's own camera-probed
30
+ * `privacyMask.getOptions()`, which is strictly more honest — it answers for
31
+ * THIS camera rather than for the device type
32
+ * ([D74](../../../../docs/decisions/adr-0074.md)).
33
+ *
34
+ * ## `privacy-mask` is the one row whose ON is not "the function is working"
35
+ *
36
+ * Every other switch means *this camera's function is doing its job*, so
37
+ * `enabled: false` is a thing an operator took away. `privacy-mask` means **the
38
+ * MASK is active** — `enabled: true` is video deliberately obscured. The
39
+ * polarity is not a choice made here: `addon-export-hap`'s privacy `Switch`
40
+ * (`builders/privacy-switch.ts`) already mirrors `patch.enabled` verbatim, and
41
+ * a HomeKit toggle that disagreed with the app's toggle for the same camera is
42
+ * worse than either surface not having one.
43
+ *
44
+ * Two consequences follow and both are load-bearing:
45
+ *
46
+ * - **It never counts as `switchedOff`.** `countsAsSwitchedOff` is `false` for
47
+ * exactly this row. With the polarity above, every camera that has NOT drawn
48
+ * a privacy mask would otherwise report `switchedOff: ['privacy-mask']` — the
49
+ * normal, healthy state of most cameras rendered as an operator disablement.
50
+ * - **Its cost line names BOTH directions.** `costWhenOff` is rendered
51
+ * unconditionally by both clients, so for this row it has to read correctly
52
+ * whichever way the switch is sitting.
19
53
  *
20
54
  * The wrapper-binding pair is not a new idea: `legacy-migrations.ts` already
21
55
  * migrated the legacy `audioEnabled` / `pipelineEnabled` /
@@ -35,20 +69,34 @@
35
69
  */
36
70
  import { z } from 'zod';
37
71
  /**
38
- * The five functions the operator named (2026-08-05). Deliberately NOT one id
39
- * per pipeline step: face recognition and plate/LPR are per-step toggles on
72
+ * The functions the operator named — five on 2026-08-05, plus the camera's own
73
+ * microphone on 2026-08-07. Deliberately NOT one id per pipeline step: face
74
+ * recognition and plate/LPR are per-step toggles on
40
75
  * `pipelineOrchestrator.setCameraStepToggle` and belong in the pipeline
41
- * editor, not in a five-button safety group.
76
+ * editor, not in a safety group.
42
77
  */
43
78
  export declare const CameraSwitchIdSchema: z.ZodEnum<{
79
+ "privacy-mask": "privacy-mask";
44
80
  "audio-analysis": "audio-analysis";
45
81
  recording: "recording";
46
82
  "stream-broker": "stream-broker";
47
83
  notifications: "notifications";
48
84
  "object-detection": "object-detection";
85
+ "device-audio": "device-audio";
49
86
  }>;
50
87
  export type CameraSwitchId = z.infer<typeof CameraSwitchIdSchema>;
51
- /** Stable render order — broadest blast radius first. */
88
+ /**
89
+ * Stable render order — broadest blast radius first, and a source before the
90
+ * thing that consumes it. `device-audio` sits ABOVE `audio-analysis` because
91
+ * turning the microphone off leaves the analyzer with nothing to analyse; the
92
+ * reverse is not true.
93
+ *
94
+ * `privacy-mask` sits directly ABOVE `device-audio` because they are literal
95
+ * siblings — one cap, one device plane, video then audio — and NOT above
96
+ * `object-detection` despite feeding it: a mask blanks REGIONS, so its blast
97
+ * radius is partial, and the "broadest first" rule does not rank a partial
98
+ * control above a whole-function one.
99
+ */
52
100
  export declare const CAMERA_SWITCH_ORDER: readonly CameraSwitchId[];
53
101
  /**
54
102
  * WHERE the switch's state actually lives. A discriminated union rather than a
@@ -65,6 +113,12 @@ export declare const CameraSwitchAuthoritySchema: z.ZodDiscriminatedUnion<[z.Zod
65
113
  kind: z.ZodLiteral<"recording-config">;
66
114
  }, z.core.$strip>, z.ZodObject<{
67
115
  kind: z.ZodLiteral<"notification-mute">;
116
+ }, z.core.$strip>, z.ZodObject<{
117
+ kind: z.ZodLiteral<"camera-audio">;
118
+ capName: z.ZodString;
119
+ }, z.core.$strip>, z.ZodObject<{
120
+ kind: z.ZodLiteral<"camera-mask">;
121
+ capName: z.ZodString;
68
122
  }, z.core.$strip>], "kind">;
69
123
  export type CameraSwitchAuthority = z.infer<typeof CameraSwitchAuthoritySchema>;
70
124
  /**
@@ -75,6 +129,7 @@ export type CameraSwitchAuthority = z.infer<typeof CameraSwitchAuthoritySchema>;
75
129
  export declare const CameraSwitchUnavailableReasonSchema: z.ZodEnum<{
76
130
  "no-provider": "no-provider";
77
131
  "source-unreachable": "source-unreachable";
132
+ "not-configured": "not-configured";
78
133
  }>;
79
134
  export type CameraSwitchUnavailableReason = z.infer<typeof CameraSwitchUnavailableReasonSchema>;
80
135
  /**
@@ -87,11 +142,13 @@ export type CameraSwitchUnavailableReason = z.infer<typeof CameraSwitchUnavailab
87
142
  */
88
143
  export declare const CameraSwitchSchema: z.ZodObject<{
89
144
  id: z.ZodEnum<{
145
+ "privacy-mask": "privacy-mask";
90
146
  "audio-analysis": "audio-analysis";
91
147
  recording: "recording";
92
148
  "stream-broker": "stream-broker";
93
149
  notifications: "notifications";
94
150
  "object-detection": "object-detection";
151
+ "device-audio": "device-audio";
95
152
  }>;
96
153
  label: z.ZodString;
97
154
  costWhenOff: z.ZodString;
@@ -99,6 +156,7 @@ export declare const CameraSwitchSchema: z.ZodObject<{
99
156
  unavailableReason: z.ZodOptional<z.ZodEnum<{
100
157
  "no-provider": "no-provider";
101
158
  "source-unreachable": "source-unreachable";
159
+ "not-configured": "not-configured";
102
160
  }>>;
103
161
  enabled: z.ZodBoolean;
104
162
  authority: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -110,6 +168,12 @@ export declare const CameraSwitchSchema: z.ZodObject<{
110
168
  kind: z.ZodLiteral<"recording-config">;
111
169
  }, z.core.$strip>, z.ZodObject<{
112
170
  kind: z.ZodLiteral<"notification-mute">;
171
+ }, z.core.$strip>, z.ZodObject<{
172
+ kind: z.ZodLiteral<"camera-audio">;
173
+ capName: z.ZodString;
174
+ }, z.core.$strip>, z.ZodObject<{
175
+ kind: z.ZodLiteral<"camera-mask">;
176
+ capName: z.ZodString;
113
177
  }, z.core.$strip>], "kind">;
114
178
  }, z.core.$strip>;
115
179
  export type CameraSwitch = z.infer<typeof CameraSwitchSchema>;
@@ -118,11 +182,13 @@ export declare const CameraSwitchGroupSchema: z.ZodObject<{
118
182
  deviceId: z.ZodNumber;
119
183
  switches: z.ZodReadonly<z.ZodArray<z.ZodObject<{
120
184
  id: z.ZodEnum<{
185
+ "privacy-mask": "privacy-mask";
121
186
  "audio-analysis": "audio-analysis";
122
187
  recording: "recording";
123
188
  "stream-broker": "stream-broker";
124
189
  notifications: "notifications";
125
190
  "object-detection": "object-detection";
191
+ "device-audio": "device-audio";
126
192
  }>;
127
193
  label: z.ZodString;
128
194
  costWhenOff: z.ZodString;
@@ -130,6 +196,7 @@ export declare const CameraSwitchGroupSchema: z.ZodObject<{
130
196
  unavailableReason: z.ZodOptional<z.ZodEnum<{
131
197
  "no-provider": "no-provider";
132
198
  "source-unreachable": "source-unreachable";
199
+ "not-configured": "not-configured";
133
200
  }>>;
134
201
  enabled: z.ZodBoolean;
135
202
  authority: z.ZodDiscriminatedUnion<[z.ZodObject<{
@@ -141,6 +208,12 @@ export declare const CameraSwitchGroupSchema: z.ZodObject<{
141
208
  kind: z.ZodLiteral<"recording-config">;
142
209
  }, z.core.$strip>, z.ZodObject<{
143
210
  kind: z.ZodLiteral<"notification-mute">;
211
+ }, z.core.$strip>, z.ZodObject<{
212
+ kind: z.ZodLiteral<"camera-audio">;
213
+ capName: z.ZodString;
214
+ }, z.core.$strip>, z.ZodObject<{
215
+ kind: z.ZodLiteral<"camera-mask">;
216
+ capName: z.ZodString;
144
217
  }, z.core.$strip>], "kind">;
145
218
  }, z.core.$strip>>>;
146
219
  fetchedAt: z.ZodNumber;
@@ -154,12 +227,32 @@ export type CameraSwitchGroup = z.infer<typeof CameraSwitchGroupSchema>;
154
227
  */
155
228
  export declare const DETECTION_PIPELINE_CAP_NAME = "detection-pipeline";
156
229
  export declare const AUDIO_ANALYSIS_CAP_NAME = "audio-analysis";
230
+ /**
231
+ * The device-NATIVE cap that owns what the camera does not capture — masked
232
+ * video regions and, since 2026-08-07, the microphone. Named here because the
233
+ * `camera-audio` authority, the orchestrator's gather and the fake harness all
234
+ * have to agree on it.
235
+ */
236
+ export declare const PRIVACY_MASK_CAP_NAME = "privacy-mask";
157
237
  /** Static half of a switch: everything that does not depend on a device. */
158
238
  export interface CameraSwitchDescriptor {
159
239
  readonly id: CameraSwitchId;
160
240
  readonly label: string;
161
241
  readonly costWhenOff: string;
162
242
  readonly authority: CameraSwitchAuthority;
243
+ /**
244
+ * May `enabled: false` on this row appear in {@link switchedOffIds}?
245
+ *
246
+ * `true` for every switch that means "this camera's function is doing its
247
+ * job", where OFF is something a person took away and a status surface must
248
+ * render as disabled rather than broken.
249
+ *
250
+ * `false` for `privacy-mask` alone, and only because its polarity is
251
+ * inverted: OFF there means the mask is not obscuring anything, which is the
252
+ * ordinary state of nearly every camera. Reporting it would put a
253
+ * `switchedOff` badge on healthy cameras and drown the badge that matters.
254
+ */
255
+ readonly countsAsSwitchedOff: boolean;
163
256
  }
164
257
  /**
165
258
  * THE catalog. One entry per switch; the cost lines are the operator-facing
@@ -195,6 +288,65 @@ export interface CameraSwitchDerivationInput {
195
288
  readonly recordingEnabled: boolean | null;
196
289
  /** `notificationRules` per-camera mute; `null` when the cap did not answer. */
197
290
  readonly notificationsMuted: boolean | null;
291
+ /**
292
+ * The camera's own microphone, read through the `privacy-mask` native cap.
293
+ * `null` = the cap is bound to this camera but did not answer.
294
+ *
295
+ * A camera with no `privacy-mask` provider at all is `{ supported: false }`,
296
+ * not `null` — "this camera has no such control" and "we could not read it"
297
+ * are different answers and the operator is shown different copy for each.
298
+ */
299
+ readonly deviceAudio: DeviceAudioSwitchState | null;
300
+ /**
301
+ * The camera's own VIDEO privacy mask, read through the same `privacy-mask`
302
+ * native cap and the same pair of round trips as {@link deviceAudio} — one
303
+ * `getOptions` + one `getStatus` answer both, so the second switch is free.
304
+ *
305
+ * `null` = the cap is bound to this camera but did not answer.
306
+ */
307
+ readonly privacyMask: PrivacyMaskSwitchState | null;
308
+ }
309
+ /** What the camera-probed audio switch reported for one camera. */
310
+ export interface DeviceAudioSwitchState {
311
+ /**
312
+ * `privacyMask.getOptions().supportsAudioMute` — camera-probed, never a
313
+ * device-type guess. A camera that does not expose the control renders no
314
+ * switch rather than a toggle that silently does nothing.
315
+ */
316
+ readonly supported: boolean;
317
+ /**
318
+ * `privacyMask.getStatus().audioEnabled` — the camera's current answer.
319
+ * `null` when unsupported, or when supported but unreadable; the second
320
+ * case yields `source-unreachable`, never a control defaulted to "on".
321
+ */
322
+ readonly enabled: boolean | null;
323
+ }
324
+ /** What the camera-probed VIDEO privacy mask reported for one camera. */
325
+ export interface PrivacyMaskSwitchState {
326
+ /**
327
+ * Can this camera store privacy zones at all? Derived from the probed
328
+ * `getOptions()`: `maxRegions > 0` AND at least one supported shape kind.
329
+ * Reolink answers `maxNum: 0` / `supportedShapes: []` honestly on models
330
+ * that cannot, and that must render no control rather than a toggle whose
331
+ * write the camera discards.
332
+ */
333
+ readonly supported: boolean;
334
+ /**
335
+ * How many zones are actually drawn on the camera right now
336
+ * (`getStatus().regions.length`). `null` = supported, but the status could
337
+ * not be read.
338
+ *
339
+ * **Zero is not "off", it is "nothing to switch".** Toggling the master
340
+ * enable with no zones changes not one pixel — the dead control D62 exists
341
+ * to remove — so it resolves to `not-configured`, which tells the operator
342
+ * to go and draw one instead of leaving them pressing a button.
343
+ */
344
+ readonly configuredRegions: number | null;
345
+ /**
346
+ * `getStatus().enabled` — the camera's master mask flag, `true` when the
347
+ * zones are being blanked. `null` when unreadable; never defaulted.
348
+ */
349
+ readonly enabled: boolean | null;
198
350
  }
199
351
  /**
200
352
  * Pure derivation of the whole group. No I/O — the orchestrator gathers, this
@@ -213,5 +365,11 @@ export declare function deriveCameraSwitches(input: CameraSwitchDerivationInput)
213
365
  * `switchedOff: ['object-detection']` was turned off; the same camera with an
214
366
  * empty list is broken. Unavailable switches never appear — a function nobody
215
367
  * provides was not switched off by anyone.
368
+ *
369
+ * `privacy-mask` never appears either, whichever way it is sitting, because its
370
+ * ON means "the mask is active" rather than "the function works"
371
+ * ({@link CameraSwitchDescriptor.countsAsSwitchedOff}). Without that filter the
372
+ * ordinary state of every camera nobody has masked would carry a
373
+ * "switched off" badge, and the badge that matters would be lost in it.
216
374
  */
217
375
  export declare function switchedOffIds(switches: readonly CameraSwitch[]): readonly CameraSwitchId[];
@@ -16,16 +16,36 @@ import type { RawFrameFormat } from '../types/io.js';
16
16
  * raw buffer to JPEG internally rather than throwing — `infer()` must
17
17
  * always succeed for either kind.
18
18
  */
19
- export type InferenceInput = {
19
+ /**
20
+ * Fields every `InferenceInput` variant carries regardless of pixel transport.
21
+ */
22
+ interface InferenceInputCommon {
23
+ /**
24
+ * The camera this frame belongs to — **diagnostic only**. It changes no
25
+ * dispatch decision, no deadline and no shed rule; it exists so an inference
26
+ * failure inside a SHARED pool can name the camera that paid for it.
27
+ *
28
+ * A `SharedInferencePool` serves every camera on its
29
+ * `(node, runtime, device)`, so its `inference request timed out` and
30
+ * `worker saturated` lines could describe the pool and nothing else — and
31
+ * "why is 617 worse than 615?" is the question those lines always get asked.
32
+ *
33
+ * Omitted by callers with no camera (commands, model loads, the benchmark);
34
+ * the failure line then carries no `deviceId` tag rather than a fabricated
35
+ * one.
36
+ */
37
+ readonly deviceId?: number;
38
+ }
39
+ export type InferenceInput = ({
20
40
  readonly kind: 'jpeg';
21
41
  readonly data: Buffer;
22
- } | {
42
+ } & InferenceInputCommon) | ({
23
43
  readonly kind: 'raw';
24
44
  readonly data: Buffer;
25
45
  readonly width: number;
26
46
  readonly height: number;
27
47
  readonly format: RawFrameFormat;
28
- };
48
+ } & InferenceInputCommon);
29
49
  export interface IInferenceEngine {
30
50
  readonly runtime: DetectionRuntime;
31
51
  readonly device: DetectionDevice;
@@ -34,3 +54,4 @@ export interface IInferenceEngine {
34
54
  run?(input: Float32Array, inputShape: readonly number[]): Promise<Float32Array>;
35
55
  dispose(): Promise<void>;
36
56
  }
57
+ export {};
@@ -0,0 +1,150 @@
1
+ /**
2
+ * THE native-frame **lease** knobs — TTL, RAM budget and demand window for the
3
+ * decode worker's native-resolution frame retention.
4
+ *
5
+ * ## Why they live here and not in the addon that reads them
6
+ *
7
+ * The WRITER is `pipeline-orchestrator` (the cluster-wide settings authority);
8
+ * the READER is a private child process of `addon-pipeline`'s pipeline-runner.
9
+ * Addons never import each other, so a key owned by either side would have to
10
+ * be hand-copied by the other — and a hand-copied key is how a setting silently
11
+ * stops arriving while both sides still look correct. Same reasoning, same
12
+ * placement as `detail-crop.ts` (D52's "one cluster-wide orchestrator setting").
13
+ *
14
+ * ## Why cluster-wide and not per-node
15
+ *
16
+ * The lease is a per-decode-worker RAM window. Its purpose — the late
17
+ * cross-process native crop landing on a full-resolution frame rather than the
18
+ * ≤640 detection fallback — is a property of the PIPELINE, not of a node's
19
+ * hardware: a per-node TTL would mean the same camera produces different crop
20
+ * quality depending on which node the balancer placed it on, and nobody could
21
+ * tell that from the stored media. Node-level RAM pressure is already handled
22
+ * by the per-session budget ceiling, which is itself one of these knobs.
23
+ *
24
+ * ## What each knob costs
25
+ *
26
+ * A retained frame is a full NATIVE-resolution copy in system RAM. With the
27
+ * default pinned-RGB24 lease path (`CAMSTACK_SESSION_PINNED_RGB_CROP`, on):
28
+ * 4K ≈ 24.9 MB/frame, 1080p ≈ 6.2 MB/frame. On the YUV420P path (flag off, and
29
+ * for software-decoded sessions): 4K ≈ 12.4 MB, 1080p ≈ 3.1 MB. Worst-case
30
+ * resident RAM for ONE busy camera ≈ frameBytes × deliveredFps × ttlSeconds,
31
+ * clamped by the budget ceiling. See `docs/design/decode-path.md` → "Lease
32
+ * admission" for what actually gets admitted.
33
+ */
34
+ import { z } from 'zod';
35
+ import type { HydratedSettingsView } from './detail-crop.js';
36
+ /**
37
+ * Store identity of the lease knobs in `pipeline-orchestrator`'s GLOBAL
38
+ * (cluster-wide) settings. Keys are unique across that addon's whole schema, so
39
+ * the reader can walk every section instead of trusting the section id.
40
+ */
41
+ export declare const NATIVE_LEASE_SECTION_ID = "native-lease";
42
+ export declare const NATIVE_LEASE_TTL_KEY = "nativeLeaseTtlMs";
43
+ export declare const NATIVE_LEASE_BUDGET_KEY = "nativeLeaseBudgetMb";
44
+ export declare const NATIVE_LEASE_ACTIVITY_KEY = "nativeLeaseActivityMs";
45
+ export declare const NATIVE_LEASE_ADMISSION_KEY = "nativeLeaseAdmission";
46
+ /**
47
+ * WHICH delivered frames the decode worker retains a native copy of.
48
+ *
49
+ * - `all` — every frame the worker delivered to the runner. The shipped
50
+ * behaviour, and the only correct one if something can ask for a crop of a
51
+ * frame the runner never sent to inference.
52
+ * - `inferred` — only the frames the runner ADMITTED to its detection queue.
53
+ * A native-crop request always names a `frameId` that rode an inference
54
+ * result, so that is the only set a request can name. How much it drops is
55
+ * the two-plane governor's admit ratio and nothing else: measured at ~50% on
56
+ * this cluster, not the ~80% the design sketch assumed, because the governor
57
+ * was not throttling as hard as the sketch supposed. Read
58
+ * `leaseAdmitted`/`leaseOffered` off the metrics line for the camera in front
59
+ * of you rather than quoting a number from here. The newest delivered frame is
60
+ * croppable regardless — it is still the worker's reserved slot, not a lease —
61
+ * which covers the one-frame race between a mark and the supersede that
62
+ * consumes it.
63
+ */
64
+ export declare const NativeLeaseAdmissionSchema: z.ZodEnum<{
65
+ all: "all";
66
+ inferred: "inferred";
67
+ }>;
68
+ export type NativeLeaseAdmission = z.infer<typeof NativeLeaseAdmissionSchema>;
69
+ /**
70
+ * Operator-tunable native-lease settings. Bounds are enforced HERE (not only in
71
+ * the slider) because the value also travels to a forked child process, where a
72
+ * junk number would silently become a 0-length or unbounded retention window.
73
+ */
74
+ export declare const NativeLeaseSettingsSchema: z.ZodObject<{
75
+ ttlMs: z.ZodNumber;
76
+ budgetMb: z.ZodNumber;
77
+ activityMs: z.ZodNumber;
78
+ admission: z.ZodEnum<{
79
+ all: "all";
80
+ inferred: "inferred";
81
+ }>;
82
+ }, z.core.$strip>;
83
+ export type NativeLeaseSettings = z.infer<typeof NativeLeaseSettingsSchema>;
84
+ /**
85
+ * The values in force when the operator has set nothing — byte-for-byte the
86
+ * constants the decode worker shipped with as env-var defaults, so making these
87
+ * settings changed no behaviour on the day it landed.
88
+ */
89
+ export declare const DEFAULT_NATIVE_LEASE_SETTINGS: NativeLeaseSettings;
90
+ /**
91
+ * Only the knobs the operator has ACTUALLY set. Distinguishing "set" from
92
+ * "absent" is what makes the documented precedence (setting > env > default)
93
+ * expressible: an absent knob leaves the env-var escape hatch working.
94
+ */
95
+ export type NativeLeaseSettingsOverride = Partial<NativeLeaseSettings>;
96
+ /** Slider bounds for the operator-facing knobs (orchestrator settings UI). */
97
+ export declare const NATIVE_LEASE_TTL_FIELD: {
98
+ readonly min: 250;
99
+ readonly max: 10000;
100
+ readonly step: 50;
101
+ readonly default: number;
102
+ };
103
+ export declare const NATIVE_LEASE_BUDGET_FIELD: {
104
+ readonly min: 0;
105
+ readonly max: 4096;
106
+ readonly step: 64;
107
+ readonly default: number;
108
+ };
109
+ export declare const NATIVE_LEASE_ACTIVITY_FIELD: {
110
+ readonly min: 0;
111
+ readonly max: 120000;
112
+ readonly step: 1000;
113
+ readonly default: number;
114
+ };
115
+ /** Select options for the admission knob (orchestrator settings UI). */
116
+ export declare const NATIVE_LEASE_ADMISSION_FIELD: {
117
+ readonly options: readonly [{
118
+ readonly value: "all";
119
+ readonly label: "Every delivered frame";
120
+ }, {
121
+ readonly value: "inferred";
122
+ readonly label: "Only frames sent to inference";
123
+ }];
124
+ readonly default: "all" | "inferred";
125
+ };
126
+ /** The four knobs, as a key union. */
127
+ export type NativeLeaseKnob = keyof NativeLeaseSettings;
128
+ /** The three knobs whose value is a bounded integer (everything but `admission`). */
129
+ export type NativeLeaseNumberKnob = 'ttlMs' | 'budgetMb' | 'activityMs';
130
+ /**
131
+ * Narrow a FLAT settings record to the knobs the operator set.
132
+ *
133
+ * Per-FIELD parse, deliberately: a junk TTL must not also discard a valid
134
+ * budget. An absent, out-of-bounds or default-valued knob is OMITTED (not
135
+ * clamped, not defaulted) so the caller can still fall through to the env
136
+ * override — clamping here would turn a typo into a value nobody chose. See
137
+ * {@link readKnob} for why the default counts as unset.
138
+ */
139
+ export declare function readNativeLeaseOverride(config: Readonly<Record<string, unknown>>): NativeLeaseSettingsOverride;
140
+ /**
141
+ * Extract the operator's lease overrides from an
142
+ * `addon-settings.getGlobalSettings` payload.
143
+ *
144
+ * Walks EVERY section rather than looking inside {@link NATIVE_LEASE_SECTION_ID}
145
+ * alone: the keys are unique across the addon's schema, and a section rename
146
+ * must not silently revert the whole cluster to the defaults. A `null` payload
147
+ * (addon mid-boot) means "operator set nothing" — the env/default fallback then
148
+ * applies, which is the correct read of "I could not ask".
149
+ */
150
+ export declare function pickNativeLeaseOverride(view: HydratedSettingsView | null): NativeLeaseSettingsOverride;
@@ -1934,9 +1934,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
1934
1934
  /**
1935
1935
  * Build the tRPC request options that pin a single capability call to `nodeId`.
1936
1936
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
1937
+ *
1938
+ * ## The id is normalised here, and it has to be
1939
+ *
1940
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
1941
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
1942
+ * compares a pin against real node ids, so such a pin matches nothing and the
1943
+ * call fails with `no provider registered for cap "…"`. The local-first
1944
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
1945
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
1946
+ * the thing you reach for when you specifically need THIS node — silently
1947
+ * addressed a node that does not exist.
1948
+ *
1949
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
1950
+ * own node to read the host's hardware-decode backend. It never once answered,
1951
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
1952
+ * while D67's whole premise was that the decoder addon is the authority on
1953
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
1954
+ * hardware", which was false.
1955
+ *
1956
+ * Normalising in the ONE constructor fixes every caller at once, which is why
1957
+ * it is here and not at the call sites.
1937
1958
  */
1938
1959
  function nodePin(nodeId) {
1939
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
1960
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
1961
+ }
1962
+ /**
1963
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
1964
+ * is idempotent, so passing an already-clean id costs nothing.
1965
+ */
1966
+ function toNodeId(idOrRunnerId) {
1967
+ const head = idOrRunnerId.split("/")[0];
1968
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
1940
1969
  }
1941
1970
  /**
1942
1971
  * Read a per-call node pin out of a tRPC `op.context` (transport side).
@@ -3180,6 +3209,7 @@ function createDeviceProxy(api, binding, opts) {
3180
3209
  privacyMask: {
3181
3210
  getOptions: (input) => dispatch("privacy-mask", "privacyMask", "getOptions", "query", input),
3182
3211
  setMask: (input) => dispatch("privacy-mask", "privacyMask", "setMask", "mutation", input),
3212
+ setAudioEnabled: (input) => dispatch("privacy-mask", "privacyMask", "setAudioEnabled", "mutation", input),
3183
3213
  getStatus: (input) => dispatch("privacy-mask", "privacyMask", "getStatus", "query", input)
3184
3214
  },
3185
3215
  ptz: {
@@ -3414,6 +3444,7 @@ function createDeviceProxy(api, binding, opts) {
3414
3444
  getDeviceConfig: (input) => dispatchSystem("recording", "getDeviceConfig", "query", input),
3415
3445
  locateSegment: (input) => dispatchSystem("recording", "locateSegment", "query", input),
3416
3446
  readSegmentBytes: (input) => dispatchSystem("recording", "readSegmentBytes", "query", input),
3447
+ readGopBytes: (input) => dispatchSystem("recording", "readGopBytes", "query", input),
3417
3448
  setDeviceConfig: (input) => dispatchSystem("recording", "setDeviceConfig", "mutation", input),
3418
3449
  rescanStorage: (input) => dispatchSystem("recording", "rescanStorage", "mutation", input),
3419
3450
  pruneFootage: (input) => dispatchSystem("recording", "pruneFootage", "mutation", input),
@@ -4129,6 +4160,12 @@ Object.defineProperty(exports, "systemMethod", {
4129
4160
  return systemMethod;
4130
4161
  }
4131
4162
  });
4163
+ Object.defineProperty(exports, "toNodeId", {
4164
+ enumerable: true,
4165
+ get: function() {
4166
+ return toNodeId;
4167
+ }
4168
+ });
4132
4169
  Object.defineProperty(exports, "viewerUiCapability", {
4133
4170
  enumerable: true,
4134
4171
  get: function() {
@@ -1934,9 +1934,38 @@ var CAP_NODE_PIN_CONTEXT_KEY = "__camstackNodePin";
1934
1934
  /**
1935
1935
  * Build the tRPC request options that pin a single capability call to `nodeId`.
1936
1936
  * Pass as the second argument to `.query(input, …)` / `.mutate(input, …)`.
1937
+ *
1938
+ * ## The id is normalised here, and it has to be
1939
+ *
1940
+ * A forked addon reads its own node from `ctx.kernel.localNodeId`, and inside a
1941
+ * worker that value is a RUNNER id — `hub/export-hap`, not `hub`. Routing
1942
+ * compares a pin against real node ids, so such a pin matches nothing and the
1943
+ * call fails with `no provider registered for cap "…"`. The local-first
1944
+ * resolver already guarded against this (`localNodeId.split('/')[0]`), which
1945
+ * made the hazard invisible: unpinned calls worked, and only an explicit pin —
1946
+ * the thing you reach for when you specifically need THIS node — silently
1947
+ * addressed a node that does not exist.
1948
+ *
1949
+ * Cost of it being missing: `addon-export-hap` pinned `decoder.getInfo` to its
1950
+ * own node to read the host's hardware-decode backend. It never once answered,
1951
+ * so every HomeKit egress transcode decoded in SOFTWARE — including 4K H.265 —
1952
+ * while D67's whole premise was that the decoder addon is the authority on
1953
+ * hardware. The warn said `decoding in SOFTWARE` and read as "this node has no
1954
+ * hardware", which was false.
1955
+ *
1956
+ * Normalising in the ONE constructor fixes every caller at once, which is why
1957
+ * it is here and not at the call sites.
1937
1958
  */
1938
1959
  function nodePin(nodeId) {
1939
- return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: nodeId } };
1960
+ return { context: { [CAP_NODE_PIN_CONTEXT_KEY]: toNodeId(nodeId) } };
1961
+ }
1962
+ /**
1963
+ * A runner id is `<nodeId>/<addonId>`; a node id has no slash. Taking the head
1964
+ * is idempotent, so passing an already-clean id costs nothing.
1965
+ */
1966
+ function toNodeId(idOrRunnerId) {
1967
+ const head = idOrRunnerId.split("/")[0];
1968
+ return head === void 0 || head.length === 0 ? idOrRunnerId : head;
1940
1969
  }
1941
1970
  /**
1942
1971
  * Read a per-call node pin out of a tRPC `op.context` (transport side).
@@ -3180,6 +3209,7 @@ function createDeviceProxy(api, binding, opts) {
3180
3209
  privacyMask: {
3181
3210
  getOptions: (input) => dispatch("privacy-mask", "privacyMask", "getOptions", "query", input),
3182
3211
  setMask: (input) => dispatch("privacy-mask", "privacyMask", "setMask", "mutation", input),
3212
+ setAudioEnabled: (input) => dispatch("privacy-mask", "privacyMask", "setAudioEnabled", "mutation", input),
3183
3213
  getStatus: (input) => dispatch("privacy-mask", "privacyMask", "getStatus", "query", input)
3184
3214
  },
3185
3215
  ptz: {
@@ -3414,6 +3444,7 @@ function createDeviceProxy(api, binding, opts) {
3414
3444
  getDeviceConfig: (input) => dispatchSystem("recording", "getDeviceConfig", "query", input),
3415
3445
  locateSegment: (input) => dispatchSystem("recording", "locateSegment", "query", input),
3416
3446
  readSegmentBytes: (input) => dispatchSystem("recording", "readSegmentBytes", "query", input),
3447
+ readGopBytes: (input) => dispatchSystem("recording", "readGopBytes", "query", input),
3417
3448
  setDeviceConfig: (input) => dispatchSystem("recording", "setDeviceConfig", "mutation", input),
3418
3449
  rescanStorage: (input) => dispatchSystem("recording", "rescanStorage", "mutation", input),
3419
3450
  pruneFootage: (input) => dispatchSystem("recording", "pruneFootage", "mutation", input),
@@ -3661,4 +3692,4 @@ function sleepCancellable(ms, signal) {
3661
3692
  });
3662
3693
  }
3663
3694
  //#endregion
3664
- export { ProfileRtspEntrySchema as $, method as A, scopeKey as B, DeviceType as C, collectHydratedFieldValues as Ct, event as D, DEVICE_STATUS_METHOD as E, readNodePin as F, CamStreamKindSchema as G, BrokerStatusSchema as H, ReadinessRegistry as I, DecodedAudioChunkSchema as J, CamStreamResolutionSchema as K, ReadinessTimeoutError as L, systemMethod as M, CAP_NODE_PIN_CONTEXT_KEY as N, expandCapMethods as O, nodePin as P, FrameHandleSchema as Q, emitDownForOwnedCaps as R, DeviceRole as S, collectHydratedFieldEntries as St, DEVICE_SETTINGS_CONTRIBUTION_METHODS as T, resolveHydratedFieldValue as Tt, CAM_PROFILE_ORDER as U, BrokerStatsSchema as V, CamProfileSchema as W, EncodedPacketSchema as X, DecodedFrameSchema as Y, FrameHandleFormatSchema as Z, RawStateResultSchema as _, createEvent as _t, asJsonObject as a, SubscribeAudioChunksResultSchema as at, ChargingStatus as b, WELL_KNOWN_TABS as bt, parseJsonArray as c, makeProfileBrokerId as ct, DEVICE_SCOPED_CAPS as d, selectAssignedProfileSlots as dt, ProfileSlotSchema as et, isDeviceScopedCap as f, DATAPLANE_SECRET_HEADER as ft, createSliceHandle as g, createDurableState as gt, createMirrorSource as h, normalizeAddonInitResult as ht, asJsonArray as i, SubscribeAudioChunksInputSchema as it, resolveCapMount as j, isDeviceConfigCap as k, parseJsonObject as l, makeSourceBrokerId as lt, createLazyTrpcSource as m, BaseAddon as mt, sleepCancellable as n, StreamSourceEntrySchema$1 as nt, asNumber as o, SubscribeFramesInputSchema as ot, createDeviceProxy as p, DisposerChain as pt, CameraStreamSchema as q, asBoolean as r, StreamSourceSchema as rt, asString as s, SubscribeFramesResultSchema as st, sleep as t, ProfileSlotStatusSchema as tt, parseJsonUnknown as u, parseProfileBrokerId as ut, deviceOpsCapability as v, emitReadiness as vt, adminUiCapability as w, hydrateSchema as wt, DeviceFeature as x, WELL_KNOWN_TAB_MAP as xt, viewerUiCapability as y, isEvent as yt, readinessKey as z };
3695
+ export { FrameHandleSchema as $, method as A, readinessKey as B, DeviceType as C, collectHydratedFieldEntries as Ct, event as D, DEVICE_STATUS_METHOD as E, resolveHydratedFieldValue as Et, readNodePin as F, CamProfileSchema as G, BrokerStatsSchema as H, toNodeId as I, CameraStreamSchema as J, CamStreamKindSchema as K, ReadinessRegistry as L, systemMethod as M, CAP_NODE_PIN_CONTEXT_KEY as N, expandCapMethods as O, nodePin as P, FrameHandleFormatSchema as Q, ReadinessTimeoutError as R, DeviceRole as S, WELL_KNOWN_TAB_MAP as St, DEVICE_SETTINGS_CONTRIBUTION_METHODS as T, hydrateSchema as Tt, BrokerStatusSchema as U, scopeKey as V, CAM_PROFILE_ORDER as W, DecodedFrameSchema as X, DecodedAudioChunkSchema as Y, EncodedPacketSchema as Z, RawStateResultSchema as _, createDurableState as _t, asJsonObject as a, SubscribeAudioChunksInputSchema as at, ChargingStatus as b, isEvent as bt, parseJsonArray as c, SubscribeFramesResultSchema as ct, DEVICE_SCOPED_CAPS as d, parseProfileBrokerId as dt, ProfileRtspEntrySchema as et, isDeviceScopedCap as f, selectAssignedProfileSlots as ft, createSliceHandle as g, normalizeAddonInitResult as gt, createMirrorSource as h, BaseAddon as ht, asJsonArray as i, StreamSourceSchema as it, resolveCapMount as j, isDeviceConfigCap as k, parseJsonObject as l, makeProfileBrokerId as lt, createLazyTrpcSource as m, DisposerChain as mt, sleepCancellable as n, ProfileSlotStatusSchema as nt, asNumber as o, SubscribeAudioChunksResultSchema as ot, createDeviceProxy as p, DATAPLANE_SECRET_HEADER as pt, CamStreamResolutionSchema as q, asBoolean as r, StreamSourceEntrySchema$1 as rt, asString as s, SubscribeFramesInputSchema as st, sleep as t, ProfileSlotSchema as tt, parseJsonUnknown as u, makeSourceBrokerId as ut, deviceOpsCapability as v, createEvent as vt, adminUiCapability as w, collectHydratedFieldValues as wt, DeviceFeature as x, WELL_KNOWN_TABS as xt, viewerUiCapability as y, emitReadiness as yt, emitDownForOwnedCaps as z };
@@ -0,0 +1,30 @@
1
+ /**
2
+ * The addon id as the REGISTRY, the wire and the durable stores spell it.
3
+ *
4
+ * `AddonContext.id` does not agree with itself across the two context
5
+ * factories:
6
+ *
7
+ * - a FORKED addon gets the bare manifest id
8
+ * (`kernel/moleculer/addon-context-factory.ts` → `id: addonId`);
9
+ * - an addon co-located in hub-main gets it PREFIXED
10
+ * (`server/backend/src/core/addon/addon-registry.service.ts` →
11
+ * ``id: `addon:${addonId}` ``).
12
+ *
13
+ * Everything an addon might compare `ctx.id` AGAINST carries the bare form:
14
+ * `DeviceBindingEntry.providerAddonId`, the `device-manager` bindings store's
15
+ * `wrapperAddonId`, `CapabilityRegistry` provider keys, manifest ids.
16
+ *
17
+ * So `entry.providerAddonId === this.ctx.id` is silently, permanently false
18
+ * for a builtin — and only for a builtin, which is why it survives a green
19
+ * suite whose fake supplies the bare id. That is exactly how camera 615's
20
+ * virtual doorbell latched `unbound` for twelve hours on 2026-08-07 while its
21
+ * binding was intact ([D72](../../../../docs/decisions/adr-0072.md)).
22
+ *
23
+ * Route every comparison between `ctx.id` and a registry/store addon id
24
+ * through {@link isSameAddonId}. `scripts/check-addon-id-comparison.ts`
25
+ * enforces it in the processes where the prefix exists.
26
+ */
27
+ /** The manifest id, whichever spelling of `ctx.id` you were handed. */
28
+ export declare function bareAddonId(id: string): string;
29
+ /** True when both ids name the same addon, prefixed or not. */
30
+ export declare function isSameAddonId(a: string, b: string): boolean;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/types",
3
- "version": "1.2.42",
3
+ "version": "1.2.43",
4
4
  "description": "Shared types, interfaces, and model catalogs for the CamStack detection ecosystem",
5
5
  "keywords": [
6
6
  "camstack",