@camstack/system 1.1.28 → 1.1.30

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.
@@ -0,0 +1,16 @@
1
+ import { ConfigUISchema, DayNightOptions, DayNightSettingsPatch, DayNightStatus } from '@camstack/types';
2
+ /**
3
+ * Build the `day-night` `ConfigUISchema` from the camera-probed options +
4
+ * current status. Returns `null` when the camera exposes no configurable
5
+ * property — the cap then reports "no schema" and the renderer shows the
6
+ * unsupported-camera message.
7
+ */
8
+ export declare function buildDayNightConfigSchema(options: DayNightOptions, status: DayNightStatus | null): ConfigUISchema | null;
9
+ /**
10
+ * Re-parse a flat `ConfigFormBuilder` patch into a `DayNightSettingsPatch`.
11
+ * Returns an empty object when no `dayNight_*` field changed — the caller
12
+ * (`parseDerivedFormSettingsPatch`) forwards the result verbatim to the
13
+ * cap's `setSettings` mutation, which itself ignores fields it doesn't
14
+ * support.
15
+ */
16
+ export declare function parseDayNightFormPatch(patch: Record<string, unknown>): DayNightSettingsPatch;
@@ -1,4 +1,4 @@
1
- import { deviceManagerCapability, InferProvider, AddonContext, CapabilityDefinition, DeviceBindingEntry, DeviceConfigSpec, DeviceSettingsContribution, ICapabilityRegistry, IDevice } from '@camstack/types';
1
+ import { deviceManagerCapability, InferProvider, AddonContext, CapabilityDefinition, DeviceBindingEntry, DeviceConfigSpec, DeviceSettingsContribution, ICapabilityRegistry, IDevice, StreamProfile, StreamProfilePatch } from '@camstack/types';
2
2
  import { ContributionShape, DriverConfigSchemaResult } from './device-meta-types.js';
3
3
  import { BindingsDeps, RemoteNativeCaps } from './device-bindings-store.js';
4
4
  /**
@@ -122,6 +122,33 @@ export declare function resolveDriverConfigSchema(deps: AggregationDeps, deviceI
122
122
  addonId: string;
123
123
  device: IDevice;
124
124
  } | null): Promise<DriverConfigSchemaResult>;
125
+ /**
126
+ * Provider surface for a `derived-form` device-config cap's save path.
127
+ * Exactly one of `setProfile` / `setSettings` is present on the actual
128
+ * bound provider — which one is present tells us how to route the
129
+ * patch. `stream-params` exposes `setProfile` (profile axis); `day-night`
130
+ * / `image-settings` expose `setSettings` (no profile axis). Never branch
131
+ * on the cap name here — the method surface is the source of truth.
132
+ */
133
+ export interface DerivedFormSaveSurface {
134
+ setProfile?: (input: {
135
+ deviceId: number;
136
+ profile: StreamProfile;
137
+ patch: StreamProfilePatch;
138
+ }) => Promise<void>;
139
+ setSettings?: (input: {
140
+ deviceId: number;
141
+ settings: Record<string, unknown>;
142
+ }) => Promise<void>;
143
+ }
144
+ /**
145
+ * Route a `derived-form` device-config cap's flat form patch to whichever
146
+ * mutation the bound provider actually exposes — inspecting the
147
+ * provider's method surface, not the cap name. `setSettings` fires once
148
+ * with the parsed flat patch; `setProfile` fans the patch out per-profile
149
+ * via the registered builderId reducer.
150
+ */
151
+ export declare function dispatchDerivedFormSave(builderId: string, deviceId: number, patch: Record<string, unknown>, dcProvider: DerivedFormSaveSurface, capName: string): Promise<void>;
125
152
  export declare function updateDeviceField(deps: AggregationDeps, input: {
126
153
  deviceId: number;
127
154
  writerCapName: string;
@@ -17,16 +17,28 @@ export interface DerivedContributionShape {
17
17
  fields: readonly unknown[];
18
18
  }>;
19
19
  }
20
- /** A per-profile mutation callback the cap's provider supplies. */
20
+ /** A per-profile mutation callback the cap's provider supplies (profile-based caps only). */
21
21
  export type ProfileSetter = (profile: StreamProfile, patch: StreamProfilePatch) => Promise<void>;
22
- /** The single registered builderId today. New device-config caps add their own. */
22
+ /** Registered builderIds today. New device-config caps add their own reducer below. */
23
23
  export declare const STREAM_PARAMS_BUILDER_ID: "stream-params";
24
+ export declare const DAY_NIGHT_BUILDER_ID: "day-night";
25
+ export declare const IMAGE_SETTINGS_BUILDER_ID: "image-settings";
24
26
  /**
25
27
  * Build the device-detail form section for a `derived-form` device-config
26
28
  * cap. Returns null when the camera exposes no configurable property.
27
29
  */
28
30
  export declare function deriveFormContribution(builderId: string, options: unknown, status: unknown): DerivedContributionShape | null;
29
31
  /**
30
- * Route a flat form patch back through the cap's per-profile setter.
32
+ * Route a flat form patch back through a profile-based cap's per-profile
33
+ * `setProfile` mutation (`stream-params`). Throws if `builderId` isn't
34
+ * registered as a profile-based reducer — callers dispatch here only
35
+ * after confirming the bound provider exposes `setProfile`.
31
36
  */
32
- export declare function applyDerivedFormPatch(builderId: string, patch: Record<string, unknown>, setProfile: ProfileSetter): Promise<void>;
37
+ export declare function applyDerivedFormProfilePatch(builderId: string, patch: Record<string, unknown>, setProfile: ProfileSetter): Promise<void>;
38
+ /**
39
+ * Parse a flat form patch into the settings payload for a `setSettings`-
40
+ * based cap (`day-night`, `image-settings`). The caller forwards the
41
+ * result verbatim to `dcProvider.setSettings({ deviceId, settings })`.
42
+ * Throws if `builderId` isn't registered as a settings-based reducer.
43
+ */
44
+ export declare function parseDerivedFormSettingsPatch(builderId: string, patch: Record<string, unknown>): Record<string, unknown>;
@@ -7,13 +7,361 @@ let _camstack_types_node = require("@camstack/types/node");
7
7
  let _camstack_types = require("@camstack/types");
8
8
  let node_crypto = require("node:crypto");
9
9
  let zod = require("zod");
10
+ //#region src/builtins/device-manager/day-night-config-schema.ts
11
+ var MODE_LABELS = {
12
+ auto: "Auto",
13
+ day: "Day",
14
+ night: "Night",
15
+ schedule: "Schedule"
16
+ };
17
+ /**
18
+ * Build the `day-night` `ConfigUISchema` from the camera-probed options +
19
+ * current status. Returns `null` when the camera exposes no configurable
20
+ * property — the cap then reports "no schema" and the renderer shows the
21
+ * unsupported-camera message.
22
+ */
23
+ function buildDayNightConfigSchema(options, status) {
24
+ const fields = [];
25
+ if (options.modes.length > 0) fields.push({
26
+ type: "select",
27
+ key: "dayNight_mode",
28
+ label: "Mode",
29
+ options: options.modes.map((m) => ({
30
+ value: m,
31
+ label: MODE_LABELS[m]
32
+ })),
33
+ default: status?.mode ?? options.modes[0]
34
+ });
35
+ if (options.supportsSensitivity && options.sensitivity) fields.push({
36
+ type: "slider",
37
+ key: "dayNight_sensitivity",
38
+ label: "IR-cut sensitivity",
39
+ min: options.sensitivity.min,
40
+ max: options.sensitivity.max,
41
+ step: options.sensitivity.step,
42
+ showValue: true,
43
+ default: status?.sensitivity ?? options.sensitivity.min
44
+ });
45
+ if (options.supportsSwitchDelay && options.switchDelaySec) fields.push({
46
+ type: "number",
47
+ key: "dayNight_switchDelaySec",
48
+ label: "Switch delay",
49
+ unit: "s",
50
+ min: options.switchDelaySec.min,
51
+ max: options.switchDelaySec.max,
52
+ step: options.switchDelaySec.step,
53
+ default: status?.switchDelaySec ?? options.switchDelaySec.min
54
+ });
55
+ if (fields.length === 0) return null;
56
+ return { sections: [{
57
+ id: "day-night",
58
+ tab: "image",
59
+ title: "Day / Night",
60
+ description: "IR-cut switching mode and the photocell knobs that gate it.",
61
+ columns: 2,
62
+ fields
63
+ }] };
64
+ }
65
+ var DAY_NIGHT_MODES = new Set([
66
+ "auto",
67
+ "day",
68
+ "night",
69
+ "schedule"
70
+ ]);
71
+ function isDayNightMode(value) {
72
+ return typeof value === "string" && DAY_NIGHT_MODES.has(value);
73
+ }
74
+ /**
75
+ * Re-parse a flat `ConfigFormBuilder` patch into a `DayNightSettingsPatch`.
76
+ * Returns an empty object when no `dayNight_*` field changed — the caller
77
+ * (`parseDerivedFormSettingsPatch`) forwards the result verbatim to the
78
+ * cap's `setSettings` mutation, which itself ignores fields it doesn't
79
+ * support.
80
+ */
81
+ function parseDayNightFormPatch(patch) {
82
+ const out = {};
83
+ if ("dayNight_mode" in patch && isDayNightMode(patch.dayNight_mode)) out.mode = patch.dayNight_mode;
84
+ if ("dayNight_sensitivity" in patch) {
85
+ const value = Number(patch.dayNight_sensitivity);
86
+ if (Number.isFinite(value)) out.sensitivity = value;
87
+ }
88
+ if ("dayNight_switchDelaySec" in patch) {
89
+ const value = Number(patch.dayNight_switchDelaySec);
90
+ if (Number.isFinite(value)) out.switchDelaySec = value;
91
+ }
92
+ return out;
93
+ }
94
+ //#endregion
95
+ //#region src/builtins/device-manager/image-settings-config-schema.ts
96
+ var ROTATE_LABELS = {
97
+ "0": "0°",
98
+ "90": "90°",
99
+ "180": "180°",
100
+ "270": "270°"
101
+ };
102
+ var WHITE_BALANCE_LABELS = {
103
+ auto: "Auto",
104
+ manual: "Manual"
105
+ };
106
+ var EXPOSURE_LABELS = {
107
+ auto: "Auto",
108
+ manual: "Manual"
109
+ };
110
+ var BACKLIGHT_LABELS = {
111
+ off: "Off",
112
+ blc: "Backlight compensation",
113
+ wdr: "Wide dynamic range",
114
+ hlc: "Highlight compensation"
115
+ };
116
+ var ROTATE_VALUES = new Set([
117
+ "0",
118
+ "90",
119
+ "180",
120
+ "270"
121
+ ]);
122
+ var WHITE_BALANCE_VALUES = new Set(["auto", "manual"]);
123
+ var EXPOSURE_VALUES = new Set(["auto", "manual"]);
124
+ var BACKLIGHT_VALUES = new Set([
125
+ "off",
126
+ "blc",
127
+ "wdr",
128
+ "hlc"
129
+ ]);
130
+ function isImageRotate(value) {
131
+ return typeof value === "string" && ROTATE_VALUES.has(value);
132
+ }
133
+ function isWhiteBalanceMode(value) {
134
+ return typeof value === "string" && WHITE_BALANCE_VALUES.has(value);
135
+ }
136
+ function isExposureMode(value) {
137
+ return typeof value === "string" && EXPOSURE_VALUES.has(value);
138
+ }
139
+ function isBacklightMode(value) {
140
+ return typeof value === "string" && BACKLIGHT_VALUES.has(value);
141
+ }
142
+ /**
143
+ * Build the `image-settings` `ConfigUISchema` from the camera-probed
144
+ * options + current status. Returns `null` when the camera exposes no
145
+ * configurable property.
146
+ */
147
+ function buildImageSettingsConfigSchema(options, status) {
148
+ const fields = [];
149
+ if (options.supportsBrightness && options.brightness) fields.push({
150
+ type: "slider",
151
+ key: "imageSettings_brightness",
152
+ label: "Brightness",
153
+ min: options.brightness.min,
154
+ max: options.brightness.max,
155
+ step: options.brightness.step,
156
+ showValue: true,
157
+ default: status?.brightness ?? options.brightness.min
158
+ });
159
+ if (options.supportsContrast && options.contrast) fields.push({
160
+ type: "slider",
161
+ key: "imageSettings_contrast",
162
+ label: "Contrast",
163
+ min: options.contrast.min,
164
+ max: options.contrast.max,
165
+ step: options.contrast.step,
166
+ showValue: true,
167
+ default: status?.contrast ?? options.contrast.min
168
+ });
169
+ if (options.supportsSaturation && options.saturation) fields.push({
170
+ type: "slider",
171
+ key: "imageSettings_saturation",
172
+ label: "Saturation",
173
+ min: options.saturation.min,
174
+ max: options.saturation.max,
175
+ step: options.saturation.step,
176
+ showValue: true,
177
+ default: status?.saturation ?? options.saturation.min
178
+ });
179
+ if (options.supportsSharpness && options.sharpness) fields.push({
180
+ type: "slider",
181
+ key: "imageSettings_sharpness",
182
+ label: "Sharpness",
183
+ min: options.sharpness.min,
184
+ max: options.sharpness.max,
185
+ step: options.sharpness.step,
186
+ showValue: true,
187
+ default: status?.sharpness ?? options.sharpness.min
188
+ });
189
+ if (options.supportsMirror) fields.push({
190
+ type: "boolean",
191
+ key: "imageSettings_mirror",
192
+ label: "Mirror",
193
+ style: "switch",
194
+ default: status?.mirror ?? false
195
+ });
196
+ if (options.supportsFlip) fields.push({
197
+ type: "boolean",
198
+ key: "imageSettings_flip",
199
+ label: "Flip",
200
+ style: "switch",
201
+ default: status?.flip ?? false
202
+ });
203
+ if (options.rotateOptions.length > 0) fields.push({
204
+ type: "select",
205
+ key: "imageSettings_rotate",
206
+ label: "Rotation",
207
+ options: options.rotateOptions.map((r) => ({
208
+ value: r,
209
+ label: ROTATE_LABELS[r]
210
+ })),
211
+ default: status?.rotate ?? options.rotateOptions[0]
212
+ });
213
+ if (options.whiteBalanceModes.length > 0) fields.push({
214
+ type: "select",
215
+ key: "imageSettings_whiteBalance",
216
+ label: "White balance",
217
+ options: options.whiteBalanceModes.map((m) => ({
218
+ value: m,
219
+ label: WHITE_BALANCE_LABELS[m]
220
+ })),
221
+ default: status?.whiteBalance ?? options.whiteBalanceModes[0]
222
+ });
223
+ if (options.supportsWarmth && options.warmth) fields.push({
224
+ type: "slider",
225
+ key: "imageSettings_warmth",
226
+ label: "Warmth",
227
+ min: options.warmth.min,
228
+ max: options.warmth.max,
229
+ step: options.warmth.step,
230
+ showValue: true,
231
+ default: status?.warmth ?? options.warmth.min,
232
+ showWhen: {
233
+ field: "imageSettings_whiteBalance",
234
+ equals: "manual"
235
+ }
236
+ });
237
+ if (options.exposureModes.length > 0) fields.push({
238
+ type: "select",
239
+ key: "imageSettings_exposureMode",
240
+ label: "Exposure mode",
241
+ options: options.exposureModes.map((m) => ({
242
+ value: m,
243
+ label: EXPOSURE_LABELS[m]
244
+ })),
245
+ default: status?.exposureMode ?? options.exposureModes[0]
246
+ });
247
+ if (options.backlightModes.length > 0) fields.push({
248
+ type: "select",
249
+ key: "imageSettings_backlightMode",
250
+ label: "Backlight compensation",
251
+ options: options.backlightModes.map((m) => ({
252
+ value: m,
253
+ label: BACKLIGHT_LABELS[m]
254
+ })),
255
+ default: status?.backlightMode ?? options.backlightModes[0]
256
+ });
257
+ if (fields.length === 0) return null;
258
+ return { sections: [{
259
+ id: "image-settings",
260
+ tab: "image",
261
+ title: "Image adjustment",
262
+ description: "Picture sliders, orientation and exposure. Option lists are read live from the camera — fields the firmware doesn't expose are hidden.",
263
+ columns: 2,
264
+ fields
265
+ }] };
266
+ }
267
+ /**
268
+ * Re-parse a flat `ConfigFormBuilder` patch into an `ImageSettingsPatch`.
269
+ * Returns an empty object when no `imageSettings_*` field changed.
270
+ */
271
+ function parseImageSettingsFormPatch(patch) {
272
+ const out = {};
273
+ if ("imageSettings_brightness" in patch) {
274
+ const value = Number(patch.imageSettings_brightness);
275
+ if (Number.isFinite(value)) out.brightness = value;
276
+ }
277
+ if ("imageSettings_contrast" in patch) {
278
+ const value = Number(patch.imageSettings_contrast);
279
+ if (Number.isFinite(value)) out.contrast = value;
280
+ }
281
+ if ("imageSettings_saturation" in patch) {
282
+ const value = Number(patch.imageSettings_saturation);
283
+ if (Number.isFinite(value)) out.saturation = value;
284
+ }
285
+ if ("imageSettings_sharpness" in patch) {
286
+ const value = Number(patch.imageSettings_sharpness);
287
+ if (Number.isFinite(value)) out.sharpness = value;
288
+ }
289
+ if ("imageSettings_mirror" in patch && typeof patch.imageSettings_mirror === "boolean") out.mirror = patch.imageSettings_mirror;
290
+ if ("imageSettings_flip" in patch && typeof patch.imageSettings_flip === "boolean") out.flip = patch.imageSettings_flip;
291
+ if ("imageSettings_rotate" in patch && isImageRotate(patch.imageSettings_rotate)) out.rotate = patch.imageSettings_rotate;
292
+ if ("imageSettings_whiteBalance" in patch && isWhiteBalanceMode(patch.imageSettings_whiteBalance)) out.whiteBalance = patch.imageSettings_whiteBalance;
293
+ if ("imageSettings_warmth" in patch) {
294
+ const value = Number(patch.imageSettings_warmth);
295
+ if (Number.isFinite(value)) out.warmth = value;
296
+ }
297
+ if ("imageSettings_exposureMode" in patch && isExposureMode(patch.imageSettings_exposureMode)) out.exposureMode = patch.imageSettings_exposureMode;
298
+ if ("imageSettings_backlightMode" in patch && isBacklightMode(patch.imageSettings_backlightMode)) out.backlightMode = patch.imageSettings_backlightMode;
299
+ return out;
300
+ }
301
+ //#endregion
302
+ //#region src/builtins/device-manager/device-config-contribution.ts
303
+ /**
304
+ * D14 device-config archetype — framework-side contribution derivation.
305
+ *
306
+ * A `deviceConfig` cap with `ui.kind: 'derived-form'` names a `builderId`.
307
+ * This module owns the registry of `builderId → reducer` pure functions
308
+ * that build the UI section from the cap's `getOptions` + `getStatus`
309
+ * output and route a flat form patch back through the cap's `set*`
310
+ * mutation. No per-vendor UI code: reolink and hikvision produce the same
311
+ * section from the same inputs.
312
+ *
313
+ * Two reducer shapes exist, one per save-path surface a `derived-form` cap
314
+ * can expose:
315
+ * - `kind: 'profile'` — the cap mutates one of several named profiles
316
+ * via `setProfile({ deviceId, profile, patch })` (`stream-params`).
317
+ * - `kind: 'settings'` — the cap has no profile axis and mutates via a
318
+ * single `setSettings({ deviceId, settings })` (`day-night`,
319
+ * `image-settings`).
320
+ *
321
+ * `device-aggregation.ts` picks which apply function to call by
322
+ * inspecting the bound provider's actual method surface (`setProfile` vs
323
+ * `setSettings`), never by branching on the cap name.
324
+ */
325
+ /** Registered builderIds today. New device-config caps add their own reducer below. */
326
+ var STREAM_PARAMS_BUILDER_ID = "stream-params";
327
+ var DAY_NIGHT_BUILDER_ID = "day-night";
328
+ var IMAGE_SETTINGS_BUILDER_ID = "image-settings";
329
+ var STREAM_PARAMS_REDUCER = {
330
+ kind: "profile",
331
+ buildSchema: (options, status) => (0, _camstack_types.buildStreamParamsConfigSchema)(options, status ?? null),
332
+ applyPatch: async (patch, setProfile) => {
333
+ for (const meta of _camstack_types.STREAM_PROFILE_META) {
334
+ const profilePatch = (0, _camstack_types.parseStreamParamsFormPatch)(patch, meta.prefix);
335
+ if (profilePatch) await setProfile(meta.profile, profilePatch);
336
+ }
337
+ }
338
+ };
339
+ var DAY_NIGHT_REDUCER = {
340
+ kind: "settings",
341
+ buildSchema: (options, status) => buildDayNightConfigSchema(options, status ?? null),
342
+ parsePatch: (patch) => parseDayNightFormPatch(patch)
343
+ };
344
+ var IMAGE_SETTINGS_REDUCER = {
345
+ kind: "settings",
346
+ buildSchema: (options, status) => buildImageSettingsConfigSchema(options, status ?? null),
347
+ parsePatch: (patch) => parseImageSettingsFormPatch(patch)
348
+ };
349
+ var BUILDER_REDUCERS = {
350
+ [STREAM_PARAMS_BUILDER_ID]: STREAM_PARAMS_REDUCER,
351
+ [DAY_NIGHT_BUILDER_ID]: DAY_NIGHT_REDUCER,
352
+ [IMAGE_SETTINGS_BUILDER_ID]: IMAGE_SETTINGS_REDUCER
353
+ };
354
+ function resolveReducer(builderId) {
355
+ const reducer = BUILDER_REDUCERS[builderId];
356
+ if (!reducer) throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
357
+ return reducer;
358
+ }
10
359
  /**
11
360
  * Build the device-detail form section for a `derived-form` device-config
12
361
  * cap. Returns null when the camera exposes no configurable property.
13
362
  */
14
363
  function deriveFormContribution(builderId, options, status) {
15
- if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
16
- const schema = (0, _camstack_types.buildStreamParamsConfigSchema)(options, status ?? null);
364
+ const schema = resolveReducer(builderId).buildSchema(options, status);
17
365
  if (!schema) return null;
18
366
  return { sections: schema.sections.map((s) => ({
19
367
  id: s.id,
@@ -26,14 +374,26 @@ function deriveFormContribution(builderId, options, status) {
26
374
  })) };
27
375
  }
28
376
  /**
29
- * Route a flat form patch back through the cap's per-profile setter.
377
+ * Route a flat form patch back through a profile-based cap's per-profile
378
+ * `setProfile` mutation (`stream-params`). Throws if `builderId` isn't
379
+ * registered as a profile-based reducer — callers dispatch here only
380
+ * after confirming the bound provider exposes `setProfile`.
30
381
  */
31
- async function applyDerivedFormPatch(builderId, patch, setProfile) {
32
- if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
33
- for (const meta of _camstack_types.STREAM_PROFILE_META) {
34
- const profilePatch = (0, _camstack_types.parseStreamParamsFormPatch)(patch, meta.prefix);
35
- if (profilePatch) await setProfile(meta.profile, profilePatch);
36
- }
382
+ async function applyDerivedFormProfilePatch(builderId, patch, setProfile) {
383
+ const reducer = resolveReducer(builderId);
384
+ if (reducer.kind !== "profile") throw new Error(`device-config: builderId "${builderId}" is not a profile-based derived-form`);
385
+ await reducer.applyPatch(patch, setProfile);
386
+ }
387
+ /**
388
+ * Parse a flat form patch into the settings payload for a `setSettings`-
389
+ * based cap (`day-night`, `image-settings`). The caller forwards the
390
+ * result verbatim to `dcProvider.setSettings({ deviceId, settings })`.
391
+ * Throws if `builderId` isn't registered as a settings-based reducer.
392
+ */
393
+ function parseDerivedFormSettingsPatch(builderId, patch) {
394
+ const reducer = resolveReducer(builderId);
395
+ if (reducer.kind !== "settings") throw new Error(`device-config: builderId "${builderId}" is not a settings-based derived-form`);
396
+ return reducer.parsePatch(patch);
37
397
  }
38
398
  //#endregion
39
399
  //#region src/builtins/device-manager/device-aggregation-merge.ts
@@ -985,6 +1345,33 @@ async function resolveDriverConfigSchema(deps, deviceId, hubLocal) {
985
1345
  });
986
1346
  return { status: "unavailable" };
987
1347
  }
1348
+ /**
1349
+ * Route a `derived-form` device-config cap's flat form patch to whichever
1350
+ * mutation the bound provider actually exposes — inspecting the
1351
+ * provider's method surface, not the cap name. `setSettings` fires once
1352
+ * with the parsed flat patch; `setProfile` fans the patch out per-profile
1353
+ * via the registered builderId reducer.
1354
+ */
1355
+ async function dispatchDerivedFormSave(builderId, deviceId, patch, dcProvider, capName) {
1356
+ if (typeof dcProvider.setSettings === "function") {
1357
+ const settings = parseDerivedFormSettingsPatch(builderId, patch);
1358
+ await dcProvider.setSettings({
1359
+ deviceId,
1360
+ settings
1361
+ });
1362
+ return;
1363
+ }
1364
+ if (typeof dcProvider.setProfile === "function") {
1365
+ const setProfile = dcProvider.setProfile;
1366
+ await applyDerivedFormProfilePatch(builderId, patch, (profile, profilePatch) => setProfile({
1367
+ deviceId,
1368
+ profile,
1369
+ patch: profilePatch
1370
+ }));
1371
+ return;
1372
+ }
1373
+ throw new Error(`[device-manager] device-config cap "${capName}" provider exposes neither setSettings nor setProfile`);
1374
+ }
988
1375
  async function updateDeviceField(deps, input) {
989
1376
  if (input.writerCapName === "device-manager") {
990
1377
  const hubRegistry = deps.ctx.kernel?.deviceRegistry;
@@ -1008,11 +1395,7 @@ async function updateDeviceField(deps, input) {
1008
1395
  if (def.deviceConfig.ui.kind === "widget") return { success: true };
1009
1396
  const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
1010
1397
  if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
1011
- await applyDerivedFormPatch(def.deviceConfig.ui.builderId, { [input.key]: input.value }, (profile, patch) => dcProvider.setProfile({
1012
- deviceId: input.deviceId,
1013
- profile,
1014
- patch
1015
- }));
1398
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, input.deviceId, { [input.key]: input.value }, dcProvider, input.writerCapName);
1016
1399
  return { success: true };
1017
1400
  }
1018
1401
  if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
@@ -1131,11 +1514,7 @@ async function applyGroupPatch(deps, deviceId, group) {
1131
1514
  if (def.deviceConfig.ui.kind === "widget") return;
1132
1515
  const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
1133
1516
  if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
1134
- await applyDerivedFormPatch(def.deviceConfig.ui.builderId, group.patch, (profile, patch) => dcProvider.setProfile({
1135
- deviceId,
1136
- profile,
1137
- patch
1138
- }));
1517
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, deviceId, group.patch, dcProvider, group.writerCapName);
1139
1518
  return;
1140
1519
  }
1141
1520
  if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
@@ -2,13 +2,361 @@ import { canonicalDeviceFingerprint } from "@camstack/types/node";
2
2
  import { ALL_CAPABILITY_DEFINITIONS, BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceRole, DeviceStatusSchema, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, applyTransform, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, deviceStatusCapability, enumerateItemArrayFields, enumerateSchemaFields, errMsg, evaluateLinkExpression, getByPath, isDeviceConfigCap, normalizeUnit, parseStreamParamsFormPatch, setByPath, sleep, toExpressionValue, validateExpressionSource } from "@camstack/types";
3
3
  import { randomUUID } from "node:crypto";
4
4
  import { z } from "zod";
5
+ //#region src/builtins/device-manager/day-night-config-schema.ts
6
+ var MODE_LABELS = {
7
+ auto: "Auto",
8
+ day: "Day",
9
+ night: "Night",
10
+ schedule: "Schedule"
11
+ };
12
+ /**
13
+ * Build the `day-night` `ConfigUISchema` from the camera-probed options +
14
+ * current status. Returns `null` when the camera exposes no configurable
15
+ * property — the cap then reports "no schema" and the renderer shows the
16
+ * unsupported-camera message.
17
+ */
18
+ function buildDayNightConfigSchema(options, status) {
19
+ const fields = [];
20
+ if (options.modes.length > 0) fields.push({
21
+ type: "select",
22
+ key: "dayNight_mode",
23
+ label: "Mode",
24
+ options: options.modes.map((m) => ({
25
+ value: m,
26
+ label: MODE_LABELS[m]
27
+ })),
28
+ default: status?.mode ?? options.modes[0]
29
+ });
30
+ if (options.supportsSensitivity && options.sensitivity) fields.push({
31
+ type: "slider",
32
+ key: "dayNight_sensitivity",
33
+ label: "IR-cut sensitivity",
34
+ min: options.sensitivity.min,
35
+ max: options.sensitivity.max,
36
+ step: options.sensitivity.step,
37
+ showValue: true,
38
+ default: status?.sensitivity ?? options.sensitivity.min
39
+ });
40
+ if (options.supportsSwitchDelay && options.switchDelaySec) fields.push({
41
+ type: "number",
42
+ key: "dayNight_switchDelaySec",
43
+ label: "Switch delay",
44
+ unit: "s",
45
+ min: options.switchDelaySec.min,
46
+ max: options.switchDelaySec.max,
47
+ step: options.switchDelaySec.step,
48
+ default: status?.switchDelaySec ?? options.switchDelaySec.min
49
+ });
50
+ if (fields.length === 0) return null;
51
+ return { sections: [{
52
+ id: "day-night",
53
+ tab: "image",
54
+ title: "Day / Night",
55
+ description: "IR-cut switching mode and the photocell knobs that gate it.",
56
+ columns: 2,
57
+ fields
58
+ }] };
59
+ }
60
+ var DAY_NIGHT_MODES = new Set([
61
+ "auto",
62
+ "day",
63
+ "night",
64
+ "schedule"
65
+ ]);
66
+ function isDayNightMode(value) {
67
+ return typeof value === "string" && DAY_NIGHT_MODES.has(value);
68
+ }
69
+ /**
70
+ * Re-parse a flat `ConfigFormBuilder` patch into a `DayNightSettingsPatch`.
71
+ * Returns an empty object when no `dayNight_*` field changed — the caller
72
+ * (`parseDerivedFormSettingsPatch`) forwards the result verbatim to the
73
+ * cap's `setSettings` mutation, which itself ignores fields it doesn't
74
+ * support.
75
+ */
76
+ function parseDayNightFormPatch(patch) {
77
+ const out = {};
78
+ if ("dayNight_mode" in patch && isDayNightMode(patch.dayNight_mode)) out.mode = patch.dayNight_mode;
79
+ if ("dayNight_sensitivity" in patch) {
80
+ const value = Number(patch.dayNight_sensitivity);
81
+ if (Number.isFinite(value)) out.sensitivity = value;
82
+ }
83
+ if ("dayNight_switchDelaySec" in patch) {
84
+ const value = Number(patch.dayNight_switchDelaySec);
85
+ if (Number.isFinite(value)) out.switchDelaySec = value;
86
+ }
87
+ return out;
88
+ }
89
+ //#endregion
90
+ //#region src/builtins/device-manager/image-settings-config-schema.ts
91
+ var ROTATE_LABELS = {
92
+ "0": "0°",
93
+ "90": "90°",
94
+ "180": "180°",
95
+ "270": "270°"
96
+ };
97
+ var WHITE_BALANCE_LABELS = {
98
+ auto: "Auto",
99
+ manual: "Manual"
100
+ };
101
+ var EXPOSURE_LABELS = {
102
+ auto: "Auto",
103
+ manual: "Manual"
104
+ };
105
+ var BACKLIGHT_LABELS = {
106
+ off: "Off",
107
+ blc: "Backlight compensation",
108
+ wdr: "Wide dynamic range",
109
+ hlc: "Highlight compensation"
110
+ };
111
+ var ROTATE_VALUES = new Set([
112
+ "0",
113
+ "90",
114
+ "180",
115
+ "270"
116
+ ]);
117
+ var WHITE_BALANCE_VALUES = new Set(["auto", "manual"]);
118
+ var EXPOSURE_VALUES = new Set(["auto", "manual"]);
119
+ var BACKLIGHT_VALUES = new Set([
120
+ "off",
121
+ "blc",
122
+ "wdr",
123
+ "hlc"
124
+ ]);
125
+ function isImageRotate(value) {
126
+ return typeof value === "string" && ROTATE_VALUES.has(value);
127
+ }
128
+ function isWhiteBalanceMode(value) {
129
+ return typeof value === "string" && WHITE_BALANCE_VALUES.has(value);
130
+ }
131
+ function isExposureMode(value) {
132
+ return typeof value === "string" && EXPOSURE_VALUES.has(value);
133
+ }
134
+ function isBacklightMode(value) {
135
+ return typeof value === "string" && BACKLIGHT_VALUES.has(value);
136
+ }
137
+ /**
138
+ * Build the `image-settings` `ConfigUISchema` from the camera-probed
139
+ * options + current status. Returns `null` when the camera exposes no
140
+ * configurable property.
141
+ */
142
+ function buildImageSettingsConfigSchema(options, status) {
143
+ const fields = [];
144
+ if (options.supportsBrightness && options.brightness) fields.push({
145
+ type: "slider",
146
+ key: "imageSettings_brightness",
147
+ label: "Brightness",
148
+ min: options.brightness.min,
149
+ max: options.brightness.max,
150
+ step: options.brightness.step,
151
+ showValue: true,
152
+ default: status?.brightness ?? options.brightness.min
153
+ });
154
+ if (options.supportsContrast && options.contrast) fields.push({
155
+ type: "slider",
156
+ key: "imageSettings_contrast",
157
+ label: "Contrast",
158
+ min: options.contrast.min,
159
+ max: options.contrast.max,
160
+ step: options.contrast.step,
161
+ showValue: true,
162
+ default: status?.contrast ?? options.contrast.min
163
+ });
164
+ if (options.supportsSaturation && options.saturation) fields.push({
165
+ type: "slider",
166
+ key: "imageSettings_saturation",
167
+ label: "Saturation",
168
+ min: options.saturation.min,
169
+ max: options.saturation.max,
170
+ step: options.saturation.step,
171
+ showValue: true,
172
+ default: status?.saturation ?? options.saturation.min
173
+ });
174
+ if (options.supportsSharpness && options.sharpness) fields.push({
175
+ type: "slider",
176
+ key: "imageSettings_sharpness",
177
+ label: "Sharpness",
178
+ min: options.sharpness.min,
179
+ max: options.sharpness.max,
180
+ step: options.sharpness.step,
181
+ showValue: true,
182
+ default: status?.sharpness ?? options.sharpness.min
183
+ });
184
+ if (options.supportsMirror) fields.push({
185
+ type: "boolean",
186
+ key: "imageSettings_mirror",
187
+ label: "Mirror",
188
+ style: "switch",
189
+ default: status?.mirror ?? false
190
+ });
191
+ if (options.supportsFlip) fields.push({
192
+ type: "boolean",
193
+ key: "imageSettings_flip",
194
+ label: "Flip",
195
+ style: "switch",
196
+ default: status?.flip ?? false
197
+ });
198
+ if (options.rotateOptions.length > 0) fields.push({
199
+ type: "select",
200
+ key: "imageSettings_rotate",
201
+ label: "Rotation",
202
+ options: options.rotateOptions.map((r) => ({
203
+ value: r,
204
+ label: ROTATE_LABELS[r]
205
+ })),
206
+ default: status?.rotate ?? options.rotateOptions[0]
207
+ });
208
+ if (options.whiteBalanceModes.length > 0) fields.push({
209
+ type: "select",
210
+ key: "imageSettings_whiteBalance",
211
+ label: "White balance",
212
+ options: options.whiteBalanceModes.map((m) => ({
213
+ value: m,
214
+ label: WHITE_BALANCE_LABELS[m]
215
+ })),
216
+ default: status?.whiteBalance ?? options.whiteBalanceModes[0]
217
+ });
218
+ if (options.supportsWarmth && options.warmth) fields.push({
219
+ type: "slider",
220
+ key: "imageSettings_warmth",
221
+ label: "Warmth",
222
+ min: options.warmth.min,
223
+ max: options.warmth.max,
224
+ step: options.warmth.step,
225
+ showValue: true,
226
+ default: status?.warmth ?? options.warmth.min,
227
+ showWhen: {
228
+ field: "imageSettings_whiteBalance",
229
+ equals: "manual"
230
+ }
231
+ });
232
+ if (options.exposureModes.length > 0) fields.push({
233
+ type: "select",
234
+ key: "imageSettings_exposureMode",
235
+ label: "Exposure mode",
236
+ options: options.exposureModes.map((m) => ({
237
+ value: m,
238
+ label: EXPOSURE_LABELS[m]
239
+ })),
240
+ default: status?.exposureMode ?? options.exposureModes[0]
241
+ });
242
+ if (options.backlightModes.length > 0) fields.push({
243
+ type: "select",
244
+ key: "imageSettings_backlightMode",
245
+ label: "Backlight compensation",
246
+ options: options.backlightModes.map((m) => ({
247
+ value: m,
248
+ label: BACKLIGHT_LABELS[m]
249
+ })),
250
+ default: status?.backlightMode ?? options.backlightModes[0]
251
+ });
252
+ if (fields.length === 0) return null;
253
+ return { sections: [{
254
+ id: "image-settings",
255
+ tab: "image",
256
+ title: "Image adjustment",
257
+ description: "Picture sliders, orientation and exposure. Option lists are read live from the camera — fields the firmware doesn't expose are hidden.",
258
+ columns: 2,
259
+ fields
260
+ }] };
261
+ }
262
+ /**
263
+ * Re-parse a flat `ConfigFormBuilder` patch into an `ImageSettingsPatch`.
264
+ * Returns an empty object when no `imageSettings_*` field changed.
265
+ */
266
+ function parseImageSettingsFormPatch(patch) {
267
+ const out = {};
268
+ if ("imageSettings_brightness" in patch) {
269
+ const value = Number(patch.imageSettings_brightness);
270
+ if (Number.isFinite(value)) out.brightness = value;
271
+ }
272
+ if ("imageSettings_contrast" in patch) {
273
+ const value = Number(patch.imageSettings_contrast);
274
+ if (Number.isFinite(value)) out.contrast = value;
275
+ }
276
+ if ("imageSettings_saturation" in patch) {
277
+ const value = Number(patch.imageSettings_saturation);
278
+ if (Number.isFinite(value)) out.saturation = value;
279
+ }
280
+ if ("imageSettings_sharpness" in patch) {
281
+ const value = Number(patch.imageSettings_sharpness);
282
+ if (Number.isFinite(value)) out.sharpness = value;
283
+ }
284
+ if ("imageSettings_mirror" in patch && typeof patch.imageSettings_mirror === "boolean") out.mirror = patch.imageSettings_mirror;
285
+ if ("imageSettings_flip" in patch && typeof patch.imageSettings_flip === "boolean") out.flip = patch.imageSettings_flip;
286
+ if ("imageSettings_rotate" in patch && isImageRotate(patch.imageSettings_rotate)) out.rotate = patch.imageSettings_rotate;
287
+ if ("imageSettings_whiteBalance" in patch && isWhiteBalanceMode(patch.imageSettings_whiteBalance)) out.whiteBalance = patch.imageSettings_whiteBalance;
288
+ if ("imageSettings_warmth" in patch) {
289
+ const value = Number(patch.imageSettings_warmth);
290
+ if (Number.isFinite(value)) out.warmth = value;
291
+ }
292
+ if ("imageSettings_exposureMode" in patch && isExposureMode(patch.imageSettings_exposureMode)) out.exposureMode = patch.imageSettings_exposureMode;
293
+ if ("imageSettings_backlightMode" in patch && isBacklightMode(patch.imageSettings_backlightMode)) out.backlightMode = patch.imageSettings_backlightMode;
294
+ return out;
295
+ }
296
+ //#endregion
297
+ //#region src/builtins/device-manager/device-config-contribution.ts
298
+ /**
299
+ * D14 device-config archetype — framework-side contribution derivation.
300
+ *
301
+ * A `deviceConfig` cap with `ui.kind: 'derived-form'` names a `builderId`.
302
+ * This module owns the registry of `builderId → reducer` pure functions
303
+ * that build the UI section from the cap's `getOptions` + `getStatus`
304
+ * output and route a flat form patch back through the cap's `set*`
305
+ * mutation. No per-vendor UI code: reolink and hikvision produce the same
306
+ * section from the same inputs.
307
+ *
308
+ * Two reducer shapes exist, one per save-path surface a `derived-form` cap
309
+ * can expose:
310
+ * - `kind: 'profile'` — the cap mutates one of several named profiles
311
+ * via `setProfile({ deviceId, profile, patch })` (`stream-params`).
312
+ * - `kind: 'settings'` — the cap has no profile axis and mutates via a
313
+ * single `setSettings({ deviceId, settings })` (`day-night`,
314
+ * `image-settings`).
315
+ *
316
+ * `device-aggregation.ts` picks which apply function to call by
317
+ * inspecting the bound provider's actual method surface (`setProfile` vs
318
+ * `setSettings`), never by branching on the cap name.
319
+ */
320
+ /** Registered builderIds today. New device-config caps add their own reducer below. */
321
+ var STREAM_PARAMS_BUILDER_ID = "stream-params";
322
+ var DAY_NIGHT_BUILDER_ID = "day-night";
323
+ var IMAGE_SETTINGS_BUILDER_ID = "image-settings";
324
+ var STREAM_PARAMS_REDUCER = {
325
+ kind: "profile",
326
+ buildSchema: (options, status) => buildStreamParamsConfigSchema(options, status ?? null),
327
+ applyPatch: async (patch, setProfile) => {
328
+ for (const meta of STREAM_PROFILE_META) {
329
+ const profilePatch = parseStreamParamsFormPatch(patch, meta.prefix);
330
+ if (profilePatch) await setProfile(meta.profile, profilePatch);
331
+ }
332
+ }
333
+ };
334
+ var DAY_NIGHT_REDUCER = {
335
+ kind: "settings",
336
+ buildSchema: (options, status) => buildDayNightConfigSchema(options, status ?? null),
337
+ parsePatch: (patch) => parseDayNightFormPatch(patch)
338
+ };
339
+ var IMAGE_SETTINGS_REDUCER = {
340
+ kind: "settings",
341
+ buildSchema: (options, status) => buildImageSettingsConfigSchema(options, status ?? null),
342
+ parsePatch: (patch) => parseImageSettingsFormPatch(patch)
343
+ };
344
+ var BUILDER_REDUCERS = {
345
+ [STREAM_PARAMS_BUILDER_ID]: STREAM_PARAMS_REDUCER,
346
+ [DAY_NIGHT_BUILDER_ID]: DAY_NIGHT_REDUCER,
347
+ [IMAGE_SETTINGS_BUILDER_ID]: IMAGE_SETTINGS_REDUCER
348
+ };
349
+ function resolveReducer(builderId) {
350
+ const reducer = BUILDER_REDUCERS[builderId];
351
+ if (!reducer) throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
352
+ return reducer;
353
+ }
5
354
  /**
6
355
  * Build the device-detail form section for a `derived-form` device-config
7
356
  * cap. Returns null when the camera exposes no configurable property.
8
357
  */
9
358
  function deriveFormContribution(builderId, options, status) {
10
- if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
11
- const schema = buildStreamParamsConfigSchema(options, status ?? null);
359
+ const schema = resolveReducer(builderId).buildSchema(options, status);
12
360
  if (!schema) return null;
13
361
  return { sections: schema.sections.map((s) => ({
14
362
  id: s.id,
@@ -21,14 +369,26 @@ function deriveFormContribution(builderId, options, status) {
21
369
  })) };
22
370
  }
23
371
  /**
24
- * Route a flat form patch back through the cap's per-profile setter.
372
+ * Route a flat form patch back through a profile-based cap's per-profile
373
+ * `setProfile` mutation (`stream-params`). Throws if `builderId` isn't
374
+ * registered as a profile-based reducer — callers dispatch here only
375
+ * after confirming the bound provider exposes `setProfile`.
25
376
  */
26
- async function applyDerivedFormPatch(builderId, patch, setProfile) {
27
- if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
28
- for (const meta of STREAM_PROFILE_META) {
29
- const profilePatch = parseStreamParamsFormPatch(patch, meta.prefix);
30
- if (profilePatch) await setProfile(meta.profile, profilePatch);
31
- }
377
+ async function applyDerivedFormProfilePatch(builderId, patch, setProfile) {
378
+ const reducer = resolveReducer(builderId);
379
+ if (reducer.kind !== "profile") throw new Error(`device-config: builderId "${builderId}" is not a profile-based derived-form`);
380
+ await reducer.applyPatch(patch, setProfile);
381
+ }
382
+ /**
383
+ * Parse a flat form patch into the settings payload for a `setSettings`-
384
+ * based cap (`day-night`, `image-settings`). The caller forwards the
385
+ * result verbatim to `dcProvider.setSettings({ deviceId, settings })`.
386
+ * Throws if `builderId` isn't registered as a settings-based reducer.
387
+ */
388
+ function parseDerivedFormSettingsPatch(builderId, patch) {
389
+ const reducer = resolveReducer(builderId);
390
+ if (reducer.kind !== "settings") throw new Error(`device-config: builderId "${builderId}" is not a settings-based derived-form`);
391
+ return reducer.parsePatch(patch);
32
392
  }
33
393
  //#endregion
34
394
  //#region src/builtins/device-manager/device-aggregation-merge.ts
@@ -980,6 +1340,33 @@ async function resolveDriverConfigSchema(deps, deviceId, hubLocal) {
980
1340
  });
981
1341
  return { status: "unavailable" };
982
1342
  }
1343
+ /**
1344
+ * Route a `derived-form` device-config cap's flat form patch to whichever
1345
+ * mutation the bound provider actually exposes — inspecting the
1346
+ * provider's method surface, not the cap name. `setSettings` fires once
1347
+ * with the parsed flat patch; `setProfile` fans the patch out per-profile
1348
+ * via the registered builderId reducer.
1349
+ */
1350
+ async function dispatchDerivedFormSave(builderId, deviceId, patch, dcProvider, capName) {
1351
+ if (typeof dcProvider.setSettings === "function") {
1352
+ const settings = parseDerivedFormSettingsPatch(builderId, patch);
1353
+ await dcProvider.setSettings({
1354
+ deviceId,
1355
+ settings
1356
+ });
1357
+ return;
1358
+ }
1359
+ if (typeof dcProvider.setProfile === "function") {
1360
+ const setProfile = dcProvider.setProfile;
1361
+ await applyDerivedFormProfilePatch(builderId, patch, (profile, profilePatch) => setProfile({
1362
+ deviceId,
1363
+ profile,
1364
+ patch: profilePatch
1365
+ }));
1366
+ return;
1367
+ }
1368
+ throw new Error(`[device-manager] device-config cap "${capName}" provider exposes neither setSettings nor setProfile`);
1369
+ }
983
1370
  async function updateDeviceField(deps, input) {
984
1371
  if (input.writerCapName === "device-manager") {
985
1372
  const hubRegistry = deps.ctx.kernel?.deviceRegistry;
@@ -1003,11 +1390,7 @@ async function updateDeviceField(deps, input) {
1003
1390
  if (def.deviceConfig.ui.kind === "widget") return { success: true };
1004
1391
  const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
1005
1392
  if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
1006
- await applyDerivedFormPatch(def.deviceConfig.ui.builderId, { [input.key]: input.value }, (profile, patch) => dcProvider.setProfile({
1007
- deviceId: input.deviceId,
1008
- profile,
1009
- patch
1010
- }));
1393
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, input.deviceId, { [input.key]: input.value }, dcProvider, input.writerCapName);
1011
1394
  return { success: true };
1012
1395
  }
1013
1396
  if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
@@ -1126,11 +1509,7 @@ async function applyGroupPatch(deps, deviceId, group) {
1126
1509
  if (def.deviceConfig.ui.kind === "widget") return;
1127
1510
  const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
1128
1511
  if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
1129
- await applyDerivedFormPatch(def.deviceConfig.ui.builderId, group.patch, (profile, patch) => dcProvider.setProfile({
1130
- deviceId,
1131
- profile,
1132
- patch
1133
- }));
1512
+ await dispatchDerivedFormSave(def.deviceConfig.ui.builderId, deviceId, group.patch, dcProvider, group.writerCapName);
1134
1513
  return;
1135
1514
  }
1136
1515
  if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
@@ -0,0 +1,12 @@
1
+ import { ConfigUISchema, ImageSettingsOptions, ImageSettingsPatch, ImageSettingsStatus } from '@camstack/types';
2
+ /**
3
+ * Build the `image-settings` `ConfigUISchema` from the camera-probed
4
+ * options + current status. Returns `null` when the camera exposes no
5
+ * configurable property.
6
+ */
7
+ export declare function buildImageSettingsConfigSchema(options: ImageSettingsOptions, status: ImageSettingsStatus | null): ConfigUISchema | null;
8
+ /**
9
+ * Re-parse a flat `ConfigFormBuilder` patch into an `ImageSettingsPatch`.
10
+ * Returns an empty object when no `imageSettings_*` field changed.
11
+ */
12
+ export declare function parseImageSettingsFormPatch(patch: Record<string, unknown>): ImageSettingsPatch;
@@ -10,6 +10,7 @@ export default class NativeMetricsAddon extends BaseAddon<NativeMetricsConfig> {
10
10
  private provider;
11
11
  private startedAtMs;
12
12
  private snapshotTimer;
13
+ private processSnapshotTimer;
13
14
  /**
14
15
  * Snapshot-equality cache for the resources + processes emit.
15
16
  * Stores the coarsened JSON and timestamp; a tick where the
@@ -22,11 +23,23 @@ export default class NativeMetricsAddon extends BaseAddon<NativeMetricsConfig> {
22
23
  protected onInitialize(): Promise<ProviderRegistration[]>;
23
24
  protected onShutdown(): Promise<void>;
24
25
  /**
25
- * Emit one `metrics.node-resources-snapshot` + one
26
- * `metrics.node-processes-snapshot` for this node. UI consumers
27
- * subscribe and read state directly from the payload.
26
+ * Resolve this node's short id (strips any `nodeId/addonId` suffix).
27
+ * Returns null when the provider or event bus isn't available yet.
28
28
  */
29
- private emitMetricsSnapshots;
29
+ private resolveSnapshotNodeId;
30
+ /**
31
+ * Emit one `metrics.node-resources-snapshot` for this node. Cheap —
32
+ * reads the cached sample (the provider's background sampler already
33
+ * runs at samplingIntervalMs). Fires on METRICS_SNAPSHOT_INTERVAL_MS.
34
+ */
35
+ private emitResourcesSnapshot;
36
+ /**
37
+ * Emit one `metrics.node-processes-snapshot` for this node. Heavy —
38
+ * runs a full OS `ps -eo` scan (`runPs`) plus a `$process.list` broker
39
+ * call. Fires on the coarser PROCESS_SNAPSHOT_INTERVAL_MS so an idle
40
+ * node isn't paying a process-table walk every 5s. Skip on failure.
41
+ */
42
+ private emitProcessesSnapshot;
30
43
  protected onConfigChanged(): Promise<void>;
31
44
  private listWorkerInstances;
32
45
  private listAddonInstances;
@@ -504,6 +504,19 @@ var SUPERVISOR_BOUNDARY_RE = /(tsx\s+watch\s.*launcher\.ts|packages\/agent\/dist
504
504
  */
505
505
  var METRICS_SNAPSHOT_INTERVAL_MS = 5e3;
506
506
  /**
507
+ * Cadence for the per-node PROCESS-TREE snapshot bus events. Kept
508
+ * deliberately coarser than the resources cadence because each tick
509
+ * runs a full OS `ps -eo` scan (`runPs`) plus a `$process.list` broker
510
+ * call — heavy work that was previously paid every 5s regardless of
511
+ * whether any UI consumer was subscribed to
512
+ * `MetricsNodeProcessesSnapshot`. The event bus exposes no per-category
513
+ * subscriber count, so instead of demand-gating we split the cadence:
514
+ * 20s cuts the `ps` frequency 4x while the ProcessesTab still gets a
515
+ * fresh-on-open payload from the on-demand `listNodeProcesses` cap call.
516
+ * The dedup + 60s heartbeat below still apply on top of this.
517
+ */
518
+ var PROCESS_SNAPSHOT_INTERVAL_MS = 2e4;
519
+ /**
507
520
  * Force a metrics-snapshot emit at least every 60s even when the
508
521
  * coarsened payload looks unchanged. Without this an idle node
509
522
  * (steady CPU within ±5%, no process churn) goes silent on the
@@ -569,6 +582,7 @@ var NativeMetricsAddon = class extends _camstack_types.BaseAddon {
569
582
  provider = null;
570
583
  startedAtMs = Date.now();
571
584
  snapshotTimer = null;
585
+ processSnapshotTimer = null;
572
586
  /**
573
587
  * Snapshot-equality cache for the resources + processes emit.
574
588
  * Stores the coarsened JSON and timestamp; a tick where the
@@ -601,7 +615,8 @@ var NativeMetricsAddon = class extends _camstack_types.BaseAddon {
601
615
  killProcess: (params) => this.killProcess(params),
602
616
  dumpHeapSnapshot: (params) => this.dumpHeapSnapshot(params)
603
617
  };
604
- this.snapshotTimer = setInterval(() => this.emitMetricsSnapshots(), METRICS_SNAPSHOT_INTERVAL_MS);
618
+ this.snapshotTimer = setInterval(() => this.emitResourcesSnapshot(), METRICS_SNAPSHOT_INTERVAL_MS);
619
+ this.processSnapshotTimer = setInterval(() => this.emitProcessesSnapshot(), PROCESS_SNAPSHOT_INTERVAL_MS);
605
620
  return [{
606
621
  capability: _camstack_types.metricsProviderCapability,
607
622
  provider: composed
@@ -612,20 +627,32 @@ var NativeMetricsAddon = class extends _camstack_types.BaseAddon {
612
627
  clearInterval(this.snapshotTimer);
613
628
  this.snapshotTimer = null;
614
629
  }
630
+ if (this.processSnapshotTimer) {
631
+ clearInterval(this.processSnapshotTimer);
632
+ this.processSnapshotTimer = null;
633
+ }
615
634
  this.provider?.stopSampling();
616
635
  this.provider = null;
617
636
  }
618
637
  /**
619
- * Emit one `metrics.node-resources-snapshot` + one
620
- * `metrics.node-processes-snapshot` for this node. UI consumers
621
- * subscribe and read state directly from the payload.
638
+ * Resolve this node's short id (strips any `nodeId/addonId` suffix).
639
+ * Returns null when the provider or event bus isn't available yet.
640
+ */
641
+ resolveSnapshotNodeId() {
642
+ if (!this.provider || !this.ctx.eventBus) return null;
643
+ const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
644
+ return rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
645
+ }
646
+ /**
647
+ * Emit one `metrics.node-resources-snapshot` for this node. Cheap —
648
+ * reads the cached sample (the provider's background sampler already
649
+ * runs at samplingIntervalMs). Fires on METRICS_SNAPSHOT_INTERVAL_MS.
622
650
  */
623
- async emitMetricsSnapshots() {
651
+ async emitResourcesSnapshot() {
624
652
  const provider = this.provider;
625
653
  const eventBus = this.ctx.eventBus;
626
- if (!provider || !eventBus) return;
627
- const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
628
- const nodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
654
+ const nodeId = this.resolveSnapshotNodeId();
655
+ if (!provider || !eventBus || !nodeId) return;
629
656
  const timestamp = Date.now();
630
657
  try {
631
658
  const snapshot = await provider.getCached() ?? await provider.collectSnapshot();
@@ -648,6 +675,18 @@ var NativeMetricsAddon = class extends _camstack_types.BaseAddon {
648
675
  }));
649
676
  }
650
677
  } catch {}
678
+ }
679
+ /**
680
+ * Emit one `metrics.node-processes-snapshot` for this node. Heavy —
681
+ * runs a full OS `ps -eo` scan (`runPs`) plus a `$process.list` broker
682
+ * call. Fires on the coarser PROCESS_SNAPSHOT_INTERVAL_MS so an idle
683
+ * node isn't paying a process-table walk every 5s. Skip on failure.
684
+ */
685
+ async emitProcessesSnapshot() {
686
+ const eventBus = this.ctx.eventBus;
687
+ const nodeId = this.resolveSnapshotNodeId();
688
+ if (!this.provider || !eventBus || !nodeId) return;
689
+ const timestamp = Date.now();
651
690
  try {
652
691
  const processes = await this.listNodeProcesses();
653
692
  const coarse = coarsenProcessList(processes);
@@ -497,6 +497,19 @@ var SUPERVISOR_BOUNDARY_RE = /(tsx\s+watch\s.*launcher\.ts|packages\/agent\/dist
497
497
  */
498
498
  var METRICS_SNAPSHOT_INTERVAL_MS = 5e3;
499
499
  /**
500
+ * Cadence for the per-node PROCESS-TREE snapshot bus events. Kept
501
+ * deliberately coarser than the resources cadence because each tick
502
+ * runs a full OS `ps -eo` scan (`runPs`) plus a `$process.list` broker
503
+ * call — heavy work that was previously paid every 5s regardless of
504
+ * whether any UI consumer was subscribed to
505
+ * `MetricsNodeProcessesSnapshot`. The event bus exposes no per-category
506
+ * subscriber count, so instead of demand-gating we split the cadence:
507
+ * 20s cuts the `ps` frequency 4x while the ProcessesTab still gets a
508
+ * fresh-on-open payload from the on-demand `listNodeProcesses` cap call.
509
+ * The dedup + 60s heartbeat below still apply on top of this.
510
+ */
511
+ var PROCESS_SNAPSHOT_INTERVAL_MS = 2e4;
512
+ /**
500
513
  * Force a metrics-snapshot emit at least every 60s even when the
501
514
  * coarsened payload looks unchanged. Without this an idle node
502
515
  * (steady CPU within ±5%, no process churn) goes silent on the
@@ -562,6 +575,7 @@ var NativeMetricsAddon = class extends BaseAddon {
562
575
  provider = null;
563
576
  startedAtMs = Date.now();
564
577
  snapshotTimer = null;
578
+ processSnapshotTimer = null;
565
579
  /**
566
580
  * Snapshot-equality cache for the resources + processes emit.
567
581
  * Stores the coarsened JSON and timestamp; a tick where the
@@ -594,7 +608,8 @@ var NativeMetricsAddon = class extends BaseAddon {
594
608
  killProcess: (params) => this.killProcess(params),
595
609
  dumpHeapSnapshot: (params) => this.dumpHeapSnapshot(params)
596
610
  };
597
- this.snapshotTimer = setInterval(() => this.emitMetricsSnapshots(), METRICS_SNAPSHOT_INTERVAL_MS);
611
+ this.snapshotTimer = setInterval(() => this.emitResourcesSnapshot(), METRICS_SNAPSHOT_INTERVAL_MS);
612
+ this.processSnapshotTimer = setInterval(() => this.emitProcessesSnapshot(), PROCESS_SNAPSHOT_INTERVAL_MS);
598
613
  return [{
599
614
  capability: metricsProviderCapability,
600
615
  provider: composed
@@ -605,20 +620,32 @@ var NativeMetricsAddon = class extends BaseAddon {
605
620
  clearInterval(this.snapshotTimer);
606
621
  this.snapshotTimer = null;
607
622
  }
623
+ if (this.processSnapshotTimer) {
624
+ clearInterval(this.processSnapshotTimer);
625
+ this.processSnapshotTimer = null;
626
+ }
608
627
  this.provider?.stopSampling();
609
628
  this.provider = null;
610
629
  }
611
630
  /**
612
- * Emit one `metrics.node-resources-snapshot` + one
613
- * `metrics.node-processes-snapshot` for this node. UI consumers
614
- * subscribe and read state directly from the payload.
631
+ * Resolve this node's short id (strips any `nodeId/addonId` suffix).
632
+ * Returns null when the provider or event bus isn't available yet.
633
+ */
634
+ resolveSnapshotNodeId() {
635
+ if (!this.provider || !this.ctx.eventBus) return null;
636
+ const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
637
+ return rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
638
+ }
639
+ /**
640
+ * Emit one `metrics.node-resources-snapshot` for this node. Cheap —
641
+ * reads the cached sample (the provider's background sampler already
642
+ * runs at samplingIntervalMs). Fires on METRICS_SNAPSHOT_INTERVAL_MS.
615
643
  */
616
- async emitMetricsSnapshots() {
644
+ async emitResourcesSnapshot() {
617
645
  const provider = this.provider;
618
646
  const eventBus = this.ctx.eventBus;
619
- if (!provider || !eventBus) return;
620
- const rawNodeId = this.ctx.kernel.localNodeId ?? this.ctx.id;
621
- const nodeId = rawNodeId.includes("/") ? rawNodeId.split("/")[0] : rawNodeId;
647
+ const nodeId = this.resolveSnapshotNodeId();
648
+ if (!provider || !eventBus || !nodeId) return;
622
649
  const timestamp = Date.now();
623
650
  try {
624
651
  const snapshot = await provider.getCached() ?? await provider.collectSnapshot();
@@ -641,6 +668,18 @@ var NativeMetricsAddon = class extends BaseAddon {
641
668
  }));
642
669
  }
643
670
  } catch {}
671
+ }
672
+ /**
673
+ * Emit one `metrics.node-processes-snapshot` for this node. Heavy —
674
+ * runs a full OS `ps -eo` scan (`runPs`) plus a `$process.list` broker
675
+ * call. Fires on the coarser PROCESS_SNAPSHOT_INTERVAL_MS so an idle
676
+ * node isn't paying a process-table walk every 5s. Skip on failure.
677
+ */
678
+ async emitProcessesSnapshot() {
679
+ const eventBus = this.ctx.eventBus;
680
+ const nodeId = this.resolveSnapshotNodeId();
681
+ if (!this.provider || !eventBus || !nodeId) return;
682
+ const timestamp = Date.now();
644
683
  try {
645
684
  const processes = await this.listNodeProcesses();
646
685
  const coarse = coarsenProcessList(processes);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@camstack/system",
3
- "version": "1.1.28",
3
+ "version": "1.1.30",
4
4
  "description": "Core addon for CamStack — builtins, pipeline, process management, auth, logging, events",
5
5
  "keywords": [
6
6
  "camstack",