@camstack/system 1.2.107 → 1.2.109

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.
@@ -4,6 +4,7 @@ Object.defineProperties(exports, {
4
4
  });
5
5
  require("../../chunk-Cek0wNdY.js");
6
6
  const require_dist = require("../../dist-Ctfhlrtl.js");
7
+ const require_retired_settings_keys = require("../../retired-settings-keys-pNmoHPTg.js");
7
8
  let node_crypto = require("node:crypto");
8
9
  let _camstack_types_node = require("@camstack/types/node");
9
10
  //#region src/builtins/device-manager/adoption-job-engine.ts
@@ -294,1718 +295,1698 @@ var AdoptionJobEngine = class {
294
295
  }
295
296
  };
296
297
  //#endregion
297
- //#region src/builtins/device-manager/day-night-config-schema.ts
298
- var MODE_LABELS = {
299
- auto: "Auto",
300
- day: "Day",
301
- night: "Night",
302
- schedule: "Schedule"
303
- };
298
+ //#region src/builtins/device-manager/device-aggregation-merge.ts
304
299
  /**
305
- * Build the `day-night` `ConfigUISchema` from the camera-probed options +
306
- * current status. Returns `null` when the camera exposes no configurable
307
- * property the cap then reports "no schema" and the renderer shows the
308
- * unsupported-camera message.
300
+ * Pure aggregator-merge + field-tagging helpers for the device-manager
301
+ * device-details aggregator. Extracted verbatim from
302
+ * `device-manager.addon.ts`. These functions are stateless: they take
303
+ * contributions in and return new wire-shape objects, attaching writer
304
+ * provenance to editable fields. No addon-instance dependency.
305
+ *
306
+ * `mergeAggregates` is re-exported from the addon module to preserve the
307
+ * existing public export surface the test-suite imports.
309
308
  */
310
- function buildDayNightConfigSchema(options, status) {
311
- const fields = [];
312
- if (options.modes.length > 0) fields.push({
313
- type: "select",
314
- key: "dayNight_mode",
315
- label: "Mode",
316
- options: options.modes.map((m) => ({
317
- value: m,
318
- label: MODE_LABELS[m]
319
- })),
320
- default: status?.mode ?? options.modes[0]
321
- });
322
- if (options.supportsSensitivity && options.sensitivity) fields.push({
323
- type: "slider",
324
- key: "dayNight_sensitivity",
325
- label: "IR-cut sensitivity",
326
- min: options.sensitivity.min,
327
- max: options.sensitivity.max,
328
- step: options.sensitivity.step,
329
- showValue: true,
330
- default: status?.sensitivity ?? options.sensitivity.min
331
- });
332
- if (options.supportsSwitchDelay && options.switchDelaySec) fields.push({
333
- type: "number",
334
- key: "dayNight_switchDelaySec",
335
- label: "Switch delay",
336
- unit: "s",
337
- min: options.switchDelaySec.min,
338
- max: options.switchDelaySec.max,
339
- step: options.switchDelaySec.step,
340
- default: status?.switchDelaySec ?? options.switchDelaySec.min
341
- });
342
- if (fields.length === 0) return null;
343
- return { sections: [{
344
- id: "day-night",
345
- tab: "image",
346
- title: "Day / Night",
347
- description: "IR-cut switching mode and the photocell knobs that gate it.",
348
- columns: 2,
349
- fields
350
- }] };
309
+ /**
310
+ * Walk the sections/fields of a contribution and inject `writerCapName` +
311
+ * `writerAddonId` + `source` on each editable field. Readonly fields and
312
+ * structural fields (separator/info/button) pass through untouched. The
313
+ * aggregator is the single place that knows provenance — provider schemas
314
+ * stay clean, UI-bound metadata is attached once at the boundary.
315
+ */
316
+ function tagContribution(contribution, capName, addonId, kind) {
317
+ const source = kind === "settings" ? "settings" : "live";
318
+ return {
319
+ ...contribution.tabs ? { tabs: [...contribution.tabs] } : {},
320
+ sections: contribution.sections.map((section) => ({
321
+ ...section,
322
+ fields: section.fields.map((field) => tagField(field, capName, addonId, source, kind))
323
+ }))
324
+ };
351
325
  }
352
- var DAY_NIGHT_MODES = new Set([
353
- "auto",
354
- "day",
355
- "night",
356
- "schedule"
357
- ]);
358
- function isDayNightMode(value) {
359
- return typeof value === "string" && DAY_NIGHT_MODES.has(value);
326
+ function isFieldRecord(value) {
327
+ return value !== null && typeof value === "object" && !Array.isArray(value);
360
328
  }
361
329
  /**
362
- * Re-parse a flat `ConfigFormBuilder` patch into a `DayNightSettingsPatch`.
363
- * Returns an empty object when no `dayNight_*` field changed — the caller
364
- * (`parseDerivedFormSettingsPatch`) forwards the result verbatim to the
365
- * cap's `setSettings` mutation, which itself ignores fields it doesn't
366
- * support.
330
+ * Convert a strict `ConfigUISchemaWithValues` (readonly arrays, typed
331
+ * field union) into the cap wire shape `ContributionShape` (mutable
332
+ * arrays, opaque field records). Required because the cap method z.infer
333
+ * uses mutable arrays readonly arrays are not assignable to mutable
334
+ * even when structurally identical, so a structural copy bridges the gap
335
+ * without disabling the type checker.
367
336
  */
368
- function parseDayNightFormPatch(patch) {
369
- const out = {};
370
- if ("dayNight_mode" in patch && isDayNightMode(patch.dayNight_mode)) out.mode = patch.dayNight_mode;
371
- if ("dayNight_sensitivity" in patch) {
372
- const value = Number(patch.dayNight_sensitivity);
373
- if (Number.isFinite(value)) out.sensitivity = value;
337
+ function toWireShape(input) {
338
+ const out = { sections: input.sections.map((s) => ({
339
+ id: s.id,
340
+ title: s.title,
341
+ ...s.description !== void 0 ? { description: s.description } : {},
342
+ ...s.style !== void 0 ? { style: s.style } : {},
343
+ ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
344
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
345
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
346
+ ...s.location !== void 0 ? { location: s.location } : {},
347
+ ...s.order !== void 0 ? { order: s.order } : {},
348
+ fields: [...s.fields]
349
+ })) };
350
+ if (input.tabs) out.tabs = [...input.tabs];
351
+ return out;
352
+ }
353
+ function tagField(field, capName, addonId, source, kind) {
354
+ if (!isFieldRecord(field)) return field;
355
+ const f = field;
356
+ const structuralTypes = new Set([
357
+ "separator",
358
+ "info",
359
+ "button"
360
+ ]);
361
+ if (typeof f.type === "string" && structuralTypes.has(f.type)) return field;
362
+ const tagged = {
363
+ ...f,
364
+ source
365
+ };
366
+ if (kind === "live" || f.readonlyField === true) tagged.readonlyField = true;
367
+ else {
368
+ tagged.writerCapName = capName;
369
+ tagged.writerAddonId = addonId;
374
370
  }
375
- if ("dayNight_switchDelaySec" in patch) {
376
- const value = Number(patch.dayNight_switchDelaySec);
377
- if (Number.isFinite(value)) out.switchDelaySec = value;
371
+ if (f.type === "group") {
372
+ const children = Array.isArray(f.fields) ? f.fields : [];
373
+ if (children.length > 0) tagged.fields = children.map((child) => tagField(child, capName, addonId, source, kind));
374
+ } else if (f.type === "sub-tabs") {
375
+ const rawTabs = Array.isArray(f.tabs) ? f.tabs : [];
376
+ if (rawTabs.length > 0) tagged.tabs = rawTabs.map((tab) => {
377
+ if (!isFieldRecord(tab)) return tab;
378
+ const tabChildren = Array.isArray(tab.fields) ? tab.fields : [];
379
+ return {
380
+ ...tab,
381
+ fields: tabChildren.map((child) => tagField(child, capName, addonId, source, kind))
382
+ };
383
+ });
384
+ }
385
+ return tagged;
386
+ }
387
+ function mergeAggregates(parts) {
388
+ const tabDecls = /* @__PURE__ */ new Map();
389
+ const sections = [];
390
+ const seenSectionIds = /* @__PURE__ */ new Set();
391
+ for (const part of parts) {
392
+ if (part.tabs) {
393
+ for (const t of part.tabs) if (!tabDecls.has(t.id)) tabDecls.set(t.id, t);
394
+ }
395
+ for (const s of part.sections) {
396
+ if (s.id !== void 0) {
397
+ if (seenSectionIds.has(s.id)) continue;
398
+ seenSectionIds.add(s.id);
399
+ }
400
+ sections.push(s);
401
+ }
402
+ }
403
+ for (const s of sections) {
404
+ const tabId = s.tab ?? "general";
405
+ if (tabDecls.has(tabId)) continue;
406
+ const known = require_dist.WELL_KNOWN_TAB_MAP[tabId];
407
+ if (known) tabDecls.set(tabId, {
408
+ id: known.id,
409
+ label: known.label,
410
+ icon: known.icon,
411
+ order: known.order
412
+ });
413
+ else tabDecls.set(tabId, {
414
+ id: tabId,
415
+ label: tabId,
416
+ icon: "wrench",
417
+ order: 100
418
+ });
378
419
  }
420
+ sections.sort((a, b) => {
421
+ const tabA = a.tab ?? "general";
422
+ const tabB = b.tab ?? "general";
423
+ if (tabA !== tabB) {
424
+ const orderA = tabDecls.get(tabA)?.order ?? 100;
425
+ const orderB = tabDecls.get(tabB)?.order ?? 100;
426
+ if (orderA !== orderB) return orderA - orderB;
427
+ return tabA.localeCompare(tabB);
428
+ }
429
+ return (a.order ?? 0) - (b.order ?? 0);
430
+ });
431
+ const sortedTabs = [...tabDecls.values()].toSorted((a, b) => (a.order ?? 100) - (b.order ?? 100));
432
+ const out = { sections };
433
+ if (sortedTabs.length > 0) out.tabs = sortedTabs;
379
434
  return out;
380
435
  }
381
436
  //#endregion
382
- //#region src/builtins/device-manager/image-settings-config-schema.ts
383
- var ROTATE_LABELS = {
384
- "0": "0°",
385
- "90": "90°",
386
- "180": "180°",
387
- "270": "270°"
388
- };
389
- var WHITE_BALANCE_LABELS = {
390
- auto: "Auto",
391
- manual: "Manual"
392
- };
393
- var EXPOSURE_LABELS = {
394
- auto: "Auto",
395
- manual: "Manual"
396
- };
397
- var BACKLIGHT_LABELS = {
398
- off: "Off",
399
- blc: "Backlight compensation",
400
- wdr: "Wide dynamic range",
401
- hlc: "Highlight compensation"
402
- };
403
- var ROTATE_VALUES = new Set([
404
- "0",
405
- "90",
406
- "180",
407
- "270"
408
- ]);
409
- var WHITE_BALANCE_VALUES = new Set(["auto", "manual"]);
410
- var EXPOSURE_VALUES = new Set(["auto", "manual"]);
411
- var BACKLIGHT_VALUES = new Set([
412
- "off",
413
- "blc",
414
- "wdr",
415
- "hlc"
416
- ]);
417
- function isImageRotate(value) {
418
- return typeof value === "string" && ROTATE_VALUES.has(value);
419
- }
420
- function isWhiteBalanceMode(value) {
421
- return typeof value === "string" && WHITE_BALANCE_VALUES.has(value);
422
- }
423
- function isExposureMode(value) {
424
- return typeof value === "string" && EXPOSURE_VALUES.has(value);
425
- }
426
- function isBacklightMode(value) {
427
- return typeof value === "string" && BACKLIGHT_VALUES.has(value);
428
- }
437
+ //#region src/builtins/device-manager/device-bindings-store.ts
429
438
  /**
430
- * Build the `image-settings` `ConfigUISchema` from the camera-probed
431
- * options + current status. Returns `null` when the camera exposes no
432
- * configurable property.
433
- */
434
- function buildImageSettingsConfigSchema(options, status) {
435
- const fields = [];
436
- if (options.supportsBrightness && options.brightness) fields.push({
437
- type: "slider",
438
- key: "imageSettings_brightness",
439
- label: "Brightness",
440
- min: options.brightness.min,
441
- max: options.brightness.max,
442
- step: options.brightness.step,
443
- showValue: true,
444
- default: status?.brightness ?? options.brightness.min
445
- });
446
- if (options.supportsContrast && options.contrast) fields.push({
447
- type: "slider",
448
- key: "imageSettings_contrast",
449
- label: "Contrast",
450
- min: options.contrast.min,
451
- max: options.contrast.max,
452
- step: options.contrast.step,
453
- showValue: true,
454
- default: status?.contrast ?? options.contrast.min
455
- });
456
- if (options.supportsSaturation && options.saturation) fields.push({
457
- type: "slider",
458
- key: "imageSettings_saturation",
459
- label: "Saturation",
460
- min: options.saturation.min,
461
- max: options.saturation.max,
462
- step: options.saturation.step,
463
- showValue: true,
464
- default: status?.saturation ?? options.saturation.min
465
- });
466
- if (options.supportsSharpness && options.sharpness) fields.push({
467
- type: "slider",
468
- key: "imageSettings_sharpness",
469
- label: "Sharpness",
470
- min: options.sharpness.min,
471
- max: options.sharpness.max,
472
- step: options.sharpness.step,
473
- showValue: true,
474
- default: status?.sharpness ?? options.sharpness.min
475
- });
476
- if (options.supportsMirror) fields.push({
477
- type: "boolean",
478
- key: "imageSettings_mirror",
479
- label: "Mirror",
480
- style: "switch",
481
- default: status?.mirror ?? false
482
- });
483
- if (options.supportsFlip) fields.push({
484
- type: "boolean",
485
- key: "imageSettings_flip",
486
- label: "Flip",
487
- style: "switch",
488
- default: status?.flip ?? false
489
- });
490
- if (options.rotateOptions.length > 0) fields.push({
491
- type: "select",
492
- key: "imageSettings_rotate",
493
- label: "Rotation",
494
- options: options.rotateOptions.map((r) => ({
495
- value: r,
496
- label: ROTATE_LABELS[r]
497
- })),
498
- default: status?.rotate ?? options.rotateOptions[0]
499
- });
500
- if (options.whiteBalanceModes.length > 0) fields.push({
501
- type: "select",
502
- key: "imageSettings_whiteBalance",
503
- label: "White balance",
504
- options: options.whiteBalanceModes.map((m) => ({
505
- value: m,
506
- label: WHITE_BALANCE_LABELS[m]
507
- })),
508
- default: status?.whiteBalance ?? options.whiteBalanceModes[0]
509
- });
510
- if (options.supportsWarmth && options.warmth) fields.push({
511
- type: "slider",
512
- key: "imageSettings_warmth",
513
- label: "Warmth",
514
- min: options.warmth.min,
515
- max: options.warmth.max,
516
- step: options.warmth.step,
517
- showValue: true,
518
- default: status?.warmth ?? options.warmth.min,
519
- showWhen: {
520
- field: "imageSettings_whiteBalance",
521
- equals: "manual"
439
+ * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
440
+ * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
441
+ * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
442
+ * full cluster view. Events from the local node are ignored: hub-local natives
443
+ * live in `capabilityRegistry` and are folded in directly by getBindings.
444
+ *
445
+ * Push events are accurate in the steady state but can be lost during the
446
+ * Moleculer transport handshake window (hub restart, crash-respawn,
447
+ * restartAddon). The reliable replacement for lost events is the D3 re-handshake
448
+ * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
449
+ * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
450
+ * handler purges a gone node's entries; the worker re-handshakes (and re-emits
451
+ * `native-registered`) on its next boot.
452
+ */
453
+ function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
454
+ const localNodeId = ctx.kernel.localNodeId ?? "hub";
455
+ ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (event) => {
456
+ const { deviceId, capName, reason, addonId, nodeId } = event.data;
457
+ if (nodeId === localNodeId) return;
458
+ if (reason === "native-registered") {
459
+ let perDevice = remoteNativeCaps.get(deviceId);
460
+ if (!perDevice) {
461
+ perDevice = /* @__PURE__ */ new Map();
462
+ remoteNativeCaps.set(deviceId, perDevice);
463
+ }
464
+ perDevice.set(capName, {
465
+ addonId,
466
+ nodeId
467
+ });
468
+ } else if (reason === "native-unregistered") {
469
+ const perDevice = remoteNativeCaps.get(deviceId);
470
+ if (!perDevice) return;
471
+ perDevice.delete(capName);
472
+ if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
522
473
  }
523
474
  });
524
- if (options.exposureModes.length > 0) fields.push({
525
- type: "select",
526
- key: "imageSettings_exposureMode",
527
- label: "Exposure mode",
528
- options: options.exposureModes.map((m) => ({
529
- value: m,
530
- label: EXPOSURE_LABELS[m]
531
- })),
532
- default: status?.exposureMode ?? options.exposureModes[0]
533
- });
534
- if (options.backlightModes.length > 0) fields.push({
535
- type: "select",
536
- key: "imageSettings_backlightMode",
537
- label: "Backlight compensation",
538
- options: options.backlightModes.map((m) => ({
539
- value: m,
540
- label: BACKLIGHT_LABELS[m]
541
- })),
542
- default: status?.backlightMode ?? options.backlightModes[0]
475
+ const cluster = ctx.kernel.cluster;
476
+ if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
477
+ const gone = payload.node.id;
478
+ const emptyDevices = [];
479
+ for (const [deviceId, perDevice] of remoteNativeCaps) {
480
+ const toDelete = [];
481
+ for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
482
+ for (const capName of toDelete) perDevice.delete(capName);
483
+ if (perDevice.size === 0) emptyDevices.push(deviceId);
484
+ }
485
+ for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
543
486
  });
544
- if (fields.length === 0) return null;
545
- return { sections: [{
546
- id: "image-settings",
547
- tab: "image",
548
- title: "Image adjustment",
549
- description: "Picture sliders, orientation and exposure. Option lists are read live from the camera — fields the firmware doesn't expose are hidden.",
550
- columns: 2,
551
- fields
552
- }] };
553
487
  }
554
- /**
555
- * Re-parse a flat `ConfigFormBuilder` patch into an `ImageSettingsPatch`.
556
- * Returns an empty object when no `imageSettings_*` field changed.
557
- */
558
- function parseImageSettingsFormPatch(patch) {
559
- const out = {};
560
- if ("imageSettings_brightness" in patch) {
561
- const value = Number(patch.imageSettings_brightness);
562
- if (Number.isFinite(value)) out.brightness = value;
563
- }
564
- if ("imageSettings_contrast" in patch) {
565
- const value = Number(patch.imageSettings_contrast);
566
- if (Number.isFinite(value)) out.contrast = value;
567
- }
568
- if ("imageSettings_saturation" in patch) {
569
- const value = Number(patch.imageSettings_saturation);
570
- if (Number.isFinite(value)) out.saturation = value;
571
- }
572
- if ("imageSettings_sharpness" in patch) {
573
- const value = Number(patch.imageSettings_sharpness);
574
- if (Number.isFinite(value)) out.sharpness = value;
575
- }
576
- if ("imageSettings_mirror" in patch && typeof patch.imageSettings_mirror === "boolean") out.mirror = patch.imageSettings_mirror;
577
- if ("imageSettings_flip" in patch && typeof patch.imageSettings_flip === "boolean") out.flip = patch.imageSettings_flip;
578
- if ("imageSettings_rotate" in patch && isImageRotate(patch.imageSettings_rotate)) out.rotate = patch.imageSettings_rotate;
579
- if ("imageSettings_whiteBalance" in patch && isWhiteBalanceMode(patch.imageSettings_whiteBalance)) out.whiteBalance = patch.imageSettings_whiteBalance;
580
- if ("imageSettings_warmth" in patch) {
581
- const value = Number(patch.imageSettings_warmth);
582
- if (Number.isFinite(value)) out.warmth = value;
583
- }
584
- if ("imageSettings_exposureMode" in patch && isExposureMode(patch.imageSettings_exposureMode)) out.exposureMode = patch.imageSettings_exposureMode;
585
- if ("imageSettings_backlightMode" in patch && isBacklightMode(patch.imageSettings_backlightMode)) out.backlightMode = patch.imageSettings_backlightMode;
586
- return out;
488
+ async function readBindingsStore(deps) {
489
+ return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
490
+ }
491
+ async function writeBindingsStore(deps, next) {
492
+ await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
493
+ }
494
+ function resolveWrapperNodeId(_wrapperAddonId) {
495
+ return "hub";
587
496
  }
588
- //#endregion
589
- //#region src/builtins/device-manager/device-config-contribution.ts
590
497
  /**
591
- * D14 device-config archetype framework-side contribution derivation.
498
+ * Reduce a provider node id to the routable form `DeviceProxy` can pin.
592
499
  *
593
- * A `deviceConfig` cap with `ui.kind: 'derived-form'` names a `builderId`.
594
- * This module owns the registry of `builderId reducer` pure functions
595
- * that build the UI section from the cap's `getOptions` + `getStatus`
596
- * output and route a flat form patch back through the cap's `set*`
597
- * mutation. No per-vendor UI code: reolink and hikvision produce the same
598
- * section from the same inputs.
500
+ * Every addon runs in its own `addon-runner` with the composite node id
501
+ * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) e.g.
502
+ * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
503
+ * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
504
+ * only THROUGH its parent (the hub resolves a hub-local-uds child by
505
+ * cap+device; an agent forwards to its own child). `DeviceProxy` pins
506
+ * `entry.providerNodeId` on every cap call, so a binding entry must expose the
507
+ * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
508
+ * to an unknown node → `no-provider`, which surfaces as
509
+ * "this camera doesn't expose …" for client-proxy-driven widget caps
510
+ * (motion-zones, privacy-mask). Wrappers already report the parent via
511
+ * `resolveWrapperNodeId`; this aligns natives with the same contract.
599
512
  *
600
- * Two reducer shapes exist, one per save-path surface a `derived-form` cap
601
- * can expose:
602
- * - `kind: 'profile'` — the cap mutates one of several named profiles
603
- * via `setProfile({ deviceId, profile, patch })` (`stream-params`).
604
- * - `kind: 'settings'` — the cap has no profile axis and mutates via a
605
- * single `setSettings({ deviceId, settings })` (`day-night`,
606
- * `image-settings`).
513
+ * A flat node id (a genuine standalone node with no `/`) is returned
514
+ * unchanged.
515
+ */
516
+ function toRoutableProviderNodeId(nodeId) {
517
+ const slash = nodeId.indexOf("/");
518
+ return slash === -1 ? nodeId : nodeId.slice(0, slash);
519
+ }
520
+ /**
521
+ * Resolve a remote native cap entry for a given `(capName, deviceId)` by
522
+ * consulting the handshake-fed `HubNodeRegistry` via
523
+ * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
524
+ * `remoteNativeCaps` cache misses — covers the Moleculer transport
525
+ * handshake window where `DeviceBindingsChanged` events were lost but the
526
+ * D3 re-handshake (post device restore) has already populated the registry.
607
527
  *
608
- * `device-aggregation.ts` picks which apply function to call by
609
- * inspecting the bound provider's actual method surface (`setProfile` vs
610
- * `setSettings`), never by branching on the cap name.
528
+ * Returns `null` when the entry is genuinely not present in the cluster
529
+ * view (cap not registered on any worker for that device).
611
530
  */
612
- /** Registered builderIds today. New device-config caps add their own reducer below. */
613
- var STREAM_PARAMS_BUILDER_ID = "stream-params";
614
- var DAY_NIGHT_BUILDER_ID = "day-night";
615
- var IMAGE_SETTINGS_BUILDER_ID = "image-settings";
616
- var STREAM_PARAMS_REDUCER = {
617
- kind: "profile",
618
- buildSchema: (options, status) => require_dist.buildStreamParamsConfigSchema(options, status ?? null),
619
- applyPatch: async (patch, setProfile) => {
620
- for (const meta of require_dist.STREAM_PROFILE_META) {
621
- const profilePatch = require_dist.parseStreamParamsFormPatch(patch, meta.prefix);
622
- if (profilePatch) await setProfile(meta.profile, profilePatch);
623
- }
624
- }
625
- };
626
- var DAY_NIGHT_REDUCER = {
627
- kind: "settings",
628
- buildSchema: (options, status) => buildDayNightConfigSchema(options, status ?? null),
629
- parsePatch: (patch) => parseDayNightFormPatch(patch)
630
- };
631
- var IMAGE_SETTINGS_REDUCER = {
632
- kind: "settings",
633
- buildSchema: (options, status) => buildImageSettingsConfigSchema(options, status ?? null),
634
- parsePatch: (patch) => parseImageSettingsFormPatch(patch)
635
- };
636
- var BUILDER_REDUCERS = {
637
- [STREAM_PARAMS_BUILDER_ID]: STREAM_PARAMS_REDUCER,
638
- [DAY_NIGHT_BUILDER_ID]: DAY_NIGHT_REDUCER,
639
- [IMAGE_SETTINGS_BUILDER_ID]: IMAGE_SETTINGS_REDUCER
640
- };
641
- function resolveReducer(builderId) {
642
- const reducer = BUILDER_REDUCERS[builderId];
643
- if (!reducer) throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
644
- return reducer;
645
- }
646
- /**
647
- * Build the device-detail form section for a `derived-form` device-config
648
- * cap. Returns null when the camera exposes no configurable property.
649
- */
650
- function deriveFormContribution(builderId, options, status) {
651
- const schema = resolveReducer(builderId).buildSchema(options, status);
652
- if (!schema) return null;
653
- return { sections: schema.sections.map((s) => ({
654
- id: s.id,
655
- title: s.title,
656
- ...s.tab !== void 0 ? { tab: s.tab } : {},
657
- ...s.order !== void 0 ? { order: s.order } : {},
658
- ...s.description !== void 0 ? { description: s.description } : {},
659
- ...s.columns !== void 0 ? { columns: s.columns } : {},
660
- fields: [...s.fields]
661
- })) };
662
- }
663
- /**
664
- * Route a flat form patch back through a profile-based cap's per-profile
665
- * `setProfile` mutation (`stream-params`). Throws if `builderId` isn't
666
- * registered as a profile-based reducer — callers dispatch here only
667
- * after confirming the bound provider exposes `setProfile`.
668
- */
669
- async function applyDerivedFormProfilePatch(builderId, patch, setProfile) {
670
- const reducer = resolveReducer(builderId);
671
- if (reducer.kind !== "profile") throw new Error(`device-config: builderId "${builderId}" is not a profile-based derived-form`);
672
- await reducer.applyPatch(patch, setProfile);
531
+ function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
532
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
533
+ if (!clusterCaps) return null;
534
+ for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
535
+ addonId: entry.addonId,
536
+ nodeId: entry.nodeId
537
+ };
538
+ return null;
673
539
  }
674
540
  /**
675
- * Parse a flat form patch into the settings payload for a `setSettings`-
676
- * based cap (`day-night`, `image-settings`). The caller forwards the
677
- * result verbatim to `dcProvider.setSettings({ deviceId, settings })`.
678
- * Throws if `builderId` isn't registered as a settings-based reducer.
541
+ * Resolve the device's declared TYPE (`'camera'`, `'event-emitter'`, …), or
542
+ * `undefined` when it cannot be established.
543
+ *
544
+ * The PERSISTED row is the authority: `ctx.kernel.deviceRegistry` is hub-only
545
+ * and has been observed empty in the very process that answers `getBindings`
546
+ * correctly (see the note on `getAllBindings`). The registry is consulted only
547
+ * as a secondary source, for a device constructed but not yet persisted.
548
+ *
549
+ * `undefined` is a first-class answer and callers must treat it as "no
550
+ * filtering" — an absent row must never be the reason a device loses bindings.
679
551
  */
680
- function parseDerivedFormSettingsPatch(builderId, patch) {
681
- const reducer = resolveReducer(builderId);
682
- if (reducer.kind !== "settings") throw new Error(`device-config: builderId "${builderId}" is not a settings-based derived-form`);
683
- return reducer.parsePatch(patch);
552
+ function resolveDeviceType(deps, row, deviceId) {
553
+ const persisted = row?.meta.type;
554
+ if (typeof persisted === "string" && persisted.length > 0) return persisted;
555
+ const live = deps.ctx.kernel?.deviceRegistry?.getById(deviceId)?.type;
556
+ return typeof live === "string" && live.length > 0 ? live : void 0;
684
557
  }
685
- //#endregion
686
- //#region src/builtins/device-manager/device-aggregation-merge.ts
687
558
  /**
688
- * Pure aggregator-merge + field-tagging helpers for the device-manager
689
- * device-details aggregator. Extracted verbatim from
690
- * `device-manager.addon.ts`. These functions are stateless: they take
691
- * contributions in and return new wire-shape objects, attaching writer
692
- * provenance to editable fields. No addon-instance dependency.
559
+ * Is this device still part of the fleet?
693
560
  *
694
- * `mergeAggregates` is re-exported from the addon module to preserve the
695
- * existing public export surface the test-suite imports.
696
- */
697
- /**
698
- * Walk the sections/fields of a contribution and inject `writerCapName` +
699
- * `writerAddonId` + `source` on each editable field. Readonly fields and
700
- * structural fields (separator/info/button) pass through untouched. The
701
- * aggregator is the single place that knows provenanceprovider schemas
702
- * stay clean, UI-bound metadata is attached once at the boundary.
561
+ * The device-manager's own stores are the authority in-process no RPC. Two
562
+ * sources, in the same order `resolveDeviceType` uses them: the live registry
563
+ * first (a device constructed but not yet persisted is present), then the
564
+ * PERSISTED meta, which is the index `getBindings` already reads and is present
565
+ * wherever this provider runs.
566
+ *
567
+ * `'absent'` is only ever returned against a NON-EMPTY ledger. An empty one
568
+ * means device restore has not run, not that the fleet was deleted (D49) the
569
+ * same reason `getAllBindings` warns instead of reporting zero devices. That is
570
+ * the only reason this is async: the `COUNT(*)` runs solely on the miss path,
571
+ * where the alternative is to call a device gone because the store is empty.
703
572
  */
704
- function tagContribution(contribution, capName, addonId, kind) {
705
- const source = kind === "settings" ? "settings" : "live";
706
- return {
707
- ...contribution.tabs ? { tabs: [...contribution.tabs] } : {},
708
- sections: contribution.sections.map((section) => ({
709
- ...section,
710
- fields: section.fields.map((field) => tagField(field, capName, addonId, source, kind))
711
- }))
712
- };
713
- }
714
- function isFieldRecord(value) {
715
- return value !== null && typeof value === "object" && !Array.isArray(value);
573
+ async function resolveDevicePresence(deps, row, deviceId) {
574
+ if (deps.ctx.kernel?.deviceRegistry?.getById(deviceId)) return "present";
575
+ if (row !== null) return "present";
576
+ return await deps.rows.count() > 0 ? "absent" : "unknown";
716
577
  }
717
578
  /**
718
- * Convert a strict `ConfigUISchemaWithValues` (readonly arrays, typed
719
- * field union) into the cap wire shape `ContributionShape` (mutable
720
- * arrays, opaque field records). Required because the cap method z.infer
721
- * uses mutable arrays readonly arrays are not assignable to mutable
722
- * even when structurally identical, so a structural copy bridges the gap
723
- * without disabling the type checker.
579
+ * Does a capability apply to a device of type `deviceType`?
580
+ *
581
+ * The cap's `deviceTypes` is the ONLY declaration consulted (D4: behavioural
582
+ * cap metadata lives in the `*.cap.ts`, never in a manifest). Two deliberate
583
+ * fail-open cases:
584
+ *
585
+ * - a cap that declares no `deviceTypes` (or an empty list) applies to every
586
+ * device — the pre-existing, back-compatible semantics;
587
+ * - an UNKNOWN `deviceType` never filters, so a missing/failed meta lookup
588
+ * changes nothing (D49: a read that fails must not destroy work).
724
589
  */
725
- function toWireShape(input) {
726
- const out = { sections: input.sections.map((s) => ({
727
- id: s.id,
728
- title: s.title,
729
- ...s.description !== void 0 ? { description: s.description } : {},
730
- ...s.style !== void 0 ? { style: s.style } : {},
731
- ...s.defaultCollapsed !== void 0 ? { defaultCollapsed: s.defaultCollapsed } : {},
732
- ...s.columns !== void 0 ? { columns: s.columns } : {},
733
- ...s.tab !== void 0 ? { tab: s.tab } : {},
734
- ...s.location !== void 0 ? { location: s.location } : {},
735
- ...s.order !== void 0 ? { order: s.order } : {},
736
- fields: [...s.fields]
737
- })) };
738
- if (input.tabs) out.tabs = [...input.tabs];
739
- return out;
590
+ function capAppliesToDeviceType(def, deviceType) {
591
+ if (deviceType === void 0) return true;
592
+ const declared = def?.deviceTypes;
593
+ if (!declared || declared.length === 0) return true;
594
+ return declared.some((t) => t === deviceType);
740
595
  }
741
- function tagField(field, capName, addonId, source, kind) {
742
- if (!isFieldRecord(field)) return field;
743
- const f = field;
744
- const structuralTypes = new Set([
745
- "separator",
746
- "info",
747
- "button"
748
- ]);
749
- if (typeof f.type === "string" && structuralTypes.has(f.type)) return field;
750
- const tagged = {
751
- ...f,
752
- source
753
- };
754
- if (kind === "live" || f.readonlyField === true) tagged.readonlyField = true;
755
- else {
756
- tagged.writerCapName = capName;
757
- tagged.writerAddonId = addonId;
758
- }
759
- if (f.type === "group") {
760
- const children = Array.isArray(f.fields) ? f.fields : [];
761
- if (children.length > 0) tagged.fields = children.map((child) => tagField(child, capName, addonId, source, kind));
762
- } else if (f.type === "sub-tabs") {
763
- const rawTabs = Array.isArray(f.tabs) ? f.tabs : [];
764
- if (rawTabs.length > 0) tagged.tabs = rawTabs.map((tab) => {
765
- if (!isFieldRecord(tab)) return tab;
766
- const tabChildren = Array.isArray(tab.fields) ? tab.fields : [];
767
- return {
768
- ...tab,
769
- fields: tabChildren.map((child) => tagField(child, capName, addonId, source, kind))
770
- };
596
+ async function getBindings(deps, input) {
597
+ const storeKey = String(input.deviceId);
598
+ const perDevice = (await readBindingsStore(deps)).deviceBindings[storeKey] ?? {};
599
+ const row = await deps.rows.get(input.deviceId);
600
+ const deviceType = resolveDeviceType(deps, row, input.deviceId);
601
+ const presence = await resolveDevicePresence(deps, row, input.deviceId);
602
+ const entries = [];
603
+ const seenCaps = /* @__PURE__ */ new Set();
604
+ const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
605
+ for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
606
+ const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
607
+ const remoteNative = resolveRemote(capName);
608
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
609
+ const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
610
+ if (wrapperAddonId === null && !nativeAddonId) {
611
+ seenCaps.add(capName);
612
+ continue;
613
+ }
614
+ entries.push({
615
+ capName,
616
+ kind: wrapperAddonId ? "wrapped" : "native",
617
+ providerAddonId: wrapperAddonId ?? nativeAddonId,
618
+ providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
619
+ nativeAddonId
771
620
  });
621
+ seenCaps.add(capName);
772
622
  }
773
- return tagged;
774
- }
775
- function mergeAggregates(parts) {
776
- const tabDecls = /* @__PURE__ */ new Map();
777
- const sections = [];
778
- const seenSectionIds = /* @__PURE__ */ new Set();
779
- for (const part of parts) {
780
- if (part.tabs) {
781
- for (const t of part.tabs) if (!tabDecls.has(t.id)) tabDecls.set(t.id, t);
782
- }
783
- for (const s of part.sections) {
784
- if (s.id !== void 0) {
785
- if (seenSectionIds.has(s.id)) continue;
786
- seenSectionIds.add(s.id);
623
+ if (presence === "absent") deps.ctx.logger.debug("bindings requested for absent device — returning none", { tags: { deviceId: input.deviceId } });
624
+ else if (deps.capabilityRegistry) {
625
+ const skippedForType = [];
626
+ for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
627
+ if (seenCaps.has(capName)) continue;
628
+ if (!capAppliesToDeviceType(deps.capabilityRegistry.getDefinition(capName), deviceType)) {
629
+ skippedForType.push(capName);
630
+ continue;
787
631
  }
788
- sections.push(s);
632
+ const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
633
+ if (!defaultWrapperAddonId) continue;
634
+ const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
635
+ const remoteNative = resolveRemote(capName);
636
+ const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
637
+ entries.push({
638
+ capName,
639
+ kind: "wrapped",
640
+ providerAddonId: defaultWrapperAddonId,
641
+ providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
642
+ nativeAddonId
643
+ });
644
+ seenCaps.add(capName);
789
645
  }
646
+ if (skippedForType.length > 0) deps.ctx.logger.debug("getBindings: default wrappers skipped — deviceTypes mismatch", {
647
+ tags: { deviceId: input.deviceId },
648
+ meta: {
649
+ deviceType,
650
+ skipped: skippedForType
651
+ }
652
+ });
790
653
  }
791
- for (const s of sections) {
792
- const tabId = s.tab ?? "general";
793
- if (tabDecls.has(tabId)) continue;
794
- const known = require_dist.WELL_KNOWN_TAB_MAP[tabId];
795
- if (known) tabDecls.set(tabId, {
796
- id: known.id,
797
- label: known.label,
798
- icon: known.icon,
799
- order: known.order
654
+ if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
655
+ if (seenCaps.has(capName)) continue;
656
+ const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
657
+ entries.push({
658
+ capName,
659
+ kind: "native",
660
+ providerAddonId: nativeAddonId,
661
+ providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
662
+ nativeAddonId
800
663
  });
801
- else tabDecls.set(tabId, {
802
- id: tabId,
803
- label: tabId,
804
- icon: "wrench",
805
- order: 100
664
+ seenCaps.add(capName);
665
+ }
666
+ const pushFed = deps.remoteNativeCaps.get(input.deviceId);
667
+ if (pushFed) for (const [capName, info] of pushFed) {
668
+ if (seenCaps.has(capName)) continue;
669
+ entries.push({
670
+ capName,
671
+ kind: "native",
672
+ providerAddonId: info.addonId,
673
+ providerNodeId: toRoutableProviderNodeId(info.nodeId),
674
+ nativeAddonId: info.addonId
806
675
  });
676
+ seenCaps.add(capName);
807
677
  }
808
- sections.sort((a, b) => {
809
- const tabA = a.tab ?? "general";
810
- const tabB = b.tab ?? "general";
811
- if (tabA !== tabB) {
812
- const orderA = tabDecls.get(tabA)?.order ?? 100;
813
- const orderB = tabDecls.get(tabB)?.order ?? 100;
814
- if (orderA !== orderB) return orderA - orderB;
815
- return tabA.localeCompare(tabB);
816
- }
817
- return (a.order ?? 0) - (b.order ?? 0);
818
- });
819
- const sortedTabs = [...tabDecls.values()].toSorted((a, b) => (a.order ?? 100) - (b.order ?? 100));
820
- const out = { sections };
821
- if (sortedTabs.length > 0) out.tabs = sortedTabs;
822
- return out;
678
+ const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
679
+ if (clusterCaps) for (const entry of clusterCaps) {
680
+ if (entry.deviceId !== input.deviceId) continue;
681
+ if (seenCaps.has(entry.capName)) continue;
682
+ if (!entry.addonId) continue;
683
+ const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
684
+ if (entry.nodeId === localNodeId) continue;
685
+ entries.push({
686
+ capName: entry.capName,
687
+ kind: "native",
688
+ providerAddonId: entry.addonId,
689
+ providerNodeId: toRoutableProviderNodeId(entry.nodeId),
690
+ nativeAddonId: entry.addonId
691
+ });
692
+ seenCaps.add(entry.capName);
693
+ }
694
+ return {
695
+ deviceId: input.deviceId,
696
+ entries
697
+ };
823
698
  }
824
- //#endregion
825
- //#region src/builtins/device-manager/device-projection.ts
826
699
  /**
827
- * Return true when `err` is a transient Moleculer error that is worth
828
- * retrying specifically any `MoleculerRetryableError` subclass
829
- * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
830
- * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
700
+ * Whole-fleet binding dump. Iterates every device known to the
701
+ * deviceRegistry and reuses the per-device `getBindings` resolver
702
+ * for each — same routing rules, single round-trip. Used by
703
+ * `SystemManager.init()` for warm-boot.
831
704
  *
832
- * Falls back to a message-substring check for serialised errors that arrive
833
- * across the Moleculer transport as plain objects rather than real instances.
705
+ * Bindings change rarely (wrapper toggle, device add/remove) so
706
+ * clients invalidate via the existing
707
+ * `capability.binding-changed` event rather than re-fetching this
708
+ * payload periodically.
834
709
  */
835
- function isTransientMoleculerError(err) {
836
- if (err !== null && typeof err === "object") {
837
- const e = err;
838
- if (e["retryable"] === true) return true;
839
- const code = typeof e["code"] === "string" ? e["code"] : "";
840
- if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
841
- }
842
- if (err instanceof Error) {
843
- const msg = err.message;
844
- if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
710
+ async function getAllBindings(deps) {
711
+ const ids = /* @__PURE__ */ new Set();
712
+ for (const row of await deps.rows.listAll()) ids.add(row.meta.id);
713
+ const registered = deps.ctx.kernel?.deviceRegistry?.getAll() ?? [];
714
+ for (const device of registered) ids.add(device.id);
715
+ if (ids.size === 0) {
716
+ deps.ctx.logger.warn("getAllBindings found no devices — warm boot will see nothing", { meta: { hasRegistry: deps.ctx.kernel?.deviceRegistry !== void 0 } });
717
+ return [];
845
718
  }
846
- return false;
847
- }
848
- function shallowEqual(a, b) {
849
- const ak = Object.keys(a);
850
- const bk = Object.keys(b);
851
- if (ak.length !== bk.length) return false;
852
- for (const k of ak) if (a[k] !== b[k]) return false;
853
- return true;
854
- }
855
- function isCameraDevice(device) {
856
- return "getStreamSources" in device && typeof device.getStreamSources === "function";
719
+ const out = [];
720
+ for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await getBindings(deps, { deviceId }));
721
+ return out;
857
722
  }
858
- var DEVICE_FEATURE_VALUES = new Set(Object.values(require_dist.DeviceFeature));
859
723
  /**
860
- * Validate persisted feature strings against the `DeviceFeature` enum
861
- * workers serialise the live `device.features` array (so every entry
862
- * is a valid enum value at write time) but the persisted blob is loose
863
- * `string[]` on the wire. The narrow keeps unknown values out of the
864
- * `getDevice` response without losing the enum-typed contract.
724
+ * Resolve a numeric deviceId to a stableId via persisted meta.
725
+ * Used only by the device-identity section of the device-details
726
+ * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
727
+ * a readonly display field. All runtime/registry lookups are keyed by
728
+ * numeric deviceId; this helper is display-only.
865
729
  */
866
- function persistedFeatures(features) {
867
- if (!features) return [];
868
- const out = [];
869
- for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
870
- return out;
730
+ async function lookupPersistedStableId(deps, deviceId) {
731
+ return (await deps.rows.get(deviceId))?.meta.stableId;
871
732
  }
733
+ //#endregion
734
+ //#region src/builtins/device-manager/day-night-config-schema.ts
735
+ var MODE_LABELS = {
736
+ auto: "Auto",
737
+ day: "Day",
738
+ night: "Night",
739
+ schedule: "Schedule"
740
+ };
872
741
  /**
873
- * Build an identity-only `SourceInfo` from the persisted device config blob.
874
- *
875
- * Forked-worker accessory children (e.g. HA sensor entities) persist
876
- * `entityId` and `system` in their config blob at spawn time. The hub has no
877
- * live `IDevice` instance for these devices, so the persisted-fallback paths
878
- * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
879
- * `SourceInfo` from the config so dispatch routing keeps working.
880
- *
881
- * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
882
- * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
883
- * present) are projected — purely identity, never rendering hints.
884
- *
885
- * Returns `undefined` when no identity anchor is resolvable (pure identity
886
- * devices like cameras/hubs that don't carry `entityId`/`system` in their
887
- * config blob) — the hub synthetic fallback applies in that case.
742
+ * Build the `day-night` `ConfigUISchema` from the camera-probed options +
743
+ * current status. Returns `null` when the camera exposes no configurable
744
+ * property the cap then reports "no schema" and the renderer shows the
745
+ * unsupported-camera message.
888
746
  */
889
- function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
890
- const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
891
- const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
892
- if (id === void 0 && system === void 0) return void 0;
893
- const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
894
- return {
895
- id: id ?? stableId,
896
- system: system ?? addonId,
897
- ...uniqueId !== void 0 ? { uniqueId } : {}
898
- };
747
+ function buildDayNightConfigSchema(options, status) {
748
+ const fields = [];
749
+ if (options.modes.length > 0) fields.push({
750
+ type: "select",
751
+ key: "dayNight_mode",
752
+ label: "Mode",
753
+ options: options.modes.map((m) => ({
754
+ value: m,
755
+ label: MODE_LABELS[m]
756
+ })),
757
+ default: status?.mode ?? options.modes[0]
758
+ });
759
+ if (options.supportsSensitivity && options.sensitivity) fields.push({
760
+ type: "slider",
761
+ key: "dayNight_sensitivity",
762
+ label: "IR-cut sensitivity",
763
+ min: options.sensitivity.min,
764
+ max: options.sensitivity.max,
765
+ step: options.sensitivity.step,
766
+ showValue: true,
767
+ default: status?.sensitivity ?? options.sensitivity.min
768
+ });
769
+ if (options.supportsSwitchDelay && options.switchDelaySec) fields.push({
770
+ type: "number",
771
+ key: "dayNight_switchDelaySec",
772
+ label: "Switch delay",
773
+ unit: "s",
774
+ min: options.switchDelaySec.min,
775
+ max: options.switchDelaySec.max,
776
+ step: options.switchDelaySec.step,
777
+ default: status?.switchDelaySec ?? options.switchDelaySec.min
778
+ });
779
+ if (fields.length === 0) return null;
780
+ return { sections: [{
781
+ id: "day-night",
782
+ tab: "image",
783
+ title: "Day / Night",
784
+ description: "IR-cut switching mode and the photocell knobs that gate it.",
785
+ columns: 2,
786
+ fields
787
+ }] };
899
788
  }
900
- var DEVICE_ROLE_VALUES = new Set(Object.values(require_dist.DeviceRole));
901
- /** Type guard: a string is a known `DeviceRole` enum member. */
902
- function isDeviceRole(value) {
903
- return DEVICE_ROLE_VALUES.has(value);
789
+ var DAY_NIGHT_MODES = new Set([
790
+ "auto",
791
+ "day",
792
+ "night",
793
+ "schedule"
794
+ ]);
795
+ function isDayNightMode(value) {
796
+ return typeof value === "string" && DAY_NIGHT_MODES.has(value);
904
797
  }
905
- /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
906
- * Unknown / null values resolve to `null` so a stale or unrecognised role
907
- * never leaks an off-enum string onto the wire shape. */
908
- function toDeviceRole(value) {
909
- return value != null && isDeviceRole(value) ? value : null;
910
- }
911
- function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
912
- const configValues = {};
913
- for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
914
- const name = metaRow?.name ?? device.name;
915
- const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
916
- const disabled = metaRow?.disabled ?? device.disabled;
917
- const probeSlice = device.runtimeState?.getCapState("feature-probe");
918
- const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
919
- return {
920
- id: device.id,
921
- stableId: device.stableId,
922
- addonId,
923
- type: device.type,
924
- name,
925
- location,
926
- disabled,
927
- parentDeviceId: device.parentDeviceId,
928
- role: device.role ?? null,
929
- online: device.online,
930
- probed,
931
- features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
932
- isCamera: isCameraDevice(device),
933
- config: configValues,
934
- metadata,
935
- ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
936
- ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
937
- ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
938
- ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
939
- ...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
940
- };
941
- }
942
- function resolveDeviceById(registry, deviceId) {
943
- const device = registry.getById(deviceId);
944
- if (!device) return null;
945
- const addonId = registry.getAddonId(deviceId);
946
- if (!addonId) return null;
947
- return {
948
- addonId,
949
- device
950
- };
798
+ /**
799
+ * Re-parse a flat `ConfigFormBuilder` patch into a `DayNightSettingsPatch`.
800
+ * Returns an empty object when no `dayNight_*` field changed — the caller
801
+ * (`parseDerivedFormSettingsPatch`) forwards the result verbatim to the
802
+ * cap's `setSettings` mutation, which itself ignores fields it doesn't
803
+ * support.
804
+ */
805
+ function parseDayNightFormPatch(patch) {
806
+ const out = {};
807
+ if ("dayNight_mode" in patch && isDayNightMode(patch.dayNight_mode)) out.mode = patch.dayNight_mode;
808
+ if ("dayNight_sensitivity" in patch) {
809
+ const value = Number(patch.dayNight_sensitivity);
810
+ if (Number.isFinite(value)) out.sensitivity = value;
811
+ }
812
+ if ("dayNight_switchDelaySec" in patch) {
813
+ const value = Number(patch.dayNight_switchDelaySec);
814
+ if (Number.isFinite(value)) out.switchDelaySec = value;
815
+ }
816
+ return out;
951
817
  }
952
818
  //#endregion
953
- //#region src/builtins/device-manager/device-queries.ts
954
- async function listPersistedByAddon(pctx, input) {
955
- const { addonId } = input;
956
- const { index, meta } = await pctx.metaStore.readAll();
957
- const stableIds = index[addonId] ?? [];
958
- const byStableId = /* @__PURE__ */ new Map();
959
- for (const m of Object.values(meta)) if (m.addonId === addonId) byStableId.set(m.stableId, m);
960
- return stableIds.map((stableId) => {
961
- const m = byStableId.get(stableId);
962
- return {
963
- id: m.id,
964
- stableId,
965
- type: m.type,
966
- name: m.name,
967
- location: m.location ?? null,
968
- disabled: m.disabled ?? false,
969
- parentDeviceId: m.parentDeviceId
970
- };
971
- });
972
- }
973
- async function listAll(pctx, input) {
974
- const ownerFilter = Reflect.get(input, "addonId");
975
- if (ownerFilter !== void 0 && (typeof ownerFilter !== "string" || ownerFilter.length === 0)) throw new Error(`deviceManager.listAll: addonId must be a non-empty string or be omitted — got ${JSON.stringify(ownerFilter)}. An owner filter that is present but empty is never widened to every device. On an addon context the id is \`ctx.id\`; there is no \`ctx.addonId\`.`);
976
- const { addonId } = input;
977
- const slim = input.projection === "slim";
978
- const camerasOnly = input.isCamera === true;
979
- const results = [];
980
- const seen = /* @__PURE__ */ new Set();
981
- const { meta, metadata: metadataMap, index } = await pctx.metaStore.readAll();
982
- if (pctx.registry) {
983
- const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
984
- addonId,
985
- device
986
- })) : pctx.registry.getAllWithAddonId();
987
- for (const { addonId: aid, device } of liveEntries) {
988
- const key = String(device.id);
989
- const info = toDeviceInfo(aid, device, metadataMap[key] ?? null, meta[key] ?? null);
990
- seen.add(key);
991
- if (camerasOnly && !info.isCamera) continue;
992
- results.push(slim ? {
993
- ...info,
994
- config: {},
995
- metadata: null
996
- } : info);
997
- }
998
- }
999
- const metaByAddonStable = /* @__PURE__ */ new Map();
1000
- for (const m of Object.values(meta)) metaByAddonStable.set(`${m.addonId}${m.stableId}`, m);
1001
- const targetAddons = addonId ? [addonId] : Object.keys(index);
1002
- for (const aid of targetAddons) for (const stableId of index[aid] ?? []) {
1003
- const m = metaByAddonStable.get(`${aid}${stableId}`);
1004
- const key = String(m.id);
1005
- if (seen.has(key)) continue;
1006
- const persistedType = m.type;
1007
- if (camerasOnly && persistedType !== require_dist.DeviceType.Camera) continue;
1008
- const persistedConfig = slim ? {} : await pctx.settings.readDeviceStore(m.id);
1009
- const metadata = slim ? null : metadataMap[key] ?? null;
1010
- results.push({
1011
- id: m.id,
1012
- stableId,
1013
- addonId: aid,
1014
- type: persistedType,
1015
- name: m?.name ?? stableId,
1016
- location: m?.location ?? null,
1017
- disabled: m?.disabled ?? false,
1018
- parentDeviceId: m?.parentDeviceId ?? null,
1019
- role: toDeviceRole(m?.role),
1020
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1021
- probed: pctx.host.resolveDeviceProbed(m.id),
1022
- features: persistedFeatures(m?.features),
1023
- isCamera: persistedType === require_dist.DeviceType.Camera,
1024
- config: persistedConfig ?? {},
1025
- metadata,
1026
- ...m?.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1027
- ...m?.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1028
- ...m?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1029
- ...m?.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1030
- ...m?.display !== void 0 ? { display: m.display } : {},
1031
- ...(() => {
1032
- if (slim) return {};
1033
- const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1034
- return si !== void 0 ? { sourceInfo: si } : {};
1035
- })()
1036
- });
1037
- }
1038
- return results;
1039
- }
1040
- async function getDevice(pctx, input) {
1041
- const { deviceId } = input;
1042
- if (pctx.registry) {
1043
- const found = resolveDeviceById(pctx.registry, deviceId);
1044
- if (found) {
1045
- const key = String(found.device.id);
1046
- const [map, metaMap] = await Promise.all([pctx.metaStore.readMetadataMap(), pctx.metaStore.readMeta()]);
1047
- const metadata = map[key] ?? null;
1048
- const metaRow = metaMap[key] ?? null;
1049
- return toDeviceInfo(found.addonId, found.device, metadata, metaRow);
1050
- }
1051
- }
1052
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1053
- if (!persisted) return null;
1054
- const { addonId: aid, stableId, meta: m } = persisted;
1055
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1056
- const key = String(deviceId);
1057
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
1058
- const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1059
- return {
1060
- id: deviceId,
1061
- stableId,
1062
- addonId: aid,
1063
- type: m.type,
1064
- name: m.name,
1065
- location: m.location ?? null,
1066
- disabled: m.disabled ?? false,
1067
- parentDeviceId: m.parentDeviceId,
1068
- role: toDeviceRole(m.role),
1069
- online: pctx.host.resolveDeviceOnline(deviceId, true),
1070
- probed: pctx.host.resolveDeviceProbed(deviceId),
1071
- features: persistedFeatures(m.features),
1072
- isCamera: false,
1073
- config: persistedConfig ?? {},
1074
- metadata,
1075
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1076
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1077
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1078
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1079
- ...m.display !== void 0 ? { display: m.display } : {},
1080
- ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
1081
- };
1082
- }
1083
- async function getChildren(pctx, input) {
1084
- const { parentDeviceId } = input;
1085
- let ownerAddonId = null;
1086
- if (pctx.registry) {
1087
- if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
1088
- }
1089
- if (!ownerAddonId) {
1090
- const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
1091
- if (!persisted) return [];
1092
- ownerAddonId = persisted.addonId;
1093
- }
1094
- const results = [];
1095
- const seen = /* @__PURE__ */ new Set();
1096
- const { index, meta, metadata: metadataMap } = await pctx.metaStore.readAll();
1097
- if (pctx.registry) {
1098
- const liveChildren = pctx.registry.getChildren(parentDeviceId);
1099
- for (const device of liveChildren) {
1100
- const key = String(device.id);
1101
- const metadata = metadataMap[key] ?? null;
1102
- const metaRow = meta[key] ?? null;
1103
- results.push(toDeviceInfo(ownerAddonId, device, metadata, metaRow));
1104
- seen.add(key);
1105
- }
1106
- }
1107
- const ownerMetaByStableId = /* @__PURE__ */ new Map();
1108
- for (const m of Object.values(meta)) if (m.addonId === ownerAddonId) ownerMetaByStableId.set(m.stableId, m);
1109
- const persistedChildren = (index[ownerAddonId] ?? []).filter((sid) => ownerMetaByStableId.get(sid)?.parentDeviceId === parentDeviceId);
1110
- for (const childStableId of persistedChildren) {
1111
- const m = ownerMetaByStableId.get(childStableId);
1112
- const key = String(m.id);
1113
- if (seen.has(key)) continue;
1114
- const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1115
- const metadata = metadataMap[key] ?? null;
1116
- const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1117
- results.push({
1118
- id: m.id,
1119
- stableId: childStableId,
1120
- addonId: ownerAddonId,
1121
- type: m.type,
1122
- name: m.name,
1123
- location: m.location ?? null,
1124
- disabled: m.disabled ?? false,
1125
- parentDeviceId,
1126
- role: toDeviceRole(m.role),
1127
- online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1128
- probed: pctx.host.resolveDeviceProbed(m.id),
1129
- features: persistedFeatures(m.features),
1130
- isCamera: false,
1131
- config: persistedConfig ?? {},
1132
- metadata,
1133
- ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1134
- ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1135
- ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1136
- ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1137
- ...m.display !== void 0 ? { display: m.display } : {},
1138
- ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
1139
- });
1140
- }
1141
- return results;
819
+ //#region src/builtins/device-manager/image-settings-config-schema.ts
820
+ var ROTATE_LABELS = {
821
+ "0": "0°",
822
+ "90": "90°",
823
+ "180": "180°",
824
+ "270": "270°"
825
+ };
826
+ var WHITE_BALANCE_LABELS = {
827
+ auto: "Auto",
828
+ manual: "Manual"
829
+ };
830
+ var EXPOSURE_LABELS = {
831
+ auto: "Auto",
832
+ manual: "Manual"
833
+ };
834
+ var BACKLIGHT_LABELS = {
835
+ off: "Off",
836
+ blc: "Backlight compensation",
837
+ wdr: "Wide dynamic range",
838
+ hlc: "Highlight compensation"
839
+ };
840
+ var ROTATE_VALUES = new Set([
841
+ "0",
842
+ "90",
843
+ "180",
844
+ "270"
845
+ ]);
846
+ var WHITE_BALANCE_VALUES = new Set(["auto", "manual"]);
847
+ var EXPOSURE_VALUES = new Set(["auto", "manual"]);
848
+ var BACKLIGHT_VALUES = new Set([
849
+ "off",
850
+ "blc",
851
+ "wdr",
852
+ "hlc"
853
+ ]);
854
+ function isImageRotate(value) {
855
+ return typeof value === "string" && ROTATE_VALUES.has(value);
1142
856
  }
1143
- async function getStreamSources(pctx, input) {
1144
- const { deviceId } = input;
1145
- if (pctx.registry) {
1146
- const found = resolveDeviceById(pctx.registry, deviceId);
1147
- if (found) {
1148
- if (!isCameraDevice(found.device)) return [];
1149
- return (await found.device.getStreamSources()).map((s) => ({
1150
- id: s.id,
1151
- label: s.label,
1152
- protocol: s.protocol,
1153
- url: s.url,
1154
- resolution: s.resolution,
1155
- fps: s.fps,
1156
- bitrate: s.bitrate,
1157
- codec: s.codec,
1158
- profileHint: s.profileHint
1159
- }));
1160
- }
1161
- }
1162
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1163
- return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
857
+ function isWhiteBalanceMode(value) {
858
+ return typeof value === "string" && WHITE_BALANCE_VALUES.has(value);
1164
859
  }
1165
- async function getConfigSchema(pctx, input) {
1166
- const { deviceId } = input;
1167
- if (pctx.registry) {
1168
- const found = resolveDeviceById(pctx.registry, deviceId);
1169
- if (found) return found.device.config.entries().map((entry) => ({
1170
- key: entry.key,
1171
- value: entry.value,
1172
- ...entry.description !== void 0 ? { description: entry.description } : {}
1173
- }));
1174
- }
1175
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1176
- return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
860
+ function isExposureMode(value) {
861
+ return typeof value === "string" && EXPOSURE_VALUES.has(value);
1177
862
  }
1178
- async function getSettingsSchema(pctx, input) {
1179
- const { deviceId } = input;
1180
- if (pctx.registry) {
1181
- const found = resolveDeviceById(pctx.registry, deviceId);
1182
- if (found) return found.device.getSettingsUISchema();
1183
- }
1184
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
1185
- return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
863
+ function isBacklightMode(value) {
864
+ return typeof value === "string" && BACKLIGHT_VALUES.has(value);
1186
865
  }
1187
- async function updateConfig(pctx, input) {
1188
- const { deviceId } = input;
1189
- if (pctx.registry) {
1190
- const found = resolveDeviceById(pctx.registry, deviceId);
1191
- if (found) {
1192
- await found.device.config.setAll(input.values);
1193
- return { success: true };
1194
- }
1195
- }
1196
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1197
- await pctx.requireDeviceOps(deviceId).setConfig({
1198
- deviceId,
1199
- values: input.values
866
+ /**
867
+ * Build the `image-settings` `ConfigUISchema` from the camera-probed
868
+ * options + current status. Returns `null` when the camera exposes no
869
+ * configurable property.
870
+ */
871
+ function buildImageSettingsConfigSchema(options, status) {
872
+ const fields = [];
873
+ if (options.supportsBrightness && options.brightness) fields.push({
874
+ type: "slider",
875
+ key: "imageSettings_brightness",
876
+ label: "Brightness",
877
+ min: options.brightness.min,
878
+ max: options.brightness.max,
879
+ step: options.brightness.step,
880
+ showValue: true,
881
+ default: status?.brightness ?? options.brightness.min
1200
882
  });
1201
- return { success: true };
1202
- }
1203
- async function enable(pctx, input) {
1204
- await pctx.provider.setDisabled({
1205
- deviceId: input.deviceId,
1206
- disabled: false
883
+ if (options.supportsContrast && options.contrast) fields.push({
884
+ type: "slider",
885
+ key: "imageSettings_contrast",
886
+ label: "Contrast",
887
+ min: options.contrast.min,
888
+ max: options.contrast.max,
889
+ step: options.contrast.step,
890
+ showValue: true,
891
+ default: status?.contrast ?? options.contrast.min
1207
892
  });
1208
- return { success: true };
1209
- }
1210
- async function disable(pctx, input) {
1211
- await pctx.provider.setDisabled({
1212
- deviceId: input.deviceId,
1213
- disabled: true
893
+ if (options.supportsSaturation && options.saturation) fields.push({
894
+ type: "slider",
895
+ key: "imageSettings_saturation",
896
+ label: "Saturation",
897
+ min: options.saturation.min,
898
+ max: options.saturation.max,
899
+ step: options.saturation.step,
900
+ showValue: true,
901
+ default: status?.saturation ?? options.saturation.min
1214
902
  });
1215
- return { success: true };
1216
- }
1217
- async function remove(pctx, input) {
1218
- const { deviceId } = input;
1219
- const removeOne = async (id) => {
1220
- if (pctx.registry) {
1221
- const live = resolveDeviceById(pctx.registry, id);
1222
- if (live) {
1223
- const deviceName = live.device.name;
1224
- await live.device.removeDevice();
1225
- pctx.registry.remove(id);
1226
- await pctx.provider.removeDevice({ deviceId: id });
1227
- pctx.host.ctx.logger.info("removed hub-local device", { tags: {
1228
- deviceId: id,
1229
- deviceName
1230
- } });
1231
- return;
1232
- }
1233
- }
1234
- const persisted = await pctx.metaStore.resolvePersistedById(id);
1235
- if (!persisted) return;
1236
- const { meta: persistedMeta } = persisted;
1237
- try {
1238
- await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
1239
- } catch (err) {
1240
- pctx.host.ctx.logger.warn("remove via device-ops failed — clearing persistence anyway", {
1241
- tags: {
1242
- deviceId: id,
1243
- deviceName: persistedMeta.name
1244
- },
1245
- meta: { error: require_dist.errMsg(err) }
1246
- });
903
+ if (options.supportsSharpness && options.sharpness) fields.push({
904
+ type: "slider",
905
+ key: "imageSettings_sharpness",
906
+ label: "Sharpness",
907
+ min: options.sharpness.min,
908
+ max: options.sharpness.max,
909
+ step: options.sharpness.step,
910
+ showValue: true,
911
+ default: status?.sharpness ?? options.sharpness.min
912
+ });
913
+ if (options.supportsMirror) fields.push({
914
+ type: "boolean",
915
+ key: "imageSettings_mirror",
916
+ label: "Mirror",
917
+ style: "switch",
918
+ default: status?.mirror ?? false
919
+ });
920
+ if (options.supportsFlip) fields.push({
921
+ type: "boolean",
922
+ key: "imageSettings_flip",
923
+ label: "Flip",
924
+ style: "switch",
925
+ default: status?.flip ?? false
926
+ });
927
+ if (options.rotateOptions.length > 0) fields.push({
928
+ type: "select",
929
+ key: "imageSettings_rotate",
930
+ label: "Rotation",
931
+ options: options.rotateOptions.map((r) => ({
932
+ value: r,
933
+ label: ROTATE_LABELS[r]
934
+ })),
935
+ default: status?.rotate ?? options.rotateOptions[0]
936
+ });
937
+ if (options.whiteBalanceModes.length > 0) fields.push({
938
+ type: "select",
939
+ key: "imageSettings_whiteBalance",
940
+ label: "White balance",
941
+ options: options.whiteBalanceModes.map((m) => ({
942
+ value: m,
943
+ label: WHITE_BALANCE_LABELS[m]
944
+ })),
945
+ default: status?.whiteBalance ?? options.whiteBalanceModes[0]
946
+ });
947
+ if (options.supportsWarmth && options.warmth) fields.push({
948
+ type: "slider",
949
+ key: "imageSettings_warmth",
950
+ label: "Warmth",
951
+ min: options.warmth.min,
952
+ max: options.warmth.max,
953
+ step: options.warmth.step,
954
+ showValue: true,
955
+ default: status?.warmth ?? options.warmth.min,
956
+ showWhen: {
957
+ field: "imageSettings_whiteBalance",
958
+ equals: "manual"
1247
959
  }
1248
- await pctx.provider.removeDevice({ deviceId: id });
1249
- };
1250
- const removeCascade = async (id) => {
1251
- for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
1252
- await removeOne(id);
1253
- };
1254
- await removeCascade(deviceId);
1255
- return { success: true };
960
+ });
961
+ if (options.exposureModes.length > 0) fields.push({
962
+ type: "select",
963
+ key: "imageSettings_exposureMode",
964
+ label: "Exposure mode",
965
+ options: options.exposureModes.map((m) => ({
966
+ value: m,
967
+ label: EXPOSURE_LABELS[m]
968
+ })),
969
+ default: status?.exposureMode ?? options.exposureModes[0]
970
+ });
971
+ if (options.backlightModes.length > 0) fields.push({
972
+ type: "select",
973
+ key: "imageSettings_backlightMode",
974
+ label: "Backlight compensation",
975
+ options: options.backlightModes.map((m) => ({
976
+ value: m,
977
+ label: BACKLIGHT_LABELS[m]
978
+ })),
979
+ default: status?.backlightMode ?? options.backlightModes[0]
980
+ });
981
+ if (fields.length === 0) return null;
982
+ return { sections: [{
983
+ id: "image-settings",
984
+ tab: "image",
985
+ title: "Image adjustment",
986
+ description: "Picture sliders, orientation and exposure. Option lists are read live from the camera — fields the firmware doesn't expose are hidden.",
987
+ columns: 2,
988
+ fields
989
+ }] };
1256
990
  }
1257
991
  /**
1258
- * Cascade-delete every top-level device whose `integrationId`
1259
- * matches. Enumerates a SNAPSHOT of the meta map so concurrent
1260
- * removals don't clobber each other. Only top-level parents are
1261
- * enumerated — children cascade via the per-parent `removeCascade`
1262
- * inside the delegated `remove` call. Idempotent: devices with no
1263
- * `integrationId` never match.
992
+ * Re-parse a flat `ConfigFormBuilder` patch into an `ImageSettingsPatch`.
993
+ * Returns an empty object when no `imageSettings_*` field changed.
1264
994
  */
1265
- async function removeByIntegration(pctx, input) {
1266
- const { integrationId } = input;
1267
- const meta = await pctx.metaStore.readMeta();
1268
- const parentKeys = Object.keys(meta).filter((key) => {
1269
- const m = meta[key];
1270
- return m !== void 0 && m.integrationId === integrationId && m.parentDeviceId === null;
1271
- });
1272
- let removed = 0;
1273
- for (const _key of parentKeys) {
1274
- const m = meta[_key];
1275
- if (!m) continue;
1276
- await pctx.provider.remove({ deviceId: m.id });
1277
- removed++;
995
+ function parseImageSettingsFormPatch(patch) {
996
+ const out = {};
997
+ if ("imageSettings_brightness" in patch) {
998
+ const value = Number(patch.imageSettings_brightness);
999
+ if (Number.isFinite(value)) out.brightness = value;
1278
1000
  }
1279
- return { removed };
1280
- }
1281
- async function getStreamProfileMap(pctx, input) {
1282
- if (!pctx.registry) return {};
1283
- const found = resolveDeviceById(pctx.registry, input.deviceId);
1284
- if (!found) return {};
1285
- const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
1286
- if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
1287
- if (!isCameraDevice(found.device)) return {};
1288
- const sources = await found.device.getStreamSources();
1289
- const profileMap = {};
1290
- for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
1291
- return profileMap;
1292
- }
1293
- async function setStreamProfileMap(pctx, input) {
1294
- const { deviceId } = input;
1295
- if (pctx.registry) {
1296
- const found = resolveDeviceById(pctx.registry, deviceId);
1297
- if (found) {
1298
- await found.device.config.setAll({ _profileMap: input.profileMap });
1299
- return { success: true };
1300
- }
1001
+ if ("imageSettings_contrast" in patch) {
1002
+ const value = Number(patch.imageSettings_contrast);
1003
+ if (Number.isFinite(value)) out.contrast = value;
1301
1004
  }
1302
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1303
- await pctx.requireDeviceOps(deviceId).setConfig({
1304
- deviceId,
1305
- values: { _profileMap: input.profileMap }
1306
- });
1307
- return { success: true };
1005
+ if ("imageSettings_saturation" in patch) {
1006
+ const value = Number(patch.imageSettings_saturation);
1007
+ if (Number.isFinite(value)) out.saturation = value;
1008
+ }
1009
+ if ("imageSettings_sharpness" in patch) {
1010
+ const value = Number(patch.imageSettings_sharpness);
1011
+ if (Number.isFinite(value)) out.sharpness = value;
1012
+ }
1013
+ if ("imageSettings_mirror" in patch && typeof patch.imageSettings_mirror === "boolean") out.mirror = patch.imageSettings_mirror;
1014
+ if ("imageSettings_flip" in patch && typeof patch.imageSettings_flip === "boolean") out.flip = patch.imageSettings_flip;
1015
+ if ("imageSettings_rotate" in patch && isImageRotate(patch.imageSettings_rotate)) out.rotate = patch.imageSettings_rotate;
1016
+ if ("imageSettings_whiteBalance" in patch && isWhiteBalanceMode(patch.imageSettings_whiteBalance)) out.whiteBalance = patch.imageSettings_whiteBalance;
1017
+ if ("imageSettings_warmth" in patch) {
1018
+ const value = Number(patch.imageSettings_warmth);
1019
+ if (Number.isFinite(value)) out.warmth = value;
1020
+ }
1021
+ if ("imageSettings_exposureMode" in patch && isExposureMode(patch.imageSettings_exposureMode)) out.exposureMode = patch.imageSettings_exposureMode;
1022
+ if ("imageSettings_backlightMode" in patch && isBacklightMode(patch.imageSettings_backlightMode)) out.backlightMode = patch.imageSettings_backlightMode;
1023
+ return out;
1308
1024
  }
1309
- async function probeStreams(pctx, input) {
1310
- const streamProbe = pctx.host.ctx.kernel.streamProbe;
1311
- if (!streamProbe) return [];
1312
- const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
1313
- const results = [];
1314
- for (const s of sources) {
1315
- if (!s.url) continue;
1316
- try {
1317
- const metadata = await streamProbe.probe(s.url, { force: true });
1318
- results.push({
1319
- streamId: s.id,
1320
- width: metadata.width,
1321
- height: metadata.height,
1322
- codec: metadata.codec,
1323
- fps: metadata.fps,
1324
- bitrateKbps: metadata.bitrateKbps
1325
- });
1326
- } catch (err) {
1327
- pctx.host.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
1328
- deviceId: input.deviceId,
1329
- streamId: s.id,
1330
- error: err instanceof Error ? err.message : String(err)
1331
- } });
1332
- results.push({ streamId: s.id });
1025
+ //#endregion
1026
+ //#region src/builtins/device-manager/device-config-contribution.ts
1027
+ /**
1028
+ * D14 device-config archetype framework-side contribution derivation.
1029
+ *
1030
+ * A `deviceConfig` cap with `ui.kind: 'derived-form'` names a `builderId`.
1031
+ * This module owns the registry of `builderId → reducer` pure functions
1032
+ * that build the UI section from the cap's `getOptions` + `getStatus`
1033
+ * output and route a flat form patch back through the cap's `set*`
1034
+ * mutation. No per-vendor UI code: reolink and hikvision produce the same
1035
+ * section from the same inputs.
1036
+ *
1037
+ * Two reducer shapes exist, one per save-path surface a `derived-form` cap
1038
+ * can expose:
1039
+ * - `kind: 'profile'` — the cap mutates one of several named profiles
1040
+ * via `setProfile({ deviceId, profile, patch })` (`stream-params`).
1041
+ * - `kind: 'settings'` — the cap has no profile axis and mutates via a
1042
+ * single `setSettings({ deviceId, settings })` (`day-night`,
1043
+ * `image-settings`).
1044
+ *
1045
+ * `device-aggregation.ts` picks which apply function to call by
1046
+ * inspecting the bound provider's actual method surface (`setProfile` vs
1047
+ * `setSettings`), never by branching on the cap name.
1048
+ */
1049
+ /** Registered builderIds today. New device-config caps add their own reducer below. */
1050
+ var STREAM_PARAMS_BUILDER_ID = "stream-params";
1051
+ var DAY_NIGHT_BUILDER_ID = "day-night";
1052
+ var IMAGE_SETTINGS_BUILDER_ID = "image-settings";
1053
+ var STREAM_PARAMS_REDUCER = {
1054
+ kind: "profile",
1055
+ buildSchema: (options, status) => require_dist.buildStreamParamsConfigSchema(options, status ?? null),
1056
+ applyPatch: async (patch, setProfile) => {
1057
+ for (const meta of require_dist.STREAM_PROFILE_META) {
1058
+ const profilePatch = require_dist.parseStreamParamsFormPatch(patch, meta.prefix);
1059
+ if (profilePatch) await setProfile(meta.profile, profilePatch);
1333
1060
  }
1334
1061
  }
1335
- return results;
1062
+ };
1063
+ var DAY_NIGHT_REDUCER = {
1064
+ kind: "settings",
1065
+ buildSchema: (options, status) => buildDayNightConfigSchema(options, status ?? null),
1066
+ parsePatch: (patch) => parseDayNightFormPatch(patch)
1067
+ };
1068
+ var IMAGE_SETTINGS_REDUCER = {
1069
+ kind: "settings",
1070
+ buildSchema: (options, status) => buildImageSettingsConfigSchema(options, status ?? null),
1071
+ parsePatch: (patch) => parseImageSettingsFormPatch(patch)
1072
+ };
1073
+ var BUILDER_REDUCERS = {
1074
+ [STREAM_PARAMS_BUILDER_ID]: STREAM_PARAMS_REDUCER,
1075
+ [DAY_NIGHT_BUILDER_ID]: DAY_NIGHT_REDUCER,
1076
+ [IMAGE_SETTINGS_BUILDER_ID]: IMAGE_SETTINGS_REDUCER
1077
+ };
1078
+ function resolveReducer(builderId) {
1079
+ const reducer = BUILDER_REDUCERS[builderId];
1080
+ if (!reducer) throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
1081
+ return reducer;
1336
1082
  }
1337
- async function discoverDevices(pctx, input) {
1338
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
1339
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
1340
- return (await dp.discoverDevices({})).map((d) => ({
1341
- stableId: d.stableId,
1342
- type: d.type,
1343
- suggestedName: d.suggestedName,
1344
- prefilledConfig: d.prefilledConfig
1345
- }));
1083
+ /**
1084
+ * Build the device-detail form section for a `derived-form` device-config
1085
+ * cap. Returns null when the camera exposes no configurable property.
1086
+ */
1087
+ function deriveFormContribution(builderId, options, status) {
1088
+ const schema = resolveReducer(builderId).buildSchema(options, status);
1089
+ if (!schema) return null;
1090
+ return { sections: schema.sections.map((s) => ({
1091
+ id: s.id,
1092
+ title: s.title,
1093
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
1094
+ ...s.order !== void 0 ? { order: s.order } : {},
1095
+ ...s.description !== void 0 ? { description: s.description } : {},
1096
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
1097
+ fields: [...s.fields]
1098
+ })) };
1346
1099
  }
1347
- async function adoptDevice(pctx, input) {
1348
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
1349
- if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
1350
- const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
1351
- if (input.integrationId !== void 0) try {
1352
- await pctx.stampIntegrationId(summary.id, input.integrationId);
1353
- } catch (err) {
1354
- pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
1355
- tags: {
1356
- deviceId: summary.id,
1357
- integrationId: input.integrationId
1358
- },
1359
- meta: { error: require_dist.errMsg(err) }
1360
- });
1100
+ /**
1101
+ * Route a flat form patch back through a profile-based cap's per-profile
1102
+ * `setProfile` mutation (`stream-params`). Throws if `builderId` isn't
1103
+ * registered as a profile-based reducer callers dispatch here only
1104
+ * after confirming the bound provider exposes `setProfile`.
1105
+ */
1106
+ async function applyDerivedFormProfilePatch(builderId, patch, setProfile) {
1107
+ const reducer = resolveReducer(builderId);
1108
+ if (reducer.kind !== "profile") throw new Error(`device-config: builderId "${builderId}" is not a profile-based derived-form`);
1109
+ await reducer.applyPatch(patch, setProfile);
1110
+ }
1111
+ /**
1112
+ * Parse a flat form patch into the settings payload for a `setSettings`-
1113
+ * based cap (`day-night`, `image-settings`). The caller forwards the
1114
+ * result verbatim to `dcProvider.setSettings({ deviceId, settings })`.
1115
+ * Throws if `builderId` isn't registered as a settings-based reducer.
1116
+ */
1117
+ function parseDerivedFormSettingsPatch(builderId, patch) {
1118
+ const reducer = resolveReducer(builderId);
1119
+ if (reducer.kind !== "settings") throw new Error(`device-config: builderId "${builderId}" is not a settings-based derived-form`);
1120
+ return reducer.parsePatch(patch);
1121
+ }
1122
+ //#endregion
1123
+ //#region src/builtins/device-manager/device-projection.ts
1124
+ /**
1125
+ * Return true when `err` is a transient Moleculer error that is worth
1126
+ * retrying — specifically any `MoleculerRetryableError` subclass
1127
+ * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
1128
+ * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
1129
+ *
1130
+ * Falls back to a message-substring check for serialised errors that arrive
1131
+ * across the Moleculer transport as plain objects rather than real instances.
1132
+ */
1133
+ function isTransientMoleculerError(err) {
1134
+ if (err !== null && typeof err === "object") {
1135
+ const e = err;
1136
+ if (e["retryable"] === true) return true;
1137
+ const code = typeof e["code"] === "string" ? e["code"] : "";
1138
+ if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
1139
+ }
1140
+ if (err instanceof Error) {
1141
+ const msg = err.message;
1142
+ if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
1361
1143
  }
1362
- return summary;
1363
- }
1364
- async function getCreationSchema(pctx, input) {
1365
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
1366
- if (!await dp.supportsManualCreation({})) return null;
1367
- return await dp.getChildCreationSchema({ type: input.type }) ?? null;
1144
+ return false;
1368
1145
  }
1369
- async function createDevice(pctx, input) {
1370
- const dp = await pctx.host.requireDeviceProvider(input.addonId);
1371
- if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
1372
- const summary = await dp.createDevice({
1373
- type: input.type,
1374
- config: input.config
1375
- });
1376
- if (input.integrationId !== void 0) try {
1377
- await pctx.stampIntegrationId(summary.id, input.integrationId);
1378
- } catch (err) {
1379
- pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
1380
- tags: {
1381
- deviceId: summary.id,
1382
- integrationId: input.integrationId
1383
- },
1384
- meta: { error: require_dist.errMsg(err) }
1385
- });
1386
- }
1387
- return summary;
1146
+ function shallowEqual(a, b) {
1147
+ const ak = Object.keys(a);
1148
+ const bk = Object.keys(b);
1149
+ if (ak.length !== bk.length) return false;
1150
+ for (const k of ak) if (a[k] !== b[k]) return false;
1151
+ return true;
1388
1152
  }
1389
- async function testCreationField(pctx, input) {
1390
- return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
1391
- type: input.type,
1392
- key: input.key,
1393
- value: input.value,
1394
- ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
1395
- });
1153
+ function isCameraDevice(device) {
1154
+ return "getStreamSources" in device && typeof device.getStreamSources === "function";
1396
1155
  }
1397
- async function adoptionListCandidates(pctx, input) {
1398
- const { addonId, ...rest } = input;
1399
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
1156
+ var DEVICE_FEATURE_VALUES = new Set(Object.values(require_dist.DeviceFeature));
1157
+ /**
1158
+ * Validate persisted feature strings against the `DeviceFeature` enum
1159
+ * — workers serialise the live `device.features` array (so every entry
1160
+ * is a valid enum value at write time) but the persisted blob is loose
1161
+ * `string[]` on the wire. The narrow keeps unknown values out of the
1162
+ * `getDevice` response without losing the enum-typed contract.
1163
+ */
1164
+ function persistedFeatures(features) {
1165
+ if (!features) return [];
1166
+ const out = [];
1167
+ for (const f of features) if (DEVICE_FEATURE_VALUES.has(f)) out.push(f);
1168
+ return out;
1400
1169
  }
1401
- async function adoptionRefresh(pctx, input) {
1402
- const { addonId, integrationId } = input;
1403
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
1170
+ /**
1171
+ * Build an identity-only `SourceInfo` from the persisted device config blob.
1172
+ *
1173
+ * Forked-worker accessory children (e.g. HA sensor entities) persist
1174
+ * `entityId` and `system` in their config blob at spawn time. The hub has no
1175
+ * live `IDevice` instance for these devices, so the persisted-fallback paths
1176
+ * in `listAll` / `getDevice` / `getChildren` must reconstruct the identity
1177
+ * `SourceInfo` from the config so dispatch routing keeps working.
1178
+ *
1179
+ * Rendering metadata (unit, precision) flows live through the cap STATUS SLICE
1180
+ * and must NOT be derived here. Only `id` + `system` (+ `uniqueId` when
1181
+ * present) are projected — purely identity, never rendering hints.
1182
+ *
1183
+ * Returns `undefined` when no identity anchor is resolvable (pure identity
1184
+ * devices like cameras/hubs that don't carry `entityId`/`system` in their
1185
+ * config blob) — the hub synthetic fallback applies in that case.
1186
+ */
1187
+ function buildSourceInfoFromConfig(persistedConfig, stableId, addonId) {
1188
+ const id = typeof persistedConfig["entityId"] === "string" ? persistedConfig["entityId"] : void 0;
1189
+ const system = typeof persistedConfig["system"] === "string" ? persistedConfig["system"] : void 0;
1190
+ if (id === void 0 && system === void 0) return void 0;
1191
+ const uniqueId = typeof persistedConfig["uniqueId"] === "string" ? persistedConfig["uniqueId"] : void 0;
1192
+ return {
1193
+ id: id ?? stableId,
1194
+ system: system ?? addonId,
1195
+ ...uniqueId !== void 0 ? { uniqueId } : {}
1196
+ };
1404
1197
  }
1405
- async function adoptionAdopt(pctx, input) {
1406
- const { addonId, ...rest } = input;
1407
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
1198
+ var DEVICE_ROLE_VALUES = new Set(Object.values(require_dist.DeviceRole));
1199
+ /** Type guard: a string is a known `DeviceRole` enum member. */
1200
+ function isDeviceRole(value) {
1201
+ return DEVICE_ROLE_VALUES.has(value);
1408
1202
  }
1409
- async function adoptionRelease(pctx, input) {
1410
- const { addonId, ...rest } = input;
1411
- return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
1203
+ /** Narrow a persisted role string (sqlite TEXT column) to a `DeviceRole`.
1204
+ * Unknown / null values resolve to `null` so a stale or unrecognised role
1205
+ * never leaks an off-enum string onto the wire shape. */
1206
+ function toDeviceRole(value) {
1207
+ return value != null && isDeviceRole(value) ? value : null;
1412
1208
  }
1413
- async function adoptionStartJob(pctx, input) {
1414
- const { addonId, integrationId, childNativeIds, filter, importLocations, perCandidate } = input;
1415
- return pctx.host.adoptionJobs.start({
1209
+ function toDeviceInfo(addonId, device, metadata = null, metaRow = null) {
1210
+ const configValues = {};
1211
+ for (const entry of device.config.entries()) configValues[entry.key] = entry.value;
1212
+ const name = metaRow?.name ?? device.name;
1213
+ const location = metaRow?.location !== void 0 ? metaRow.location : device.location;
1214
+ const disabled = metaRow?.disabled ?? device.disabled;
1215
+ const probeSlice = device.runtimeState?.getCapState("feature-probe");
1216
+ const probed = probeSlice === void 0 ? true : (probeSlice.lastProbedAt ?? 0) > 0;
1217
+ return {
1218
+ id: device.id,
1219
+ stableId: device.stableId,
1416
1220
  addonId,
1417
- integrationId,
1418
- childNativeIds,
1419
- ...filter !== void 0 ? { filter } : {},
1420
- ...importLocations !== void 0 ? { importLocations } : {},
1421
- ...perCandidate !== void 0 ? { perCandidate } : {}
1422
- });
1221
+ type: device.type,
1222
+ name,
1223
+ location,
1224
+ disabled,
1225
+ parentDeviceId: device.parentDeviceId,
1226
+ role: device.role ?? null,
1227
+ online: device.online,
1228
+ probed,
1229
+ features: device.features.length > 0 ? [...device.features] : persistedFeatures(metaRow?.features),
1230
+ isCamera: isCameraDevice(device),
1231
+ config: configValues,
1232
+ metadata,
1233
+ ...metaRow?.integrationId !== void 0 ? { integrationId: metaRow.integrationId } : {},
1234
+ ...metaRow?.linkDeviceId !== void 0 ? { linkDeviceId: metaRow.linkDeviceId } : {},
1235
+ ...metaRow?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: metaRow.primaryChildEntityId } : {},
1236
+ ...metaRow?.childLayout !== void 0 ? { childLayout: metaRow.childLayout } : {},
1237
+ ...metaRow?.display !== void 0 ? { display: metaRow.display } : {}
1238
+ };
1423
1239
  }
1424
- async function adoptionListJobs(pctx, input) {
1425
- const { addonId, integrationId } = input;
1426
- return pctx.host.adoptionJobs.list({
1240
+ function resolveDeviceById(registry, deviceId) {
1241
+ const device = registry.getById(deviceId);
1242
+ if (!device) return null;
1243
+ const addonId = registry.getAddonId(deviceId);
1244
+ if (!addonId) return null;
1245
+ return {
1427
1246
  addonId,
1428
- ...integrationId !== void 0 ? { integrationId } : {}
1429
- });
1247
+ device
1248
+ };
1430
1249
  }
1431
- async function adoptionCancelJob(pctx, input) {
1432
- return { cancelled: pctx.host.adoptionJobs.cancel(input.jobId) };
1250
+ //#endregion
1251
+ //#region src/builtins/device-manager/device-queries.ts
1252
+ async function listPersistedByAddon(pctx, input) {
1253
+ const { addonId } = input;
1254
+ return (await pctx.metaStore.rows.listByAddon(addonId)).map(({ meta: m }) => ({
1255
+ id: m.id,
1256
+ stableId: m.stableId,
1257
+ type: m.type,
1258
+ name: m.name,
1259
+ location: m.location ?? null,
1260
+ disabled: m.disabled ?? false,
1261
+ parentDeviceId: m.parentDeviceId
1262
+ }));
1433
1263
  }
1434
- async function adoptionResync(pctx, input) {
1435
- const { camDeviceId, resetToSource } = input;
1436
- let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
1437
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
1438
- if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
1439
- let removedChildren = 0;
1440
- if (resetToSource === true) {
1441
- const childIds = await pctx.metaStore.directChildIds(camDeviceId);
1442
- for (const childId of childIds) {
1443
- await pctx.provider.remove({ deviceId: childId });
1444
- removedChildren += 1;
1264
+ async function listAll(pctx, input) {
1265
+ const ownerFilter = Reflect.get(input, "addonId");
1266
+ if (ownerFilter !== void 0 && (typeof ownerFilter !== "string" || ownerFilter.length === 0)) throw new Error(`deviceManager.listAll: addonId must be a non-empty string or be omitted — got ${JSON.stringify(ownerFilter)}. An owner filter that is present but empty is never widened to every device. On an addon context the id is \`ctx.id\`; there is no \`ctx.addonId\`.`);
1267
+ const { addonId } = input;
1268
+ const slim = input.projection === "slim";
1269
+ const camerasOnly = input.isCamera === true;
1270
+ const results = [];
1271
+ const seen = /* @__PURE__ */ new Set();
1272
+ const fleet = addonId ? await pctx.metaStore.rows.listByAddon(addonId) : await pctx.metaStore.rows.listAll();
1273
+ const rowById = /* @__PURE__ */ new Map();
1274
+ for (const row of fleet) rowById.set(row.meta.id, row);
1275
+ if (pctx.registry) {
1276
+ const liveEntries = addonId ? pctx.registry.getAllForAddon(addonId).map((device) => ({
1277
+ addonId,
1278
+ device
1279
+ })) : pctx.registry.getAllWithAddonId();
1280
+ for (const { addonId: aid, device } of liveEntries) {
1281
+ const key = String(device.id);
1282
+ const row = rowById.get(device.id);
1283
+ const info = toDeviceInfo(aid, device, row?.metadata ?? null, row?.meta ?? null);
1284
+ seen.add(key);
1285
+ if (camerasOnly && !info.isCamera) continue;
1286
+ results.push(slim ? {
1287
+ ...info,
1288
+ config: {},
1289
+ metadata: null
1290
+ } : info);
1445
1291
  }
1446
- pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
1447
- deviceId: camDeviceId,
1448
- removedChildren
1449
- } });
1450
1292
  }
1451
- return {
1452
- ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
1453
- camDeviceId,
1454
- resetToSource
1455
- }),
1456
- removedChildren
1457
- };
1458
- }
1459
- async function testField(pctx, input) {
1460
- const { deviceId } = input;
1461
- let owningAddonId = null;
1462
- if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
1463
- if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
1464
- if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
1465
- const dp = await pctx.host.waitDeviceProvider(owningAddonId);
1466
- if (!dp) return {
1467
- status: "ok",
1468
- labels: [],
1469
- error: void 0
1470
- };
1471
- if (typeof dp.testCreationField !== "function") return {
1472
- status: "ok",
1473
- labels: [],
1474
- error: void 0
1475
- };
1476
- return dp.testCreationField({
1477
- type: require_dist.DeviceType.Camera,
1478
- key: input.key,
1479
- value: input.value
1480
- });
1293
+ for (const row of fleet) {
1294
+ const m = row.meta;
1295
+ const aid = m.addonId;
1296
+ const stableId = m.stableId;
1297
+ if (seen.has(String(m.id))) continue;
1298
+ const persistedType = m.type;
1299
+ if (camerasOnly && persistedType !== require_dist.DeviceType.Camera) continue;
1300
+ const persistedConfig = slim ? {} : await pctx.settings.readDeviceStore(m.id);
1301
+ const metadata = slim ? null : row.metadata;
1302
+ results.push({
1303
+ id: m.id,
1304
+ stableId,
1305
+ addonId: aid,
1306
+ type: persistedType,
1307
+ name: m.name,
1308
+ location: m.location ?? null,
1309
+ disabled: m.disabled ?? false,
1310
+ parentDeviceId: m.parentDeviceId ?? null,
1311
+ role: toDeviceRole(m.role),
1312
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1313
+ probed: pctx.host.resolveDeviceProbed(m.id),
1314
+ features: persistedFeatures(m.features),
1315
+ isCamera: persistedType === require_dist.DeviceType.Camera,
1316
+ config: persistedConfig ?? {},
1317
+ metadata,
1318
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1319
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1320
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1321
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1322
+ ...m.display !== void 0 ? { display: m.display } : {},
1323
+ ...(() => {
1324
+ if (slim) return {};
1325
+ const si = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1326
+ return si !== void 0 ? { sourceInfo: si } : {};
1327
+ })()
1328
+ });
1329
+ }
1330
+ return results;
1481
1331
  }
1482
- //#endregion
1483
- //#region src/builtins/device-manager/device-linked-devices.ts
1484
- /** Reserved device-config keys (cf. `_profileMap`). */
1485
- var LINKED_MODE_CONFIG_KEY = "_linkedDevicesMode";
1486
- var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
1487
- /** Reserved key: subset of linked devices that materialize synthetic tracked
1488
- * events on the camera. Absent NONE producing tracked events is an
1489
- * explicit operator OPT-IN (2026-07-22 flip: the old absent⇒ALL default
1490
- * auto-materialized sensor snapshots the operator never asked for). */
1491
- var LINKED_TRACKED_IDS_CONFIG_KEY = "_linkedDeviceTrackedIds";
1492
- /** Parse the two reserved keys out of a device-config blob. Tolerant:
1493
- * ids may arrive as numbers or numeric strings (the multiselect field
1494
- * stores string option values); anything else is dropped. */
1495
- function parseLinkedDevicesConfig(blob) {
1496
- const mode = blob["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
1497
- const rawIds = blob[LINKED_IDS_CONFIG_KEY];
1498
- const manualIds = [];
1499
- if (Array.isArray(rawIds)) for (const v of rawIds) {
1500
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1501
- if (Number.isInteger(n) && !manualIds.includes(n)) manualIds.push(n);
1332
+ async function getDevice(pctx, input) {
1333
+ const { deviceId } = input;
1334
+ if (pctx.registry) {
1335
+ const found = resolveDeviceById(pctx.registry, deviceId);
1336
+ if (found) {
1337
+ const row = await pctx.metaStore.getRow(found.device.id);
1338
+ return toDeviceInfo(found.addonId, found.device, row?.metadata ?? null, row?.meta ?? null);
1339
+ }
1502
1340
  }
1341
+ const row = await pctx.metaStore.getRow(deviceId);
1342
+ if (row === null) return null;
1343
+ const { meta: m, metadata } = row;
1344
+ const { addonId: aid, stableId } = m;
1345
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1346
+ const sourceInfoGetDevice = buildSourceInfoFromConfig(persistedConfig ?? {}, stableId, aid);
1503
1347
  return {
1504
- mode,
1505
- manualIds
1348
+ id: deviceId,
1349
+ stableId,
1350
+ addonId: aid,
1351
+ type: m.type,
1352
+ name: m.name,
1353
+ location: m.location ?? null,
1354
+ disabled: m.disabled ?? false,
1355
+ parentDeviceId: m.parentDeviceId,
1356
+ role: toDeviceRole(m.role),
1357
+ online: pctx.host.resolveDeviceOnline(deviceId, true),
1358
+ probed: pctx.host.resolveDeviceProbed(deviceId),
1359
+ features: persistedFeatures(m.features),
1360
+ isCamera: false,
1361
+ config: persistedConfig ?? {},
1362
+ metadata,
1363
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1364
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1365
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1366
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1367
+ ...m.display !== void 0 ? { display: m.display } : {},
1368
+ ...sourceInfoGetDevice !== void 0 ? { sourceInfo: sourceInfoGetDevice } : {}
1506
1369
  };
1507
1370
  }
1508
- /**
1509
- * Pure resolution core: children ∪ (auto → same-location | manual → list),
1510
- * minus self, deduped. Order: children first, then mode additions.
1511
- */
1512
- function resolveLinkedDeviceIds(params) {
1513
- const { selfId, selfLocation, config, candidates } = params;
1514
- const ids = /* @__PURE__ */ new Set();
1515
- for (const c of candidates) if (c.parentDeviceId === selfId) ids.add(c.id);
1516
- if (config.mode === "auto") {
1517
- if (selfLocation !== null && selfLocation.length > 0) {
1518
- for (const c of candidates) if (c.location === selfLocation) ids.add(c.id);
1519
- }
1520
- } else {
1521
- const known = new Set(candidates.map((c) => c.id));
1522
- for (const id of config.manualIds) if (known.has(id)) ids.add(id);
1371
+ async function getChildren(pctx, input) {
1372
+ const { parentDeviceId } = input;
1373
+ let ownerAddonId = null;
1374
+ if (pctx.registry) {
1375
+ if (pctx.registry.getById(parentDeviceId)) ownerAddonId = pctx.registry.getAddonId(parentDeviceId);
1523
1376
  }
1524
- ids.delete(selfId);
1525
- return [...ids];
1526
- }
1527
- /**
1528
- * Which linked devices materialize synthetic tracked events on the camera.
1529
- * Absent / non-array config key ⇒ NONE — the operator must explicitly pick
1530
- * producers (opt-in; flipped from the legacy absent⇒ALL default, which
1531
- * auto-materialized sensor snapshots for every linked device). Otherwise the
1532
- * configured subset intersected with the currently-resolved linked set (a
1533
- * picked id no longer linked is dropped). Ids may arrive as numbers or numeric
1534
- * strings (the multiselect stores string option values); non-numeric entries
1535
- * are ignored.
1536
- */
1537
- function resolveTrackedEventDeviceIds(config, linkedIds) {
1538
- const raw = config[LINKED_TRACKED_IDS_CONFIG_KEY];
1539
- if (!Array.isArray(raw)) return [];
1540
- const picked = /* @__PURE__ */ new Set();
1541
- for (const v of raw) {
1542
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1543
- if (Number.isInteger(n)) picked.add(n);
1377
+ if (!ownerAddonId) {
1378
+ const persisted = await pctx.metaStore.resolvePersistedById(parentDeviceId);
1379
+ if (!persisted) return [];
1380
+ ownerAddonId = persisted.addonId;
1544
1381
  }
1545
- return linkedIds.filter((id) => picked.has(id));
1546
- }
1547
- /** Cap implementation of `getLinkedDevices`. */
1548
- async function getLinkedDevices(pctx, input) {
1549
- const { deviceId } = input;
1550
- const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
1551
- const config = parseLinkedDevicesConfig(blob);
1552
- const byId = new Map(all.map((d) => [d.id, d]));
1553
- const ids = resolveLinkedDeviceIds({
1554
- selfId: deviceId,
1555
- selfLocation: byId.get(deviceId)?.location ?? null,
1556
- config,
1557
- candidates: all
1558
- });
1559
- const trackedIds = new Set(resolveTrackedEventDeviceIds(blob, ids));
1560
- const devices = [];
1561
- for (const id of ids) {
1562
- const d = byId.get(id);
1563
- if (!d) continue;
1564
- devices.push({
1565
- deviceId: d.id,
1566
- name: d.name,
1567
- location: d.location,
1568
- features: d.features,
1569
- producesTrackedEvents: trackedIds.has(d.id)
1382
+ const results = [];
1383
+ const seen = /* @__PURE__ */ new Set();
1384
+ const childRows = await pctx.metaStore.rows.listByParent(parentDeviceId);
1385
+ const rowById = /* @__PURE__ */ new Map();
1386
+ for (const row of childRows) rowById.set(row.meta.id, row);
1387
+ if (pctx.registry) {
1388
+ const liveChildren = pctx.registry.getChildren(parentDeviceId);
1389
+ for (const device of liveChildren) {
1390
+ const key = String(device.id);
1391
+ const row = rowById.get(device.id);
1392
+ results.push(toDeviceInfo(ownerAddonId, device, row?.metadata ?? null, row?.meta ?? null));
1393
+ seen.add(key);
1394
+ }
1395
+ }
1396
+ for (const row of childRows) {
1397
+ const m = row.meta;
1398
+ const childStableId = m.stableId;
1399
+ const key = String(m.id);
1400
+ if (seen.has(key)) continue;
1401
+ const persistedConfig = await pctx.settings.readDeviceStore(m.id);
1402
+ const metadata = row.metadata;
1403
+ const sourceInfoChild = buildSourceInfoFromConfig(persistedConfig ?? {}, childStableId, ownerAddonId);
1404
+ results.push({
1405
+ id: m.id,
1406
+ stableId: childStableId,
1407
+ addonId: ownerAddonId,
1408
+ type: m.type,
1409
+ name: m.name,
1410
+ location: m.location ?? null,
1411
+ disabled: m.disabled ?? false,
1412
+ parentDeviceId,
1413
+ role: toDeviceRole(m.role),
1414
+ online: pctx.host.resolveDeviceOnline(m.id, pctx.registry !== null),
1415
+ probed: pctx.host.resolveDeviceProbed(m.id),
1416
+ features: persistedFeatures(m.features),
1417
+ isCamera: false,
1418
+ config: persistedConfig ?? {},
1419
+ metadata,
1420
+ ...m.integrationId !== void 0 ? { integrationId: m.integrationId } : {},
1421
+ ...m.linkDeviceId !== void 0 ? { linkDeviceId: m.linkDeviceId } : {},
1422
+ ...m.primaryChildEntityId !== void 0 ? { primaryChildEntityId: m.primaryChildEntityId } : {},
1423
+ ...m.childLayout !== void 0 ? { childLayout: m.childLayout } : {},
1424
+ ...m.display !== void 0 ? { display: m.display } : {},
1425
+ ...sourceInfoChild !== void 0 ? { sourceInfo: sourceInfoChild } : {}
1570
1426
  });
1571
1427
  }
1572
- return {
1573
- mode: config.mode,
1574
- devices
1575
- };
1428
+ return results;
1576
1429
  }
1577
- /**
1578
- * "Linked devices" settings section for a CAMERA (null for every other
1579
- * device type — the association is camera-anchored). Contributed through
1580
- * `collectSystemDeviceContributions`; saves route through the existing
1581
- * `writerCapName: 'device-manager'` path into the device config blob.
1582
- */
1583
- async function buildLinkedDevicesContribution(pctx, deviceId) {
1584
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1585
- if (!persisted || persisted.meta.type !== require_dist.DeviceType.Camera) return null;
1586
- const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
1587
- const config = parseLinkedDevicesConfig(blob);
1588
- const options = all.filter((d) => d.id !== deviceId).map((d) => ({
1589
- value: String(d.id),
1590
- label: d.location !== null ? `${d.name} (${d.location})` : d.name
1591
- }));
1592
- const linkedIds = resolveLinkedDeviceIds({
1593
- selfId: deviceId,
1594
- selfLocation: all.find((d) => d.id === deviceId)?.location ?? null,
1595
- config,
1596
- candidates: all
1597
- });
1598
- const byId = new Map(all.map((d) => [d.id, d]));
1599
- const trackedOptions = linkedIds.flatMap((id) => {
1600
- const d = byId.get(id);
1601
- if (!d) return [];
1602
- return [{
1603
- value: String(d.id),
1604
- label: d.location !== null ? `${d.name} (${d.location})` : d.name
1605
- }];
1606
- });
1607
- const trackedValue = resolveTrackedEventDeviceIds(blob, linkedIds).map(String);
1608
- return { sections: [{
1609
- id: "linked-devices",
1610
- title: "Linked devices",
1611
- description: "Companion devices associated with this camera. Device-tree children are always linked; auto mode also links every device sharing the camera location, manual mode links the picked list instead.",
1612
- order: 70,
1613
- fields: [
1614
- {
1615
- type: "select",
1616
- key: LINKED_MODE_CONFIG_KEY,
1617
- label: "Link mode",
1618
- description: "Auto: children + same-location devices. Manual: children + the list below.",
1619
- options: [{
1620
- value: "auto",
1621
- label: "Auto (location-based)"
1622
- }, {
1623
- value: "manual",
1624
- label: "Manual"
1625
- }],
1626
- value: config.mode
1627
- },
1628
- {
1629
- type: "multiselect",
1630
- key: LINKED_IDS_CONFIG_KEY,
1631
- label: "Manually linked devices",
1632
- description: "Used in manual mode (children stay linked regardless).",
1633
- showWhen: {
1634
- field: LINKED_MODE_CONFIG_KEY,
1635
- equals: "manual"
1636
- },
1637
- options,
1638
- value: config.manualIds.map(String)
1639
- },
1640
- {
1641
- type: "multiselect",
1642
- key: LINKED_TRACKED_IDS_CONFIG_KEY,
1643
- label: "Devices that produce tracked events",
1644
- description: "Linked devices whose state changes materialize a synthetic tracked event on this camera. Default: none (opt-in).",
1645
- options: trackedOptions,
1646
- value: trackedValue
1647
- }
1648
- ]
1649
- }] };
1430
+ async function getStreamSources(pctx, input) {
1431
+ const { deviceId } = input;
1432
+ if (pctx.registry) {
1433
+ const found = resolveDeviceById(pctx.registry, deviceId);
1434
+ if (found) {
1435
+ if (!isCameraDevice(found.device)) return [];
1436
+ return (await found.device.getStreamSources()).map((s) => ({
1437
+ id: s.id,
1438
+ label: s.label,
1439
+ protocol: s.protocol,
1440
+ url: s.url,
1441
+ resolution: s.resolution,
1442
+ fps: s.fps,
1443
+ bitrate: s.bitrate,
1444
+ codec: s.codec,
1445
+ profileHint: s.profileHint
1446
+ }));
1447
+ }
1448
+ }
1449
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1450
+ return (await pctx.requireDeviceOps(deviceId).getStreamSources({ deviceId })).map((s) => ({ ...s }));
1650
1451
  }
1651
- /**
1652
- * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
1653
- * The admin-ui save path reaches the device config blob via the
1654
- * `writerCapName: 'device-manager'` routing (never this method), but the cap
1655
- * contract includes the method — keep it correct for direct callers.
1656
- */
1657
- async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
1658
- const next = { ...await pctx.settings.readDeviceStore(deviceId) };
1659
- if ("_linkedDevicesMode" in patch) next[LINKED_MODE_CONFIG_KEY] = patch["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
1660
- if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
1661
- if ("_linkedDeviceTrackedIds" in patch) {
1662
- const raw = patch[LINKED_TRACKED_IDS_CONFIG_KEY];
1663
- const ids = [];
1664
- if (Array.isArray(raw)) for (const v of raw) {
1665
- const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1666
- if (Number.isInteger(n) && !ids.includes(n)) ids.push(n);
1452
+ async function getConfigSchema(pctx, input) {
1453
+ const { deviceId } = input;
1454
+ if (pctx.registry) {
1455
+ const found = resolveDeviceById(pctx.registry, deviceId);
1456
+ if (found) return found.device.config.entries().map((entry) => ({
1457
+ key: entry.key,
1458
+ value: entry.value,
1459
+ ...entry.description !== void 0 ? { description: entry.description } : {}
1460
+ }));
1461
+ }
1462
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1463
+ return (await pctx.requireDeviceOps(deviceId).getConfigEntries({ deviceId })).map((e) => ({ ...e }));
1464
+ }
1465
+ async function getSettingsSchema(pctx, input) {
1466
+ const { deviceId } = input;
1467
+ if (pctx.registry) {
1468
+ const found = resolveDeviceById(pctx.registry, deviceId);
1469
+ if (found) return found.device.getSettingsUISchema();
1470
+ }
1471
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) return null;
1472
+ return await pctx.requireDeviceOps(deviceId).getSettingsSchema({ deviceId }) ?? null;
1473
+ }
1474
+ async function updateConfig(pctx, input) {
1475
+ const { deviceId } = input;
1476
+ if (pctx.registry) {
1477
+ const found = resolveDeviceById(pctx.registry, deviceId);
1478
+ if (found) {
1479
+ await found.device.config.setAll(input.values);
1480
+ return { success: true };
1667
1481
  }
1668
- next[LINKED_TRACKED_IDS_CONFIG_KEY] = ids;
1669
1482
  }
1670
- await pctx.settings.writeDeviceStore(deviceId, next);
1483
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1484
+ await pctx.requireDeviceOps(deviceId).setConfig({
1485
+ deviceId,
1486
+ values: input.values
1487
+ });
1671
1488
  return { success: true };
1672
1489
  }
1673
- //#endregion
1674
- //#region src/builtins/device-manager/device-store-sections.ts
1675
- /** The device-manager's device-store-backed sections. Add an entry to extend. */
1676
- var DEVICE_STORE_SECTIONS = [{
1677
- keys: [
1678
- LINKED_MODE_CONFIG_KEY,
1679
- LINKED_IDS_CONFIG_KEY,
1680
- LINKED_TRACKED_IDS_CONFIG_KEY
1681
- ],
1682
- apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
1683
- }];
1684
- /**
1685
- * Split a `writerCapName: 'device-manager'` patch into device-store slices
1686
- * (one per matching registered section) and the leftover driver patch.
1687
- * Generic consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
1688
- */
1689
- function splitDeviceStoreKeys(patch) {
1690
- const claimed = /* @__PURE__ */ new Set();
1691
- const storeGroups = [];
1692
- for (const section of DEVICE_STORE_SECTIONS) {
1693
- const slice = {};
1694
- for (const key of section.keys) if (key in patch) {
1695
- slice[key] = patch[key];
1696
- claimed.add(key);
1490
+ async function enable(pctx, input) {
1491
+ await pctx.provider.setDisabled({
1492
+ deviceId: input.deviceId,
1493
+ disabled: false
1494
+ });
1495
+ return { success: true };
1496
+ }
1497
+ async function disable(pctx, input) {
1498
+ await pctx.provider.setDisabled({
1499
+ deviceId: input.deviceId,
1500
+ disabled: true
1501
+ });
1502
+ return { success: true };
1503
+ }
1504
+ async function remove(pctx, input) {
1505
+ const { deviceId } = input;
1506
+ const removeOne = async (id) => {
1507
+ if (pctx.registry) {
1508
+ const live = resolveDeviceById(pctx.registry, id);
1509
+ if (live) {
1510
+ const deviceName = live.device.name;
1511
+ await live.device.removeDevice();
1512
+ pctx.registry.remove(id);
1513
+ await pctx.provider.removeDevice({ deviceId: id });
1514
+ pctx.host.ctx.logger.info("removed hub-local device", { tags: {
1515
+ deviceId: id,
1516
+ deviceName
1517
+ } });
1518
+ return;
1519
+ }
1697
1520
  }
1698
- if (Object.keys(slice).length > 0) storeGroups.push({
1699
- section,
1700
- patch: slice
1701
- });
1702
- }
1703
- const driverPatch = {};
1704
- for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
1705
- return {
1706
- storeGroups,
1707
- driverPatch
1521
+ const persisted = await pctx.metaStore.resolvePersistedById(id);
1522
+ if (!persisted) return;
1523
+ const { meta: persistedMeta } = persisted;
1524
+ try {
1525
+ await pctx.requireDeviceOps(id).removeDevice({ deviceId: id });
1526
+ } catch (err) {
1527
+ pctx.host.ctx.logger.warn("remove via device-ops failed clearing persistence anyway", {
1528
+ tags: {
1529
+ deviceId: id,
1530
+ deviceName: persistedMeta.name
1531
+ },
1532
+ meta: { error: require_dist.errMsg(err) }
1533
+ });
1534
+ }
1535
+ await pctx.provider.removeDevice({ deviceId: id });
1536
+ };
1537
+ const removeCascade = async (id) => {
1538
+ for (const childId of await pctx.metaStore.directChildIds(id)) await removeCascade(childId);
1539
+ await removeOne(id);
1708
1540
  };
1541
+ await removeCascade(deviceId);
1542
+ return { success: true };
1709
1543
  }
1710
- //#endregion
1711
- //#region src/builtins/device-manager/device-bindings-store.ts
1712
1544
  /**
1713
- * Wire the push-fed cross-process native-cap cache (`remoteNativeCaps`).
1714
- * Workers emit `DeviceBindingsChanged` on `ctx.registerNativeCap` / device
1715
- * removal; we mirror those into `remoteNativeCaps` so `getBindings` returns the
1716
- * full cluster view. Events from the local node are ignored: hub-local natives
1717
- * live in `capabilityRegistry` and are folded in directly by getBindings.
1718
- *
1719
- * Push events are accurate in the steady state but can be lost during the
1720
- * Moleculer transport handshake window (hub restart, crash-respawn,
1721
- * restartAddon). The reliable replacement for lost events is the D3 re-handshake
1722
- * (`listClusterNativeCaps()`), which `resolveNativeCapOwnerSync` /
1723
- * `getBindings` step 4 fall through to on a push miss. The `$node.disconnected`
1724
- * handler purges a gone node's entries; the worker re-handshakes (and re-emits
1725
- * `native-registered`) on its next boot.
1545
+ * Cascade-delete every top-level device whose `integrationId`
1546
+ * matches. Enumerates a SNAPSHOT of the meta map so concurrent
1547
+ * removals don't clobber each other. Only top-level parents are
1548
+ * enumerated children cascade via the per-parent `removeCascade`
1549
+ * inside the delegated `remove` call. Idempotent: devices with no
1550
+ * `integrationId` never match.
1726
1551
  */
1727
- function wireRemoteNativeCapSync(ctx, remoteNativeCaps) {
1728
- const localNodeId = ctx.kernel.localNodeId ?? "hub";
1729
- ctx.eventBus.subscribe({ category: require_dist.EventCategory.DeviceBindingsChanged }, (event) => {
1730
- const { deviceId, capName, reason, addonId, nodeId } = event.data;
1731
- if (nodeId === localNodeId) return;
1732
- if (reason === "native-registered") {
1733
- let perDevice = remoteNativeCaps.get(deviceId);
1734
- if (!perDevice) {
1735
- perDevice = /* @__PURE__ */ new Map();
1736
- remoteNativeCaps.set(deviceId, perDevice);
1737
- }
1738
- perDevice.set(capName, {
1739
- addonId,
1740
- nodeId
1741
- });
1742
- } else if (reason === "native-unregistered") {
1743
- const perDevice = remoteNativeCaps.get(deviceId);
1744
- if (!perDevice) return;
1745
- perDevice.delete(capName);
1746
- if (perDevice.size === 0) remoteNativeCaps.delete(deviceId);
1552
+ async function removeByIntegration(pctx, input) {
1553
+ const { integrationId } = input;
1554
+ const parents = (await pctx.metaStore.rows.listByIntegration(integrationId)).filter((row) => row.meta.parentDeviceId === null);
1555
+ let removed = 0;
1556
+ for (const parent of parents) {
1557
+ await pctx.provider.remove({ deviceId: parent.meta.id });
1558
+ removed++;
1559
+ }
1560
+ return { removed };
1561
+ }
1562
+ async function getStreamProfileMap(pctx, input) {
1563
+ if (!pctx.registry) return {};
1564
+ const found = resolveDeviceById(pctx.registry, input.deviceId);
1565
+ if (!found) return {};
1566
+ const storedMap = found.device.config.entries().find((e) => e.key === "_profileMap")?.value;
1567
+ if (storedMap !== void 0 && typeof storedMap === "object" && storedMap !== null) return storedMap;
1568
+ if (!isCameraDevice(found.device)) return {};
1569
+ const sources = await found.device.getStreamSources();
1570
+ const profileMap = {};
1571
+ for (const s of sources) if (s.profileHint && s.id) profileMap[s.profileHint] = s.id;
1572
+ return profileMap;
1573
+ }
1574
+ async function setStreamProfileMap(pctx, input) {
1575
+ const { deviceId } = input;
1576
+ if (pctx.registry) {
1577
+ const found = resolveDeviceById(pctx.registry, deviceId);
1578
+ if (found) {
1579
+ await found.device.config.setAll({ _profileMap: input.profileMap });
1580
+ return { success: true };
1747
1581
  }
1582
+ }
1583
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] Device with id ${deviceId} not found`);
1584
+ await pctx.requireDeviceOps(deviceId).setConfig({
1585
+ deviceId,
1586
+ values: { _profileMap: input.profileMap }
1748
1587
  });
1749
- const cluster = ctx.kernel.cluster;
1750
- if (cluster) cluster.broker.localBus.on("$node.disconnected", (payload) => {
1751
- const gone = payload.node.id;
1752
- const emptyDevices = [];
1753
- for (const [deviceId, perDevice] of remoteNativeCaps) {
1754
- const toDelete = [];
1755
- for (const [capName, entry] of perDevice) if (entry.nodeId === gone) toDelete.push(capName);
1756
- for (const capName of toDelete) perDevice.delete(capName);
1757
- if (perDevice.size === 0) emptyDevices.push(deviceId);
1588
+ return { success: true };
1589
+ }
1590
+ async function probeStreams(pctx, input) {
1591
+ const streamProbe = pctx.host.ctx.kernel.streamProbe;
1592
+ if (!streamProbe) return [];
1593
+ const sources = await pctx.provider.getStreamSources({ deviceId: input.deviceId });
1594
+ const results = [];
1595
+ for (const s of sources) {
1596
+ if (!s.url) continue;
1597
+ try {
1598
+ const metadata = await streamProbe.probe(s.url, { force: true });
1599
+ results.push({
1600
+ streamId: s.id,
1601
+ width: metadata.width,
1602
+ height: metadata.height,
1603
+ codec: metadata.codec,
1604
+ fps: metadata.fps,
1605
+ bitrateKbps: metadata.bitrateKbps
1606
+ });
1607
+ } catch (err) {
1608
+ pctx.host.ctx.logger.debug("streamProbe.probe failed — returning placeholder", { meta: {
1609
+ deviceId: input.deviceId,
1610
+ streamId: s.id,
1611
+ error: err instanceof Error ? err.message : String(err)
1612
+ } });
1613
+ results.push({ streamId: s.id });
1758
1614
  }
1759
- for (const deviceId of emptyDevices) remoteNativeCaps.delete(deviceId);
1615
+ }
1616
+ return results;
1617
+ }
1618
+ async function discoverDevices(pctx, input) {
1619
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1620
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device discovery`);
1621
+ return (await dp.discoverDevices({})).map((d) => ({
1622
+ stableId: d.stableId,
1623
+ type: d.type,
1624
+ suggestedName: d.suggestedName,
1625
+ prefilledConfig: d.prefilledConfig
1626
+ }));
1627
+ }
1628
+ async function adoptDevice(pctx, input) {
1629
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1630
+ if (!await dp.supportsDiscovery({})) throw new Error(`Addon "${input.addonId}" does not support device adoption`);
1631
+ const summary = await dp.adoptDiscoveredDevice({ candidate: input.candidate });
1632
+ if (input.integrationId !== void 0) try {
1633
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
1634
+ } catch (err) {
1635
+ pctx.host.ctx.logger.warn("adoptDevice: integrationId stamp failed (device adopted)", {
1636
+ tags: {
1637
+ deviceId: summary.id,
1638
+ integrationId: input.integrationId
1639
+ },
1640
+ meta: { error: require_dist.errMsg(err) }
1641
+ });
1642
+ }
1643
+ return summary;
1644
+ }
1645
+ async function getCreationSchema(pctx, input) {
1646
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1647
+ if (!await dp.supportsManualCreation({})) return null;
1648
+ return await dp.getChildCreationSchema({ type: input.type }) ?? null;
1649
+ }
1650
+ async function createDevice(pctx, input) {
1651
+ const dp = await pctx.host.requireDeviceProvider(input.addonId);
1652
+ if (!await dp.supportsManualCreation({})) throw new Error(`Addon "${input.addonId}" does not support manual device creation`);
1653
+ const summary = await dp.createDevice({
1654
+ type: input.type,
1655
+ config: input.config
1760
1656
  });
1657
+ if (input.integrationId !== void 0) try {
1658
+ await pctx.stampIntegrationId(summary.id, input.integrationId);
1659
+ } catch (err) {
1660
+ pctx.host.ctx.logger.warn("createDevice: integrationId stamp failed (device created)", {
1661
+ tags: {
1662
+ deviceId: summary.id,
1663
+ integrationId: input.integrationId
1664
+ },
1665
+ meta: { error: require_dist.errMsg(err) }
1666
+ });
1667
+ }
1668
+ return summary;
1761
1669
  }
1762
- async function readBindingsStore(deps) {
1763
- return { deviceBindings: (await deps.ctx.settings.readAddonStore()).deviceBindings ?? {} };
1670
+ async function testCreationField(pctx, input) {
1671
+ return (await pctx.host.requireDeviceProvider(input.addonId)).testCreationField({
1672
+ type: input.type,
1673
+ key: input.key,
1674
+ value: input.value,
1675
+ ...input.formValues !== void 0 ? { formValues: input.formValues } : {}
1676
+ });
1764
1677
  }
1765
- async function writeBindingsStore(deps, next) {
1766
- await deps.ctx.settings.writeAddonStore({ deviceBindings: next.deviceBindings });
1678
+ async function adoptionListCandidates(pctx, input) {
1679
+ const { addonId, ...rest } = input;
1680
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).listCandidates(rest);
1767
1681
  }
1768
- function resolveWrapperNodeId(_wrapperAddonId) {
1769
- return "hub";
1682
+ async function adoptionRefresh(pctx, input) {
1683
+ const { addonId, integrationId } = input;
1684
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).refresh({ integrationId });
1770
1685
  }
1771
- /**
1772
- * Reduce a provider node id to the routable form `DeviceProxy` can pin.
1773
- *
1774
- * Every addon runs in its own `addon-runner` with the composite node id
1775
- * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
1776
- * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
1777
- * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
1778
- * only THROUGH its parent (the hub resolves a hub-local-uds child by
1779
- * cap+device; an agent forwards to its own child). `DeviceProxy` pins
1780
- * `entry.providerNodeId` on every cap call, so a binding entry must expose the
1781
- * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
1782
- * to an unknown node → `no-provider`, which surfaces as
1783
- * "this camera doesn't expose …" for client-proxy-driven widget caps
1784
- * (motion-zones, privacy-mask). Wrappers already report the parent via
1785
- * `resolveWrapperNodeId`; this aligns natives with the same contract.
1786
- *
1787
- * A flat node id (a genuine standalone node with no `/`) is returned
1788
- * unchanged.
1789
- */
1790
- function toRoutableProviderNodeId(nodeId) {
1791
- const slash = nodeId.indexOf("/");
1792
- return slash === -1 ? nodeId : nodeId.slice(0, slash);
1686
+ async function adoptionAdopt(pctx, input) {
1687
+ const { addonId, ...rest } = input;
1688
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).adopt(rest);
1793
1689
  }
1794
- /**
1795
- * Resolve a remote native cap entry for a given `(capName, deviceId)` by
1796
- * consulting the handshake-fed `HubNodeRegistry` via
1797
- * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
1798
- * `remoteNativeCaps` cache misses — covers the Moleculer transport
1799
- * handshake window where `DeviceBindingsChanged` events were lost but the
1800
- * D3 re-handshake (post device restore) has already populated the registry.
1801
- *
1802
- * Returns `null` when the entry is genuinely not present in the cluster
1803
- * view (cap not registered on any worker for that device).
1804
- */
1805
- function resolveRemoteNativeCapFromRegistry(deps, capName, deviceId) {
1806
- const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1807
- if (!clusterCaps) return null;
1808
- for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
1809
- addonId: entry.addonId,
1810
- nodeId: entry.nodeId
1811
- };
1812
- return null;
1690
+ async function adoptionRelease(pctx, input) {
1691
+ const { addonId, ...rest } = input;
1692
+ return (await pctx.host.requireDeviceAdoptionProvider(addonId)).release(rest);
1813
1693
  }
1814
- /**
1815
- * Resolve the device's declared TYPE (`'camera'`, `'event-emitter'`, …), or
1816
- * `undefined` when it cannot be established.
1817
- *
1818
- * The PERSISTED meta is the authority: `ctx.kernel.deviceRegistry` is hub-only
1819
- * and has been observed empty in the very process that answers `getBindings`
1820
- * correctly (see the note on `getAllBindings`). The registry is consulted only
1821
- * as a secondary source, for a device constructed but not yet persisted.
1822
- *
1823
- * `undefined` is a first-class answer and callers must treat it as "no
1824
- * filtering" — an absent row must never be the reason a device loses bindings.
1825
- */
1826
- function resolveDeviceType(deps, rawStore, deviceId) {
1827
- const persisted = rawStore.deviceMeta?.[String(deviceId)]?.type;
1828
- if (typeof persisted === "string" && persisted.length > 0) return persisted;
1829
- const live = deps.ctx.kernel?.deviceRegistry?.getById(deviceId)?.type;
1830
- return typeof live === "string" && live.length > 0 ? live : void 0;
1694
+ async function adoptionStartJob(pctx, input) {
1695
+ const { addonId, integrationId, childNativeIds, filter, importLocations, perCandidate } = input;
1696
+ return pctx.host.adoptionJobs.start({
1697
+ addonId,
1698
+ integrationId,
1699
+ childNativeIds,
1700
+ ...filter !== void 0 ? { filter } : {},
1701
+ ...importLocations !== void 0 ? { importLocations } : {},
1702
+ ...perCandidate !== void 0 ? { perCandidate } : {}
1703
+ });
1831
1704
  }
1832
- /**
1833
- * Is this device still part of the fleet?
1834
- *
1835
- * The device-manager's own stores are the authority in-process — no RPC. Two
1836
- * sources, in the same order `resolveDeviceType` uses them: the live registry
1837
- * first (a device constructed but not yet persisted is present), then the
1838
- * PERSISTED meta, which is the index `getBindings` already reads and is present
1839
- * wherever this provider runs.
1840
- *
1841
- * `'absent'` is only ever returned against a NON-EMPTY meta store. An empty one
1842
- * means device restore has not run, not that the fleet was deleted (D49) — the
1843
- * same reason `getAllBindings` warns instead of reporting zero devices.
1844
- */
1845
- function resolveDevicePresence(deps, rawStore, deviceId) {
1846
- if (deps.ctx.kernel?.deviceRegistry?.getById(deviceId)) return "present";
1847
- const meta = rawStore.deviceMeta;
1848
- if (meta && Object.keys(meta).length > 0) return meta[String(deviceId)] === void 0 ? "absent" : "present";
1849
- return "unknown";
1705
+ async function adoptionListJobs(pctx, input) {
1706
+ const { addonId, integrationId } = input;
1707
+ return pctx.host.adoptionJobs.list({
1708
+ addonId,
1709
+ ...integrationId !== void 0 ? { integrationId } : {}
1710
+ });
1850
1711
  }
1851
- /**
1852
- * Does a capability apply to a device of type `deviceType`?
1853
- *
1854
- * The cap's `deviceTypes` is the ONLY declaration consulted (D4: behavioural
1855
- * cap metadata lives in the `*.cap.ts`, never in a manifest). Two deliberate
1856
- * fail-open cases:
1857
- *
1858
- * - a cap that declares no `deviceTypes` (or an empty list) applies to every
1859
- * device — the pre-existing, back-compatible semantics;
1860
- * - an UNKNOWN `deviceType` never filters, so a missing/failed meta lookup
1861
- * changes nothing (D49: a read that fails must not destroy work).
1862
- */
1863
- function capAppliesToDeviceType(def, deviceType) {
1864
- if (deviceType === void 0) return true;
1865
- const declared = def?.deviceTypes;
1866
- if (!declared || declared.length === 0) return true;
1867
- return declared.some((t) => t === deviceType);
1712
+ async function adoptionCancelJob(pctx, input) {
1713
+ return { cancelled: pctx.host.adoptionJobs.cancel(input.jobId) };
1868
1714
  }
1869
- async function getBindings(deps, input) {
1870
- const storeKey = String(input.deviceId);
1871
- const rawStore = await deps.ctx.settings.readAddonStore();
1872
- const perDevice = (rawStore.deviceBindings ?? {})[storeKey] ?? {};
1873
- const deviceType = resolveDeviceType(deps, rawStore, input.deviceId);
1874
- const presence = resolveDevicePresence(deps, rawStore, input.deviceId);
1875
- const entries = [];
1876
- const seenCaps = /* @__PURE__ */ new Set();
1877
- const resolveRemote = (capName) => deps.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? resolveRemoteNativeCapFromRegistry(deps, capName, input.deviceId);
1878
- for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
1879
- const hubLocalNative = deps.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
1880
- const remoteNative = resolveRemote(capName);
1881
- const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1882
- const nativeNodeId = hubLocalNative ? deps.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? deps.ctx.kernel.localNodeId ?? "hub";
1883
- if (wrapperAddonId === null && !nativeAddonId) {
1884
- seenCaps.add(capName);
1885
- continue;
1715
+ async function adoptionResync(pctx, input) {
1716
+ const { camDeviceId, resetToSource } = input;
1717
+ let owningAddonId = pctx.registry?.getAddonId(camDeviceId) ?? null;
1718
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(camDeviceId))?.addonId ?? null;
1719
+ if (!owningAddonId) throw new Error(`adoptionResync: device ${camDeviceId} not found`);
1720
+ let removedChildren = 0;
1721
+ if (resetToSource === true) {
1722
+ const childIds = await pctx.metaStore.directChildIds(camDeviceId);
1723
+ for (const childId of childIds) {
1724
+ await pctx.provider.remove({ deviceId: childId });
1725
+ removedChildren += 1;
1886
1726
  }
1887
- entries.push({
1888
- capName,
1889
- kind: wrapperAddonId ? "wrapped" : "native",
1890
- providerAddonId: wrapperAddonId ?? nativeAddonId,
1891
- providerNodeId: wrapperAddonId ? resolveWrapperNodeId(wrapperAddonId) : toRoutableProviderNodeId(nativeNodeId),
1892
- nativeAddonId
1893
- });
1894
- seenCaps.add(capName);
1727
+ pctx.host.ctx.logger.info("resetToSource purge before resync", { tags: {
1728
+ deviceId: camDeviceId,
1729
+ removedChildren
1730
+ } });
1731
+ }
1732
+ return {
1733
+ ...await (await pctx.host.requireDeviceAdoptionProvider(owningAddonId)).resync({
1734
+ camDeviceId,
1735
+ resetToSource
1736
+ }),
1737
+ removedChildren
1738
+ };
1739
+ }
1740
+ async function testField(pctx, input) {
1741
+ const { deviceId } = input;
1742
+ let owningAddonId = null;
1743
+ if (pctx.registry) owningAddonId = pctx.registry.getAddonId(deviceId);
1744
+ if (!owningAddonId) owningAddonId = (await pctx.metaStore.resolvePersistedById(deviceId))?.addonId ?? null;
1745
+ if (!owningAddonId) throw new Error(`Device with id ${deviceId} not found`);
1746
+ const dp = await pctx.host.waitDeviceProvider(owningAddonId);
1747
+ if (!dp) return {
1748
+ status: "ok",
1749
+ labels: [],
1750
+ error: void 0
1751
+ };
1752
+ if (typeof dp.testCreationField !== "function") return {
1753
+ status: "ok",
1754
+ labels: [],
1755
+ error: void 0
1756
+ };
1757
+ return dp.testCreationField({
1758
+ type: require_dist.DeviceType.Camera,
1759
+ key: input.key,
1760
+ value: input.value
1761
+ });
1762
+ }
1763
+ //#endregion
1764
+ //#region src/builtins/device-manager/device-linked-devices.ts
1765
+ /** Reserved device-config keys (cf. `_profileMap`). */
1766
+ var LINKED_MODE_CONFIG_KEY = "_linkedDevicesMode";
1767
+ var LINKED_IDS_CONFIG_KEY = "_linkedDeviceIds";
1768
+ /** Reserved key: subset of linked devices that materialize synthetic tracked
1769
+ * events on the camera. Absent ⇒ NONE — producing tracked events is an
1770
+ * explicit operator OPT-IN (2026-07-22 flip: the old absent⇒ALL default
1771
+ * auto-materialized sensor snapshots the operator never asked for). */
1772
+ var LINKED_TRACKED_IDS_CONFIG_KEY = "_linkedDeviceTrackedIds";
1773
+ /** Parse the two reserved keys out of a device-config blob. Tolerant:
1774
+ * ids may arrive as numbers or numeric strings (the multiselect field
1775
+ * stores string option values); anything else is dropped. */
1776
+ function parseLinkedDevicesConfig(blob) {
1777
+ const mode = blob["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
1778
+ const rawIds = blob[LINKED_IDS_CONFIG_KEY];
1779
+ const manualIds = [];
1780
+ if (Array.isArray(rawIds)) for (const v of rawIds) {
1781
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1782
+ if (Number.isInteger(n) && !manualIds.includes(n)) manualIds.push(n);
1895
1783
  }
1896
- if (presence === "absent") deps.ctx.logger.debug("bindings requested for absent device — returning none", { tags: { deviceId: input.deviceId } });
1897
- else if (deps.capabilityRegistry) {
1898
- const skippedForType = [];
1899
- for (const capName of deps.capabilityRegistry.getCapsWithDefaultWrapper()) {
1900
- if (seenCaps.has(capName)) continue;
1901
- if (!capAppliesToDeviceType(deps.capabilityRegistry.getDefinition(capName), deviceType)) {
1902
- skippedForType.push(capName);
1903
- continue;
1904
- }
1905
- const defaultWrapperAddonId = deps.capabilityRegistry.getDefaultWrapperForCap(capName);
1906
- if (!defaultWrapperAddonId) continue;
1907
- const hubLocalNative = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
1908
- const remoteNative = resolveRemote(capName);
1909
- const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
1910
- entries.push({
1911
- capName,
1912
- kind: "wrapped",
1913
- providerAddonId: defaultWrapperAddonId,
1914
- providerNodeId: resolveWrapperNodeId(defaultWrapperAddonId),
1915
- nativeAddonId
1916
- });
1917
- seenCaps.add(capName);
1784
+ return {
1785
+ mode,
1786
+ manualIds
1787
+ };
1788
+ }
1789
+ /**
1790
+ * Pure resolution core: children ∪ (auto → same-location | manual → list),
1791
+ * minus self, deduped. Order: children first, then mode additions.
1792
+ */
1793
+ function resolveLinkedDeviceIds(params) {
1794
+ const { selfId, selfLocation, config, candidates } = params;
1795
+ const ids = /* @__PURE__ */ new Set();
1796
+ for (const c of candidates) if (c.parentDeviceId === selfId) ids.add(c.id);
1797
+ if (config.mode === "auto") {
1798
+ if (selfLocation !== null && selfLocation.length > 0) {
1799
+ for (const c of candidates) if (c.location === selfLocation) ids.add(c.id);
1918
1800
  }
1919
- if (skippedForType.length > 0) deps.ctx.logger.debug("getBindings: default wrappers skipped — deviceTypes mismatch", {
1920
- tags: { deviceId: input.deviceId },
1921
- meta: {
1922
- deviceType,
1923
- skipped: skippedForType
1924
- }
1925
- });
1926
- }
1927
- if (deps.capabilityRegistry) for (const capName of deps.capabilityRegistry.getNativeCapsForDevice(input.deviceId)) {
1928
- if (seenCaps.has(capName)) continue;
1929
- const nativeAddonId = deps.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? "";
1930
- entries.push({
1931
- capName,
1932
- kind: "native",
1933
- providerAddonId: nativeAddonId,
1934
- providerNodeId: deps.ctx.kernel.localNodeId ?? "hub",
1935
- nativeAddonId
1936
- });
1937
- seenCaps.add(capName);
1801
+ } else {
1802
+ const known = new Set(candidates.map((c) => c.id));
1803
+ for (const id of config.manualIds) if (known.has(id)) ids.add(id);
1938
1804
  }
1939
- const pushFed = deps.remoteNativeCaps.get(input.deviceId);
1940
- if (pushFed) for (const [capName, info] of pushFed) {
1941
- if (seenCaps.has(capName)) continue;
1942
- entries.push({
1943
- capName,
1944
- kind: "native",
1945
- providerAddonId: info.addonId,
1946
- providerNodeId: toRoutableProviderNodeId(info.nodeId),
1947
- nativeAddonId: info.addonId
1948
- });
1949
- seenCaps.add(capName);
1805
+ ids.delete(selfId);
1806
+ return [...ids];
1807
+ }
1808
+ /**
1809
+ * Which linked devices materialize synthetic tracked events on the camera.
1810
+ * Absent / non-array config key ⇒ NONE — the operator must explicitly pick
1811
+ * producers (opt-in; flipped from the legacy absent⇒ALL default, which
1812
+ * auto-materialized sensor snapshots for every linked device). Otherwise the
1813
+ * configured subset intersected with the currently-resolved linked set (a
1814
+ * picked id no longer linked is dropped). Ids may arrive as numbers or numeric
1815
+ * strings (the multiselect stores string option values); non-numeric entries
1816
+ * are ignored.
1817
+ */
1818
+ function resolveTrackedEventDeviceIds(config, linkedIds) {
1819
+ const raw = config[LINKED_TRACKED_IDS_CONFIG_KEY];
1820
+ if (!Array.isArray(raw)) return [];
1821
+ const picked = /* @__PURE__ */ new Set();
1822
+ for (const v of raw) {
1823
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1824
+ if (Number.isInteger(n)) picked.add(n);
1950
1825
  }
1951
- const clusterCaps = deps.ctx.kernel.listClusterNativeCapsForDevice?.(input.deviceId) ?? deps.ctx.kernel.listClusterNativeCaps?.();
1952
- if (clusterCaps) for (const entry of clusterCaps) {
1953
- if (entry.deviceId !== input.deviceId) continue;
1954
- if (seenCaps.has(entry.capName)) continue;
1955
- if (!entry.addonId) continue;
1956
- const localNodeId = deps.ctx.kernel.localNodeId ?? "hub";
1957
- if (entry.nodeId === localNodeId) continue;
1958
- entries.push({
1959
- capName: entry.capName,
1960
- kind: "native",
1961
- providerAddonId: entry.addonId,
1962
- providerNodeId: toRoutableProviderNodeId(entry.nodeId),
1963
- nativeAddonId: entry.addonId
1826
+ return linkedIds.filter((id) => picked.has(id));
1827
+ }
1828
+ /** Cap implementation of `getLinkedDevices`. */
1829
+ async function getLinkedDevices(pctx, input) {
1830
+ const { deviceId } = input;
1831
+ const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
1832
+ const config = parseLinkedDevicesConfig(blob);
1833
+ const byId = new Map(all.map((d) => [d.id, d]));
1834
+ const ids = resolveLinkedDeviceIds({
1835
+ selfId: deviceId,
1836
+ selfLocation: byId.get(deviceId)?.location ?? null,
1837
+ config,
1838
+ candidates: all
1839
+ });
1840
+ const trackedIds = new Set(resolveTrackedEventDeviceIds(blob, ids));
1841
+ const devices = [];
1842
+ for (const id of ids) {
1843
+ const d = byId.get(id);
1844
+ if (!d) continue;
1845
+ devices.push({
1846
+ deviceId: d.id,
1847
+ name: d.name,
1848
+ location: d.location,
1849
+ features: d.features,
1850
+ producesTrackedEvents: trackedIds.has(d.id)
1964
1851
  });
1965
- seenCaps.add(entry.capName);
1966
1852
  }
1967
1853
  return {
1968
- deviceId: input.deviceId,
1969
- entries
1854
+ mode: config.mode,
1855
+ devices
1970
1856
  };
1971
1857
  }
1972
1858
  /**
1973
- * Whole-fleet binding dump. Iterates every device known to the
1974
- * deviceRegistry and reuses the per-device `getBindings` resolver
1975
- * for each same routing rules, single round-trip. Used by
1976
- * `SystemManager.init()` for warm-boot.
1977
- *
1978
- * Bindings change rarely (wrapper toggle, device add/remove) so
1979
- * clients invalidate via the existing
1980
- * `capability.binding-changed` event rather than re-fetching this
1981
- * payload periodically.
1859
+ * "Linked devices" settings section for a CAMERA (null for every other
1860
+ * device type the association is camera-anchored). Contributed through
1861
+ * `collectSystemDeviceContributions`; saves route through the existing
1862
+ * `writerCapName: 'device-manager'` path into the device config blob.
1982
1863
  */
1983
- async function getAllBindings(deps) {
1984
- const store = await deps.ctx.settings.readAddonStore();
1985
- const ids = /* @__PURE__ */ new Set();
1986
- for (const key of Object.keys(store.deviceMeta ?? {})) {
1987
- const id = Number(key);
1988
- if (Number.isInteger(id)) ids.add(id);
1989
- }
1990
- const registered = deps.ctx.kernel?.deviceRegistry?.getAll() ?? [];
1991
- for (const device of registered) ids.add(device.id);
1992
- if (ids.size === 0) {
1993
- deps.ctx.logger.warn("getAllBindings found no devices — warm boot will see nothing", { meta: { hasRegistry: deps.ctx.kernel?.deviceRegistry !== void 0 } });
1994
- return [];
1864
+ async function buildLinkedDevicesContribution(pctx, deviceId) {
1865
+ const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
1866
+ if (!persisted || persisted.meta.type !== require_dist.DeviceType.Camera) return null;
1867
+ const [all, blob] = await Promise.all([listAll(pctx, {}), pctx.settings.readDeviceStore(deviceId)]);
1868
+ const config = parseLinkedDevicesConfig(blob);
1869
+ const options = all.filter((d) => d.id !== deviceId).map((d) => ({
1870
+ value: String(d.id),
1871
+ label: d.location !== null ? `${d.name} (${d.location})` : d.name
1872
+ }));
1873
+ const linkedIds = resolveLinkedDeviceIds({
1874
+ selfId: deviceId,
1875
+ selfLocation: all.find((d) => d.id === deviceId)?.location ?? null,
1876
+ config,
1877
+ candidates: all
1878
+ });
1879
+ const byId = new Map(all.map((d) => [d.id, d]));
1880
+ const trackedOptions = linkedIds.flatMap((id) => {
1881
+ const d = byId.get(id);
1882
+ if (!d) return [];
1883
+ return [{
1884
+ value: String(d.id),
1885
+ label: d.location !== null ? `${d.name} (${d.location})` : d.name
1886
+ }];
1887
+ });
1888
+ const trackedValue = resolveTrackedEventDeviceIds(blob, linkedIds).map(String);
1889
+ return { sections: [{
1890
+ id: "linked-devices",
1891
+ title: "Linked devices",
1892
+ description: "Companion devices associated with this camera. Device-tree children are always linked; auto mode also links every device sharing the camera location, manual mode links the picked list instead.",
1893
+ order: 70,
1894
+ fields: [
1895
+ {
1896
+ type: "select",
1897
+ key: LINKED_MODE_CONFIG_KEY,
1898
+ label: "Link mode",
1899
+ description: "Auto: children + same-location devices. Manual: children + the list below.",
1900
+ options: [{
1901
+ value: "auto",
1902
+ label: "Auto (location-based)"
1903
+ }, {
1904
+ value: "manual",
1905
+ label: "Manual"
1906
+ }],
1907
+ value: config.mode
1908
+ },
1909
+ {
1910
+ type: "multiselect",
1911
+ key: LINKED_IDS_CONFIG_KEY,
1912
+ label: "Manually linked devices",
1913
+ description: "Used in manual mode (children stay linked regardless).",
1914
+ showWhen: {
1915
+ field: LINKED_MODE_CONFIG_KEY,
1916
+ equals: "manual"
1917
+ },
1918
+ options,
1919
+ value: config.manualIds.map(String)
1920
+ },
1921
+ {
1922
+ type: "multiselect",
1923
+ key: LINKED_TRACKED_IDS_CONFIG_KEY,
1924
+ label: "Devices that produce tracked events",
1925
+ description: "Linked devices whose state changes materialize a synthetic tracked event on this camera. Default: none (opt-in).",
1926
+ options: trackedOptions,
1927
+ value: trackedValue
1928
+ }
1929
+ ]
1930
+ }] };
1931
+ }
1932
+ /**
1933
+ * Direct `applyDeviceSettingsPatch` normalization for the two reserved keys.
1934
+ * The admin-ui save path reaches the device config blob via the
1935
+ * `writerCapName: 'device-manager'` routing (never this method), but the cap
1936
+ * contract includes the method — keep it correct for direct callers.
1937
+ */
1938
+ async function applyLinkedDevicesPatch(pctx, deviceId, patch) {
1939
+ const next = { ...await pctx.settings.readDeviceStore(deviceId) };
1940
+ if ("_linkedDevicesMode" in patch) next[LINKED_MODE_CONFIG_KEY] = patch["_linkedDevicesMode"] === "manual" ? "manual" : "auto";
1941
+ if ("_linkedDeviceIds" in patch) next[LINKED_IDS_CONFIG_KEY] = [...parseLinkedDevicesConfig({ [LINKED_IDS_CONFIG_KEY]: patch[LINKED_IDS_CONFIG_KEY] }).manualIds];
1942
+ if ("_linkedDeviceTrackedIds" in patch) {
1943
+ const raw = patch[LINKED_TRACKED_IDS_CONFIG_KEY];
1944
+ const ids = [];
1945
+ if (Array.isArray(raw)) for (const v of raw) {
1946
+ const n = typeof v === "number" ? v : typeof v === "string" ? Number(v) : NaN;
1947
+ if (Number.isInteger(n) && !ids.includes(n)) ids.push(n);
1948
+ }
1949
+ next[LINKED_TRACKED_IDS_CONFIG_KEY] = ids;
1995
1950
  }
1996
- const out = [];
1997
- for (const deviceId of [...ids].sort((a, b) => a - b)) out.push(await getBindings(deps, { deviceId }));
1998
- return out;
1951
+ await pctx.settings.writeDeviceStore(deviceId, next);
1952
+ return { success: true };
1999
1953
  }
1954
+ //#endregion
1955
+ //#region src/builtins/device-manager/device-store-sections.ts
1956
+ /** The device-manager's device-store-backed sections. Add an entry to extend. */
1957
+ var DEVICE_STORE_SECTIONS = [{
1958
+ keys: [
1959
+ LINKED_MODE_CONFIG_KEY,
1960
+ LINKED_IDS_CONFIG_KEY,
1961
+ LINKED_TRACKED_IDS_CONFIG_KEY
1962
+ ],
1963
+ apply: (pctx, deviceId, patch) => applyLinkedDevicesPatch(pctx, deviceId, patch)
1964
+ }];
2000
1965
  /**
2001
- * Resolve a numeric deviceId to a stableId via persisted meta.
2002
- * Used only by the device-identity section of the device-details
2003
- * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
2004
- * a readonly display field. All runtime/registry lookups are keyed by
2005
- * numeric deviceId; this helper is display-only.
1966
+ * Split a `writerCapName: 'device-manager'` patch into device-store slices
1967
+ * (one per matching registered section) and the leftover driver patch.
1968
+ * Generic consults {@link DEVICE_STORE_SECTIONS}, no hardcoded key list.
2006
1969
  */
2007
- async function lookupPersistedStableId(deps, deviceId) {
2008
- return ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.stableId;
1970
+ function splitDeviceStoreKeys(patch) {
1971
+ const claimed = /* @__PURE__ */ new Set();
1972
+ const storeGroups = [];
1973
+ for (const section of DEVICE_STORE_SECTIONS) {
1974
+ const slice = {};
1975
+ for (const key of section.keys) if (key in patch) {
1976
+ slice[key] = patch[key];
1977
+ claimed.add(key);
1978
+ }
1979
+ if (Object.keys(slice).length > 0) storeGroups.push({
1980
+ section,
1981
+ patch: slice
1982
+ });
1983
+ }
1984
+ const driverPatch = {};
1985
+ for (const [key, value] of Object.entries(patch)) if (!claimed.has(key)) driverPatch[key] = value;
1986
+ return {
1987
+ storeGroups,
1988
+ driverPatch
1989
+ };
2009
1990
  }
2010
1991
  //#endregion
2011
1992
  //#region src/builtins/device-manager/device-aggregation.ts
@@ -2795,7 +2776,7 @@ async function getWireableFields(deps, input) {
2795
2776
  if (wireable) caps.push(wireable);
2796
2777
  }
2797
2778
  if (input.includeSynthesizable === true) {
2798
- const deviceType = ((await deps.ctx.settings.readAddonStore()).deviceMeta ?? {})[String(deviceId)]?.type;
2779
+ const deviceType = (await deps.bindingsDeps.rows.get(deviceId))?.meta.type;
2799
2780
  if (deviceType !== void 0) for (const def of require_dist.ALL_CAPABILITY_DEFINITIONS) {
2800
2781
  if (seen.has(def.name)) continue;
2801
2782
  if (def.scope !== "device" || def.kind === "wrapper") continue;
@@ -2949,20 +2930,10 @@ var DeviceEventPropagator = class {
2949
2930
  * because `ProviderContext.stampIntegrationId` delegates HERE — passing the
2950
2931
  * context would be a capture cycle.
2951
2932
  */
2952
- async function stampIntegrationId(metaStore, settings, ctx, deviceId, integrationId) {
2933
+ async function stampIntegrationId(metaStore, ctx, deviceId, integrationId) {
2953
2934
  await metaStore.withMetaWriteLock(async () => {
2954
- const persisted = await metaStore.resolvePersistedById(deviceId);
2955
- if (!persisted) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
2956
- const { meta: m } = persisted;
2957
- const key = String(deviceId);
2958
- const allMeta = await metaStore.readMeta();
2959
- await settings.writeAddonStore({ deviceMeta: {
2960
- ...allMeta,
2961
- [key]: {
2962
- ...m,
2963
- integrationId
2964
- }
2965
- } });
2935
+ if (!await metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] stampIntegrationId: unknown device id=${deviceId}`);
2936
+ await metaStore.rows.patch(deviceId, { integrationId });
2966
2937
  });
2967
2938
  ctx.eventBus.emit({
2968
2939
  id: (0, node_crypto.randomUUID)(),
@@ -2982,70 +2953,54 @@ async function stampIntegrationId(metaStore, settings, ctx, deviceId, integratio
2982
2953
  async function allocateDeviceId(pctx, input) {
2983
2954
  const { addonId, stableId } = input;
2984
2955
  return await pctx.metaStore.withMetaWriteLock(async () => {
2985
- const meta = await pctx.metaStore.readMeta();
2986
- const existing = Object.values(meta).find((m) => m.addonId === addonId && m.stableId === stableId);
2987
- if (existing) return { id: existing.id };
2956
+ const existing = await pctx.metaStore.rows.findByStableId(addonId, stableId);
2957
+ if (existing) return { id: existing.meta.id };
2988
2958
  const id = await pctx.metaStore.allocateNextDeviceId();
2989
- await pctx.settings.writeAddonStore({ deviceMeta: {
2990
- ...meta,
2991
- [String(id)]: {
2992
- addonId,
2993
- stableId,
2994
- type: "generic",
2995
- name: stableId,
2996
- location: null,
2997
- disabled: false,
2998
- parentDeviceId: null,
2999
- id
3000
- }
3001
- } });
2959
+ await pctx.metaStore.rows.upsert({
2960
+ addonId,
2961
+ stableId,
2962
+ type: "generic",
2963
+ name: stableId,
2964
+ location: null,
2965
+ disabled: false,
2966
+ parentDeviceId: null,
2967
+ id
2968
+ }, {
2969
+ registered: false,
2970
+ metadata: null
2971
+ });
3002
2972
  return { id };
3003
2973
  });
3004
2974
  }
3005
2975
  async function registerDevice(pctx, input) {
3006
2976
  const { addonId, stableId, id, type, name, parentDeviceId, features, config } = input;
3007
- const key = String(id);
3008
2977
  const featuresArr = Array.isArray(features) ? [...features] : [];
3009
2978
  const { isFirstRegistration, exportFingerprint, fingerprintChanged } = await pctx.metaStore.withMetaWriteLock(async () => {
3010
- const index = await pctx.metaStore.readIndex();
3011
- const existing = index[addonId] ?? [];
3012
- const wasInIndex = existing.includes(stableId);
3013
- if (!wasInIndex) await pctx.settings.writeAddonStore({ deviceIndex: {
3014
- ...index,
3015
- [addonId]: [...existing, stableId]
3016
- } });
3017
- const meta = await pctx.metaStore.readMeta();
3018
- const existingMeta = meta[key];
2979
+ const existingRow = await pctx.metaStore.rows.get(id);
2980
+ const existingMeta = existingRow?.meta;
2981
+ const wasRegistered = existingRow?.registered ?? false;
3019
2982
  const wasUserNamed = existingMeta?.userNamed ?? (existingMeta !== void 0 && existingMeta.name !== stableId);
3020
2983
  const resolvedName = wasUserNamed && existingMeta !== void 0 ? existingMeta.name : name;
3021
- const isFirst = !existingMeta || !wasInIndex;
2984
+ const isFirst = !existingMeta || !wasRegistered;
3022
2985
  const fingerprint = (0, _camstack_types_node.canonicalDeviceFingerprint)({
3023
2986
  deviceId: id,
3024
2987
  deviceType: type,
3025
2988
  features: featuresArr
3026
2989
  });
3027
- await pctx.settings.writeAddonStore({ deviceMeta: {
3028
- ...meta,
3029
- [key]: {
3030
- addonId,
3031
- stableId,
3032
- type,
3033
- name: resolvedName,
3034
- userNamed: wasUserNamed,
3035
- location: existingMeta?.location ?? null,
3036
- disabled: existingMeta?.disabled ?? false,
3037
- ...existingMeta?.integrationId !== void 0 ? { integrationId: existingMeta.integrationId } : {},
3038
- ...existingMeta?.linkDeviceId !== void 0 ? { linkDeviceId: existingMeta.linkDeviceId } : {},
3039
- ...existingMeta?.primaryChildEntityId !== void 0 ? { primaryChildEntityId: existingMeta.primaryChildEntityId } : {},
3040
- ...existingMeta?.childLayout !== void 0 ? { childLayout: existingMeta.childLayout } : {},
3041
- ...existingMeta?.role !== void 0 ? { role: existingMeta.role } : {},
3042
- ...existingMeta?.display !== void 0 ? { display: existingMeta.display } : {},
3043
- parentDeviceId,
3044
- id,
3045
- features: featuresArr,
3046
- exportFingerprint: fingerprint
3047
- }
3048
- } });
2990
+ await pctx.metaStore.rows.upsertRegistration({
2991
+ deviceId: id,
2992
+ addonId,
2993
+ stableId,
2994
+ type,
2995
+ name: resolvedName,
2996
+ userNamed: wasUserNamed,
2997
+ location: existingMeta?.location ?? null,
2998
+ disabled: existingMeta?.disabled ?? false,
2999
+ parentDeviceId: parentDeviceId ?? null,
3000
+ registered: true,
3001
+ features: featuresArr,
3002
+ exportFingerprint: fingerprint
3003
+ });
3049
3004
  return {
3050
3005
  isFirstRegistration: isFirst,
3051
3006
  exportFingerprint: fingerprint,
@@ -3105,26 +3060,9 @@ async function removeDevice(pctx, input) {
3105
3060
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3106
3061
  if (!persisted) return;
3107
3062
  const { addonId, stableId, meta: persistedMeta } = persisted;
3108
- const key = String(deviceId);
3109
3063
  const deviceName = persistedMeta.name;
3110
3064
  await pctx.metaStore.withMetaWriteLock(async () => {
3111
- const index = await pctx.metaStore.readIndex();
3112
- const remaining = (index[addonId] ?? []).filter((sid) => sid !== stableId);
3113
- const updatedIndex = remaining.length > 0 ? {
3114
- ...index,
3115
- [addonId]: remaining
3116
- } : (() => {
3117
- const { [addonId]: _removed, ...rest } = index;
3118
- return rest;
3119
- })();
3120
- await pctx.settings.writeAddonStore({ deviceIndex: updatedIndex });
3121
- const { [key]: _removedMeta, ...restMeta } = await pctx.metaStore.readMeta();
3122
- await pctx.settings.writeAddonStore({ deviceMeta: restMeta });
3123
- const map = await pctx.metaStore.readMetadataMap();
3124
- if (key in map) {
3125
- const { [key]: _removedMetadata, ...restMap } = map;
3126
- await pctx.settings.writeAddonStore({ deviceMetadata: restMap });
3127
- }
3065
+ await pctx.metaStore.rows.remove(deviceId);
3128
3066
  });
3129
3067
  await pctx.settings.clearDeviceStore(deviceId);
3130
3068
  await pctx.settings.clearDeviceRuntimeState(deviceId);
@@ -3180,11 +3118,10 @@ async function loadConfig(pctx, input) {
3180
3118
  */
3181
3119
  async function loadMeta(pctx, input) {
3182
3120
  const { deviceId } = input;
3183
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3184
- if (!persisted) return null;
3185
- const { addonId, stableId, meta: m } = persisted;
3186
- const key = String(deviceId);
3187
- const metadata = (await pctx.metaStore.readMetadataMap())[key] ?? null;
3121
+ const row = await pctx.metaStore.getRow(deviceId);
3122
+ if (row === null) return null;
3123
+ const { meta: m, metadata } = row;
3124
+ const { addonId, stableId } = m;
3188
3125
  return {
3189
3126
  id: m.id,
3190
3127
  stableId,
@@ -3214,32 +3151,26 @@ async function setName(pctx, input) {
3214
3151
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3215
3152
  if (!persisted) throw new Error(`[device-manager] setName: unknown device id=${deviceId}`);
3216
3153
  const { meta: m } = persisted;
3217
- const key = String(deviceId);
3218
3154
  const oldName = m.name;
3219
- const allMeta = await pctx.metaStore.readMeta();
3220
- const nextMeta = {
3221
- ...allMeta,
3222
- [key]: {
3223
- ...m,
3224
- name,
3225
- userNamed: true
3155
+ await pctx.metaStore.rows.patch(deviceId, {
3156
+ name,
3157
+ userNamed: true
3158
+ });
3159
+ if (oldName.length > 0 && oldName !== name) {
3160
+ const candidates = /* @__PURE__ */ new Map();
3161
+ for (const row of await pctx.metaStore.rows.listByParent(deviceId)) candidates.set(row.meta.id, row.meta);
3162
+ for (const row of await pctx.metaStore.rows.listAll()) if (row.meta.linkDeviceId === deviceId) candidates.set(row.meta.id, row.meta);
3163
+ candidates.delete(deviceId);
3164
+ for (const childMeta of candidates.values()) {
3165
+ if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
3166
+ const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
3167
+ await pctx.metaStore.rows.patch(childMeta.id, { name: childName });
3168
+ cascaded.push({
3169
+ id: childMeta.id,
3170
+ name: childName
3171
+ });
3226
3172
  }
3227
- };
3228
- if (oldName.length > 0 && oldName !== name) for (const [childKey, childMeta] of Object.entries(allMeta)) {
3229
- if (childKey === key) continue;
3230
- if (!(childMeta.parentDeviceId === deviceId || childMeta.linkDeviceId === deviceId)) continue;
3231
- if (childMeta.name !== oldName && !childMeta.name.startsWith(`${oldName} `)) continue;
3232
- const childName = childMeta.name === oldName ? name : `${name}${childMeta.name.slice(oldName.length)}`;
3233
- nextMeta[childKey] = {
3234
- ...childMeta,
3235
- name: childName
3236
- };
3237
- cascaded.push({
3238
- id: Number(childKey),
3239
- name: childName
3240
- });
3241
3173
  }
3242
- await pctx.settings.writeAddonStore({ deviceMeta: nextMeta });
3243
3174
  });
3244
3175
  pctx.host.ctx.eventBus.emit({
3245
3176
  id: (0, node_crypto.randomUUID)(),
@@ -3279,18 +3210,8 @@ async function setName(pctx, input) {
3279
3210
  async function setLocation(pctx, input) {
3280
3211
  const { deviceId, location } = input;
3281
3212
  await pctx.metaStore.withMetaWriteLock(async () => {
3282
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3283
- if (!persisted) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
3284
- const { meta: m } = persisted;
3285
- const key = String(deviceId);
3286
- const allMeta = await pctx.metaStore.readMeta();
3287
- await pctx.settings.writeAddonStore({ deviceMeta: {
3288
- ...allMeta,
3289
- [key]: {
3290
- ...m,
3291
- location
3292
- }
3293
- } });
3213
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setLocation: unknown device id=${deviceId}`);
3214
+ await pctx.metaStore.rows.patch(deviceId, { location });
3294
3215
  });
3295
3216
  pctx.host.ctx.eventBus.emit({
3296
3217
  id: (0, node_crypto.randomUUID)(),
@@ -3317,18 +3238,8 @@ async function setLocation(pctx, input) {
3317
3238
  async function setType(pctx, input) {
3318
3239
  const { deviceId, type } = input;
3319
3240
  await pctx.metaStore.withMetaWriteLock(async () => {
3320
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3321
- if (!persisted) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
3322
- const { meta: m } = persisted;
3323
- const key = String(deviceId);
3324
- const allMeta = await pctx.metaStore.readMeta();
3325
- await pctx.settings.writeAddonStore({ deviceMeta: {
3326
- ...allMeta,
3327
- [key]: {
3328
- ...m,
3329
- type
3330
- }
3331
- } });
3241
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setType: unknown device id=${deviceId}`);
3242
+ await pctx.metaStore.rows.patch(deviceId, { type });
3332
3243
  });
3333
3244
  pctx.host.ctx.eventBus.emit({
3334
3245
  id: (0, node_crypto.randomUUID)(),
@@ -3363,18 +3274,8 @@ async function setIntegrationId(pctx, input) {
3363
3274
  async function setLinkDeviceId(pctx, input) {
3364
3275
  const { deviceId, linkDeviceId } = input;
3365
3276
  await pctx.metaStore.withMetaWriteLock(async () => {
3366
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3367
- if (!persisted) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
3368
- const { meta: m } = persisted;
3369
- const key = String(deviceId);
3370
- const allMeta = await pctx.metaStore.readMeta();
3371
- await pctx.settings.writeAddonStore({ deviceMeta: {
3372
- ...allMeta,
3373
- [key]: {
3374
- ...m,
3375
- linkDeviceId
3376
- }
3377
- } });
3277
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setLinkDeviceId: unknown device id=${deviceId}`);
3278
+ await pctx.metaStore.rows.patch(deviceId, { linkDeviceId });
3378
3279
  });
3379
3280
  pctx.host.ctx.eventBus.emit({
3380
3281
  id: (0, node_crypto.randomUUID)(),
@@ -3401,18 +3302,8 @@ async function setLinkDeviceId(pctx, input) {
3401
3302
  async function setPrimaryChildEntityId(pctx, input) {
3402
3303
  const { deviceId, primaryChildEntityId } = input;
3403
3304
  await pctx.metaStore.withMetaWriteLock(async () => {
3404
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3405
- if (!persisted) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
3406
- const { meta: m } = persisted;
3407
- const key = String(deviceId);
3408
- const allMeta = await pctx.metaStore.readMeta();
3409
- await pctx.settings.writeAddonStore({ deviceMeta: {
3410
- ...allMeta,
3411
- [key]: {
3412
- ...m,
3413
- primaryChildEntityId
3414
- }
3415
- } });
3305
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setPrimaryChildEntityId: unknown device id=${deviceId}`);
3306
+ await pctx.metaStore.rows.patch(deviceId, { primaryChildEntityId });
3416
3307
  });
3417
3308
  pctx.host.ctx.eventBus.emit({
3418
3309
  id: (0, node_crypto.randomUUID)(),
@@ -3438,18 +3329,8 @@ async function setPrimaryChildEntityId(pctx, input) {
3438
3329
  async function setChildLayout(pctx, input) {
3439
3330
  const { deviceId, childLayout } = input;
3440
3331
  await pctx.metaStore.withMetaWriteLock(async () => {
3441
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3442
- if (!persisted) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
3443
- const { meta: m } = persisted;
3444
- const key = String(deviceId);
3445
- const allMeta = await pctx.metaStore.readMeta();
3446
- await pctx.settings.writeAddonStore({ deviceMeta: {
3447
- ...allMeta,
3448
- [key]: {
3449
- ...m,
3450
- childLayout
3451
- }
3452
- } });
3332
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setChildLayout: unknown device id=${deviceId}`);
3333
+ await pctx.metaStore.rows.patch(deviceId, { childLayout });
3453
3334
  });
3454
3335
  pctx.host.ctx.eventBus.emit({
3455
3336
  id: (0, node_crypto.randomUUID)(),
@@ -3475,18 +3356,8 @@ async function setChildLayout(pctx, input) {
3475
3356
  async function setRole(pctx, input) {
3476
3357
  const { deviceId, role } = input;
3477
3358
  await pctx.metaStore.withMetaWriteLock(async () => {
3478
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3479
- if (!persisted) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
3480
- const { meta: m } = persisted;
3481
- const key = String(deviceId);
3482
- const allMeta = await pctx.metaStore.readMeta();
3483
- await pctx.settings.writeAddonStore({ deviceMeta: {
3484
- ...allMeta,
3485
- [key]: {
3486
- ...m,
3487
- role
3488
- }
3489
- } });
3359
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setRole: unknown device id=${deviceId}`);
3360
+ await pctx.metaStore.rows.patch(deviceId, { role });
3490
3361
  });
3491
3362
  pctx.host.ctx.eventBus.emit({
3492
3363
  id: (0, node_crypto.randomUUID)(),
@@ -3531,19 +3402,8 @@ async function setDisplay(pctx, input) {
3531
3402
  const { deviceId, display } = input;
3532
3403
  const normalized = display === null ? null : normalizeDisplayOverride(display);
3533
3404
  await pctx.metaStore.withMetaWriteLock(async () => {
3534
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3535
- if (!persisted) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
3536
- const { meta: m } = persisted;
3537
- const key = String(deviceId);
3538
- const allMeta = await pctx.metaStore.readMeta();
3539
- const nextRow = normalized === null ? (({ display: _drop, ...rest }) => rest)(m) : {
3540
- ...m,
3541
- display: normalized
3542
- };
3543
- await pctx.settings.writeAddonStore({ deviceMeta: {
3544
- ...allMeta,
3545
- [key]: nextRow
3546
- } });
3405
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setDisplay: unknown device id=${deviceId}`);
3406
+ await pctx.metaStore.rows.patch(deviceId, { display: normalized });
3547
3407
  });
3548
3408
  pctx.host.ctx.eventBus.emit({
3549
3409
  id: (0, node_crypto.randomUUID)(),
@@ -3571,7 +3431,7 @@ async function getRoleDisplayDefaults(pctx, _input) {
3571
3431
  * Replace the per-role display defaults whole-record (full replace). Override
3572
3432
  * units are normalized (`normalizeUnit`) at write so the render path always
3573
3433
  * looks up canonical spellings. Single-writer top-level key — no lock, no RMW,
3574
- * no interaction with the `deviceMeta` write lock. Not per-device, so no event
3434
+ * no interaction with the device write lock. Not per-device, so no event
3575
3435
  * is emitted; the UI invalidates its own query on mutate.
3576
3436
  */
3577
3437
  async function setRoleDisplayDefaults(pctx, input) {
@@ -3584,7 +3444,7 @@ async function setRoleDisplayDefaults(pctx, input) {
3584
3444
  /**
3585
3445
  * Batched meta pre-seed. Applies every provided field to the
3586
3446
  * device's meta row in ONE read-modify-write under a single
3587
- * `withMetaWriteLock` acquisition (one `deviceMeta` blob write),
3447
+ * `withMetaWriteLock` acquisition (one row write),
3588
3448
  * then emits one `DeviceMetaChanged` event per field that was
3589
3449
  * supplied — preserving the exact semantics of the individual
3590
3450
  * setters (`setName` / `setLocation` / `setType` /
@@ -3600,24 +3460,15 @@ async function setRoleDisplayDefaults(pctx, input) {
3600
3460
  async function applyInitialMeta(pctx, input) {
3601
3461
  const { deviceId, name, location, type, integrationId, linkDeviceId, role } = input;
3602
3462
  await pctx.metaStore.withMetaWriteLock(async () => {
3603
- const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3604
- if (!persisted) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
3605
- const { meta: m } = persisted;
3606
- const key = String(deviceId);
3607
- const allMeta = await pctx.metaStore.readMeta();
3608
- const merged = {
3609
- ...m,
3463
+ if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] applyInitialMeta: unknown device id=${deviceId}`);
3464
+ await pctx.metaStore.rows.patch(deviceId, {
3610
3465
  ...name !== void 0 ? { name } : {},
3611
3466
  ...location !== void 0 ? { location } : {},
3612
3467
  ...type !== void 0 ? { type } : {},
3613
3468
  ...integrationId !== void 0 ? { integrationId } : {},
3614
3469
  ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
3615
3470
  ...role !== void 0 ? { role } : {}
3616
- };
3617
- await pctx.settings.writeAddonStore({ deviceMeta: {
3618
- ...allMeta,
3619
- [key]: merged
3620
- } });
3471
+ });
3621
3472
  });
3622
3473
  const emitMetaChanged = (field, value) => {
3623
3474
  pctx.host.ctx.eventBus.emit({
@@ -3653,10 +3504,9 @@ async function applyInitialMeta(pctx, input) {
3653
3504
  async function setMetadata(pctx, input) {
3654
3505
  const { deviceId, patch } = input;
3655
3506
  const result = await pctx.metaStore.withMetaWriteLock(async () => {
3656
- if (!await pctx.metaStore.resolvePersistedById(deviceId)) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
3657
- const key = String(deviceId);
3658
- const map = await pctx.metaStore.readMetadataMap();
3659
- const next = { ...map[key] ?? {} };
3507
+ const row = await pctx.metaStore.rows.get(deviceId);
3508
+ if (row === null) throw new Error(`[device-manager] setMetadata: unknown device id=${deviceId}`);
3509
+ const next = { ...row.metadata ?? {} };
3660
3510
  let changed = false;
3661
3511
  for (const [k, v] of Object.entries(patch)) if (v === null) {
3662
3512
  if (k in next) {
@@ -3669,10 +3519,7 @@ async function setMetadata(pctx, input) {
3669
3519
  }
3670
3520
  if (!changed) return { changed: false };
3671
3521
  const hasFields = Object.keys(next).length > 0;
3672
- const updatedMap = { ...map };
3673
- if (hasFields) updatedMap[key] = next;
3674
- else delete updatedMap[key];
3675
- await pctx.settings.writeAddonStore({ deviceMetadata: updatedMap });
3522
+ await pctx.metaStore.rows.patch(deviceId, { metadata: hasFields ? next : null });
3676
3523
  return {
3677
3524
  changed: true,
3678
3525
  finalMeta: hasFields ? next : null
@@ -3718,15 +3565,7 @@ async function setDisabled(pctx, input) {
3718
3565
  const persisted = await pctx.metaStore.resolvePersistedById(deviceId);
3719
3566
  if (!persisted) throw new Error(`[device-manager] setDisabled: unknown device id=${deviceId}`);
3720
3567
  const { meta: m } = persisted;
3721
- const key = String(deviceId);
3722
- const allMeta = await pctx.metaStore.readMeta();
3723
- await pctx.settings.writeAddonStore({ deviceMeta: {
3724
- ...allMeta,
3725
- [key]: {
3726
- ...m,
3727
- disabled
3728
- }
3729
- } });
3568
+ await pctx.metaStore.rows.patch(deviceId, { disabled });
3730
3569
  return {
3731
3570
  changed: (m.disabled ?? false) !== disabled,
3732
3571
  integrationId: m.integrationId ?? ""
@@ -3776,9 +3615,7 @@ async function loadRuntimeState(pctx, input) {
3776
3615
  * location autocomplete.
3777
3616
  */
3778
3617
  async function listLocations(pctx) {
3779
- const store = await pctx.settings.readAddonStore();
3780
- const meta = store.deviceMeta ?? {};
3781
- const locations = store.locations ?? [];
3618
+ const locations = (await pctx.metaStore.readStore()).locations ?? [];
3782
3619
  const seen = /* @__PURE__ */ new Map();
3783
3620
  const consider = (raw) => {
3784
3621
  if (typeof raw !== "string") return;
@@ -3788,7 +3625,7 @@ async function listLocations(pctx) {
3788
3625
  if (!seen.has(key)) seen.set(key, trimmed);
3789
3626
  };
3790
3627
  for (const label of locations) consider(label);
3791
- for (const m of Object.values(meta)) consider(m.location);
3628
+ for (const row of await pctx.metaStore.rows.listAll()) consider(row.meta.location);
3792
3629
  return [...seen.values()].toSorted((a, b) => a.localeCompare(b, void 0, { sensitivity: "base" }));
3793
3630
  }
3794
3631
  /**
@@ -3800,7 +3637,7 @@ async function listLocations(pctx) {
3800
3637
  async function addLocation(pctx, input) {
3801
3638
  const trimmed = input.name.trim();
3802
3639
  if (trimmed.length === 0) throw new Error("[device-manager] addLocation: name must be non-empty");
3803
- const current = (await pctx.settings.readAddonStore()).locations ?? [];
3640
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
3804
3641
  if (current.some((l) => l.toLowerCase() === trimmed.toLowerCase())) return;
3805
3642
  await pctx.settings.writeAddonStore({ locations: [...current, trimmed] });
3806
3643
  }
@@ -3816,25 +3653,21 @@ async function addLocation(pctx, input) {
3816
3653
  async function removeLocation(pctx, input) {
3817
3654
  const trimmed = input.name.trim();
3818
3655
  if (trimmed.length === 0) return;
3819
- const store = await pctx.settings.readAddonStore();
3820
- const current = store.locations ?? [];
3656
+ const current = (await pctx.metaStore.readStore()).locations ?? [];
3821
3657
  const remaining = current.filter((l) => l.toLowerCase() !== trimmed.toLowerCase());
3822
3658
  if (remaining.length !== current.length) await pctx.settings.writeAddonStore({ locations: remaining });
3823
3659
  if (input.cascade !== true) return;
3824
- const meta = store.deviceMeta ?? {};
3825
- const updates = { ...meta };
3826
- const cleared = [];
3827
- for (const [key, m] of Object.entries(meta)) {
3828
- if (typeof m.location !== "string") continue;
3829
- if (m.location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
3830
- updates[key] = {
3831
- ...m,
3832
- location: null
3833
- };
3834
- cleared.push(m.id);
3835
- }
3836
- if (cleared.length === 0) return;
3837
- await pctx.settings.writeAddonStore({ deviceMeta: updates });
3660
+ const cleared = await pctx.metaStore.withMetaWriteLock(async () => {
3661
+ const out = [];
3662
+ for (const row of await pctx.metaStore.rows.listAll()) {
3663
+ const location = row.meta.location;
3664
+ if (typeof location !== "string") continue;
3665
+ if (location.trim().toLowerCase() !== trimmed.toLowerCase()) continue;
3666
+ await pctx.metaStore.rows.patch(row.meta.id, { location: null });
3667
+ out.push(row.meta.id);
3668
+ }
3669
+ return out;
3670
+ });
3838
3671
  for (const deviceId of cleared) pctx.host.ctx.eventBus.emit({
3839
3672
  id: (0, node_crypto.randomUUID)(),
3840
3673
  timestamp: /* @__PURE__ */ new Date(),
@@ -3851,42 +3684,58 @@ async function removeLocation(pctx, input) {
3851
3684
  });
3852
3685
  }
3853
3686
  //#endregion
3687
+ //#region src/builtins/device-manager/device-meta-types.ts
3688
+ /**
3689
+ * Decode the raw `ctx.settings.readAddonStore()` record into {@link AddonStore}.
3690
+ *
3691
+ * A field of the wrong shape reads as ABSENT rather than being trusted through
3692
+ * a cast: the store is JSON on disk and a hand-edit or a partial restore must
3693
+ * cost the caller its default, never a downstream `TypeError`.
3694
+ */
3695
+ function decodeAddonStore(raw) {
3696
+ const nextDeviceId = raw["nextDeviceId"];
3697
+ const locations = raw["locations"];
3698
+ const roleDisplayDefaults = raw["roleDisplayDefaults"];
3699
+ return {
3700
+ ...typeof nextDeviceId === "number" && Number.isFinite(nextDeviceId) ? { nextDeviceId } : {},
3701
+ ...Array.isArray(locations) ? { locations: locations.filter((l) => typeof l === "string") } : {},
3702
+ ...isRoleDisplayDefaults(roleDisplayDefaults) ? { roleDisplayDefaults } : {}
3703
+ };
3704
+ }
3705
+ function isRoleDisplayDefaults(value) {
3706
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3707
+ }
3708
+ //#endregion
3854
3709
  //#region src/builtins/device-manager/device-meta-store.ts
3855
3710
  var DeviceMetaStore = class {
3856
3711
  settings;
3857
3712
  registry;
3713
+ rows;
3858
3714
  /** Synchronous ownership cache, keyed by NUMERIC deviceId → owning addonId.
3859
- * The persisted meta store is authoritative but reads are async; hub-side
3715
+ * The persisted row store is authoritative but reads are async; hub-side
3860
3716
  * callers (e.g. `CapabilityRegistry.getNativeProvider` fallback) need
3861
3717
  * ownership without awaiting. Kept in sync with every register/remove and
3862
3718
  * warmed from persistence on boot. */
3863
3719
  idToAddonId = /* @__PURE__ */ new Map();
3864
- /** Serialises every read-modify-write of the deviceMeta / deviceIndex blob
3865
- * through one promise chain (see `withMetaWriteLock`). Per-instance state
3866
- * identical to the former `onInitialize` closure variable. */
3720
+ /** Serialises every read-modify-write of a device row through one promise
3721
+ * chain (see `withMetaWriteLock`). Per-instance state. */
3867
3722
  metaWriteChain = Promise.resolve();
3868
- constructor(settings, registry) {
3723
+ constructor(settings, registry, rows) {
3869
3724
  this.settings = settings;
3870
3725
  this.registry = registry;
3726
+ this.rows = rows;
3871
3727
  }
3872
3728
  /** The read currently in flight, or null. Never a settled value — see
3873
3729
  * {@link readStore}. */
3874
3730
  inFlightRead = null;
3875
3731
  /**
3876
- * The whole persisted addon store.
3732
+ * The addon's own settings row set — `nextDeviceId`, `roleDisplayDefaults`,
3733
+ * `locations`. NOT the fleet: no device has lived here since the flatten.
3877
3734
  *
3878
3735
  * **Concurrent callers join the read already in flight.** This is not a
3879
3736
  * cache and nothing survives settlement: a caller that awaited the running
3880
3737
  * promise could not have observed anything older than its result, so the
3881
- * only thing that changes is cost. What that cost was, measured on the
3882
- * 2026-08-19 boot: `readAddonStore` lands in `SqliteSettingsBackend
3883
- * .getAllAddon`, which reads and `JSON.parse`s this addon's rows —
3884
- * 625 KB on the live hub (`deviceMeta` 467 KB + `deviceMetadata` 87 KB +
3885
- * `deviceIndex` 70 KB) — synchronously, on the hub's event loop. A V8
3886
- * profile of that boot had hub-main's JS thread 99.9% busy with 64% of it
3887
- * inside `getAllAddon`, ~89% of that entered here, and every runner's first
3888
- * store read queued behind it (the notification centre's six parallel reads
3889
- * all resolved together at t+44.7 s).
3738
+ * only thing that changes is cost.
3890
3739
  *
3891
3740
  * A rejection is NOT latched: the slot is cleared before the promise
3892
3741
  * settles either way, so a failed read costs the joiners that one failure
@@ -3897,7 +3746,7 @@ var DeviceMetaStore = class {
3897
3746
  if (existing !== null) return existing;
3898
3747
  const read = (async () => {
3899
3748
  try {
3900
- return await this.settings.readAddonStore();
3749
+ return decodeAddonStore(await this.settings.readAddonStore());
3901
3750
  } finally {
3902
3751
  this.inFlightRead = null;
3903
3752
  }
@@ -3905,41 +3754,6 @@ var DeviceMetaStore = class {
3905
3754
  this.inFlightRead = read;
3906
3755
  return read;
3907
3756
  };
3908
- /**
3909
- * The three fleet projections from ONE read.
3910
- *
3911
- * `listAll` asked for `deviceMeta`, then `deviceMetadata`, then
3912
- * `deviceIndex` — three SEQUENTIAL awaits, which {@link readStore}'s
3913
- * in-flight join cannot collapse because each starts after the previous one
3914
- * settled. Three full 625 KB parses per call, on a call made once per device
3915
- * lifecycle event during a 974-device boot: 23% of hub-main's CPU.
3916
- *
3917
- * It is also ONE snapshot. Three separate reads could straddle a write and
3918
- * hand back an index that names a device the meta map no longer has.
3919
- */
3920
- readAll = async () => {
3921
- const store = await this.readStore();
3922
- return {
3923
- index: store.deviceIndex ?? {},
3924
- meta: store.deviceMeta ?? {},
3925
- metadata: store.deviceMetadata ?? {}
3926
- };
3927
- };
3928
- readIndex = async () => {
3929
- return (await this.readStore()).deviceIndex ?? {};
3930
- };
3931
- readMeta = async () => {
3932
- return (await this.readStore()).deviceMeta ?? {};
3933
- };
3934
- /** Hardware-identity metadata map. Lives in a sibling key on the
3935
- * device-manager addon store so its writers (`setMetadata`) never
3936
- * collide with the lifecycle writers on `deviceMeta`
3937
- * (`registerDevice` / `setName` / `setLocation` / `setDisabled`).
3938
- * Single-writer per row eliminates the "writer X clobbers writer
3939
- * Y's field" bug class — `setMetadata` is the only producer. */
3940
- readMetadataMap = async () => {
3941
- return (await this.readStore()).deviceMetadata ?? {};
3942
- };
3943
3757
  withMetaWriteLock = async (fn) => {
3944
3758
  const previous = this.metaWriteChain;
3945
3759
  let release = () => {};
@@ -3954,31 +3768,36 @@ var DeviceMetaStore = class {
3954
3768
  release();
3955
3769
  }
3956
3770
  };
3771
+ /** The whole persisted row for one device, or `null`. */
3772
+ getRow = async (deviceId) => this.rows.get(deviceId);
3957
3773
  /**
3958
3774
  * Resolve a numeric deviceId to the owning `(addonId, stableId)` pair.
3959
- * Scans persisted meta — live IDevice lookup (hub registry) is handled
3775
+ * Reads the device's own row — live IDevice lookup (hub registry) is handled
3960
3776
  * separately per call site so callers can decide whether to route to
3961
3777
  * an in-process driver or to the cross-process `device-ops` bridge.
3962
3778
  * Returns null when no device with that id is known to the hub.
3963
3779
  */
3964
3780
  resolvePersistedById = async (deviceId) => {
3965
- const m = (await this.readMeta())[String(deviceId)];
3966
- if (!m) return null;
3781
+ const row = await this.rows.get(deviceId);
3782
+ if (row === null) return null;
3967
3783
  return {
3968
- addonId: m.addonId,
3969
- stableId: m.stableId,
3970
- meta: m
3784
+ addonId: row.meta.addonId,
3785
+ stableId: row.meta.stableId,
3786
+ meta: row.meta
3971
3787
  };
3972
3788
  };
3789
+ /** The device's hardware-identity metadata blob, or `null`. */
3790
+ readMetadata = async (deviceId) => {
3791
+ return (await this.rows.get(deviceId))?.metadata ?? null;
3792
+ };
3973
3793
  /** Direct children of a device: the union of the live registry's children
3974
- * and the persisted-meta scan (`parentDeviceId === parentId`), deduplicated
3794
+ * and the persisted rows whose `parentDeviceId` is `parentId`, deduplicated
3975
3795
  * and excluding self. Shared by the `remove` cascade and the `resetToSource`
3976
3796
  * resync purge (#19). */
3977
3797
  directChildIds = async (parentId) => {
3978
3798
  const ids = /* @__PURE__ */ new Set();
3979
3799
  if (this.registry) for (const c of this.registry.getChildren(parentId)) ids.add(c.id);
3980
- const meta = await this.readMeta();
3981
- for (const m of Object.values(meta)) if (m.parentDeviceId === parentId) ids.add(m.id);
3800
+ for (const row of await this.rows.listByParent(parentId)) ids.add(row.meta.id);
3982
3801
  ids.delete(parentId);
3983
3802
  return [...ids];
3984
3803
  };
@@ -3989,6 +3808,544 @@ var DeviceMetaStore = class {
3989
3808
  };
3990
3809
  };
3991
3810
  //#endregion
3811
+ //#region src/builtins/device-manager/device-row-store.ts
3812
+ /**
3813
+ * @durable class=ledger owner=device-manager
3814
+ * write="one row per device, written by `allocateDeviceId` (identity placeholder),
3815
+ * `registerDevice` (full reconcile) and every meta setter (single-column patch);
3816
+ * `setMetadata` patches the `metadata` column of the same row"
3817
+ * retention="none — a row goes only when the operator removes the device
3818
+ * (`removeDevice`), or when an integration is deleted and cascades. Bounded by the
3819
+ * fleet an operator configures (974 rows on the reference hub)."
3820
+ *
3821
+ * ### Why `ledger` and not `config`
3822
+ *
3823
+ * This shipped as `class=registry`, a sixth class that does not exist —
3824
+ * `DURABLE_CLASSES` is `ledger | mirror | ephemeral | config | audit`, so
3825
+ * `check-durable-state-declared.ts` has failed since. `config` is the tempting
3826
+ * answer, because a device's `name` and `location` ARE operator intent. It is
3827
+ * the wrong one, and wrong in the dangerous direction: `config`'s rule is
3828
+ * *"already durable everywhere"*, and that is exactly the premise that made the
3829
+ * dead `devices` table look harmless right up to D183.
3830
+ *
3831
+ * Nothing else holds a device's NUMERIC id. `allocateDeviceId` mints it here
3832
+ * and every other store in the system keys on it — `addon-device-settings`,
3833
+ * `device-runtime-state`, every `pipeline-analytics:*` row, every recorder path.
3834
+ * That is `ledger`'s definition verbatim ("a fact only this component ever
3835
+ * held"), and `ledger`'s rule — *must not be pruned against a fallible read* —
3836
+ * is precisely the rule this collection needs (D49): a `getBindings` or an
3837
+ * integration listing that answers empty because it FAILED must never be
3838
+ * allowed to retire a device row.
3839
+ */
3840
+ var DEVICE_ROWS_COLLECTION = "device-manager:devices";
3841
+ /**
3842
+ * Fleet reads pass this explicitly.
3843
+ *
3844
+ * `settings-store.query` applies a DEFAULT row cap of 2 000 when the caller
3845
+ * names no `limit` (`query-bounds.ts`), and truncation there is silent to the
3846
+ * caller — it warns in the engine's log and returns a short list that looks
3847
+ * complete. The reference hub is already at 974 devices; half the default cap
3848
+ * is not a margin worth betting the fleet listing on. 20 000 is the engine's
3849
+ * hard ceiling, so this asks for everything the engine will ever serve in one
3850
+ * call and any future need to page is a loud failure rather than a quiet one.
3851
+ */
3852
+ var DEVICE_ROWS_FLEET_LIMIT = 2e4;
3853
+ var DEVICE_ROWS_COLUMNS = [
3854
+ {
3855
+ name: "id",
3856
+ type: "TEXT",
3857
+ primaryKey: true,
3858
+ notNull: true
3859
+ },
3860
+ {
3861
+ name: "deviceId",
3862
+ type: "INTEGER",
3863
+ notNull: true
3864
+ },
3865
+ {
3866
+ name: "addonId",
3867
+ type: "TEXT",
3868
+ notNull: true
3869
+ },
3870
+ {
3871
+ name: "stableId",
3872
+ type: "TEXT",
3873
+ notNull: true
3874
+ },
3875
+ {
3876
+ name: "type",
3877
+ type: "TEXT",
3878
+ notNull: true
3879
+ },
3880
+ {
3881
+ name: "name",
3882
+ type: "TEXT",
3883
+ notNull: true
3884
+ },
3885
+ {
3886
+ name: "userNamed",
3887
+ type: "BOOLEAN"
3888
+ },
3889
+ {
3890
+ name: "location",
3891
+ type: "TEXT"
3892
+ },
3893
+ {
3894
+ name: "disabled",
3895
+ type: "BOOLEAN",
3896
+ notNull: true,
3897
+ defaultValue: false
3898
+ },
3899
+ {
3900
+ name: "parentDeviceId",
3901
+ type: "INTEGER"
3902
+ },
3903
+ {
3904
+ name: "registered",
3905
+ type: "BOOLEAN",
3906
+ notNull: true,
3907
+ defaultValue: false
3908
+ },
3909
+ {
3910
+ name: "features",
3911
+ type: "JSON"
3912
+ },
3913
+ {
3914
+ name: "exportFingerprint",
3915
+ type: "TEXT"
3916
+ },
3917
+ {
3918
+ name: "integrationId",
3919
+ type: "TEXT"
3920
+ },
3921
+ {
3922
+ name: "linkDeviceId",
3923
+ type: "INTEGER"
3924
+ },
3925
+ {
3926
+ name: "primaryChildEntityId",
3927
+ type: "TEXT"
3928
+ },
3929
+ {
3930
+ name: "childLayout",
3931
+ type: "JSON"
3932
+ },
3933
+ {
3934
+ name: "role",
3935
+ type: "TEXT"
3936
+ },
3937
+ {
3938
+ name: "display",
3939
+ type: "JSON"
3940
+ },
3941
+ {
3942
+ name: "metadata",
3943
+ type: "JSON"
3944
+ }
3945
+ ];
3946
+ var DEVICE_ROWS_INDEXES = [
3947
+ {
3948
+ name: "idx_dm_devices_addon_stable",
3949
+ columns: ["addonId", "stableId"]
3950
+ },
3951
+ {
3952
+ name: "idx_dm_devices_parent",
3953
+ columns: ["parentDeviceId"]
3954
+ },
3955
+ {
3956
+ name: "idx_dm_devices_integration",
3957
+ columns: ["integrationId"]
3958
+ }
3959
+ ];
3960
+ /** Adapt `ctx.api.settingsStore` (tRPC namespace) to {@link DeviceRowBackend}. */
3961
+ function deviceRowBackendOf(client) {
3962
+ return {
3963
+ declareCollection: async (input) => {
3964
+ await client.declareCollection.mutate(input);
3965
+ },
3966
+ get: (input) => client.get.query(input),
3967
+ set: async (input) => {
3968
+ await client.set.mutate(input);
3969
+ },
3970
+ query: (input) => client.query.query(input),
3971
+ updateWhere: (input) => client.updateWhere.mutate(input),
3972
+ delete: async (input) => {
3973
+ await client.delete.mutate(input);
3974
+ },
3975
+ count: (input) => client.count.query(input)
3976
+ };
3977
+ }
3978
+ function isPlainObject(value) {
3979
+ return value !== null && typeof value === "object" && !Array.isArray(value);
3980
+ }
3981
+ function readString(value) {
3982
+ return typeof value === "string" ? value : void 0;
3983
+ }
3984
+ function readNumber(value) {
3985
+ return typeof value === "number" && Number.isFinite(value) ? value : void 0;
3986
+ }
3987
+ function readBoolean(value) {
3988
+ if (typeof value === "boolean") return value;
3989
+ if (value === 1) return true;
3990
+ if (value === 0) return false;
3991
+ }
3992
+ function readStringArray(value) {
3993
+ if (!Array.isArray(value)) return void 0;
3994
+ const out = [];
3995
+ for (const entry of value) if (typeof entry === "string") out.push(entry);
3996
+ return out;
3997
+ }
3998
+ /**
3999
+ * Decode one stored row.
4000
+ *
4001
+ * Returns `null` for a row missing an identity field the rest of the system
4002
+ * treats as an invariant (`deviceId` / `addonId` / `stableId` / `type` /
4003
+ * `name`). A row like that cannot be projected into a `DeviceInfo` and taking
4004
+ * the whole fleet read down for it would be worse — the caller logs the skip.
4005
+ */
4006
+ function decodeDeviceRow(data) {
4007
+ const deviceId = readNumber(data["deviceId"]);
4008
+ const addonId = readString(data["addonId"]);
4009
+ const stableId = readString(data["stableId"]);
4010
+ const type = readString(data["type"]);
4011
+ const name = readString(data["name"]);
4012
+ if (deviceId === void 0 || addonId === void 0 || stableId === void 0 || type === void 0 || name === void 0) return null;
4013
+ const location = readString(data["location"]);
4014
+ const parentDeviceId = readNumber(data["parentDeviceId"]);
4015
+ const userNamed = readBoolean(data["userNamed"]);
4016
+ const features = readStringArray(data["features"]);
4017
+ const exportFingerprint = readString(data["exportFingerprint"]);
4018
+ const integrationId = readString(data["integrationId"]);
4019
+ const linkDeviceId = readNumber(data["linkDeviceId"]);
4020
+ const primaryChildEntityId = readString(data["primaryChildEntityId"]);
4021
+ const role = readString(data["role"]);
4022
+ const rawChildLayout = data["childLayout"];
4023
+ const rawDisplay = data["display"];
4024
+ const rawMetadata = data["metadata"];
4025
+ return {
4026
+ meta: {
4027
+ id: deviceId,
4028
+ addonId,
4029
+ stableId,
4030
+ type,
4031
+ name,
4032
+ location: location ?? null,
4033
+ disabled: readBoolean(data["disabled"]) ?? false,
4034
+ parentDeviceId: parentDeviceId ?? null,
4035
+ ...userNamed !== void 0 ? { userNamed } : {},
4036
+ ...features !== void 0 ? { features } : {},
4037
+ ...exportFingerprint !== void 0 ? { exportFingerprint } : {},
4038
+ ...integrationId !== void 0 ? { integrationId } : {},
4039
+ ...linkDeviceId !== void 0 ? { linkDeviceId } : {},
4040
+ ...primaryChildEntityId !== void 0 ? { primaryChildEntityId } : {},
4041
+ ...isChildLayout(rawChildLayout) ? { childLayout: rawChildLayout } : {},
4042
+ ...role !== void 0 ? { role } : {},
4043
+ ...isPlainObject(rawDisplay) ? { display: toDisplayOverride(rawDisplay) } : {}
4044
+ },
4045
+ metadata: isPlainObject(rawMetadata) ? rawMetadata : null,
4046
+ registered: readBoolean(data["registered"]) ?? false
4047
+ };
4048
+ }
4049
+ /**
4050
+ * `ChildLayout` is a JSON column: what comes back is whatever was written.
4051
+ * The projection hands it to the UI untouched, so the only thing worth
4052
+ * checking is that it is still the array shape the writer put in.
4053
+ */
4054
+ function isChildLayout(value) {
4055
+ return Array.isArray(value);
4056
+ }
4057
+ /**
4058
+ * `DeviceDisplayOverride` is likewise stored verbatim in a JSON column. It is
4059
+ * an all-optional record, so any object round-trips; the write path
4060
+ * (`normalizeDisplayOverride`) is what gives it shape.
4061
+ */
4062
+ function toDisplayOverride(value) {
4063
+ return value;
4064
+ }
4065
+ /** Full-row value for an upsert: identity + every field of the meta record. */
4066
+ function encodeDeviceRow(meta, extra = {}) {
4067
+ const row = {
4068
+ id: String(meta.id),
4069
+ deviceId: meta.id,
4070
+ addonId: meta.addonId,
4071
+ stableId: meta.stableId,
4072
+ type: meta.type,
4073
+ name: meta.name,
4074
+ userNamed: meta.userNamed ?? null,
4075
+ location: meta.location,
4076
+ disabled: meta.disabled,
4077
+ parentDeviceId: meta.parentDeviceId,
4078
+ registered: extra.registered ?? false,
4079
+ features: meta.features === void 0 ? null : [...meta.features],
4080
+ exportFingerprint: meta.exportFingerprint ?? null,
4081
+ integrationId: meta.integrationId ?? null,
4082
+ linkDeviceId: meta.linkDeviceId ?? null,
4083
+ primaryChildEntityId: meta.primaryChildEntityId ?? null,
4084
+ childLayout: meta.childLayout ?? null,
4085
+ role: meta.role ?? null,
4086
+ display: meta.display ?? null
4087
+ };
4088
+ if (extra.metadata !== void 0) row["metadata"] = extra.metadata;
4089
+ return row;
4090
+ }
4091
+ /** Column map for a partial write — only the keys the caller actually named. */
4092
+ function encodeDeviceRowPatch(patch) {
4093
+ const row = {};
4094
+ for (const [key, value] of Object.entries(patch)) {
4095
+ if (value === void 0) continue;
4096
+ row[key] = value;
4097
+ }
4098
+ return row;
4099
+ }
4100
+ /**
4101
+ * Row access for the device fleet. Every method is a single statement against
4102
+ * `device-manager:devices` — there is no in-memory copy of the fleet here and
4103
+ * no cache: a per-device question is a primary-key lookup, a fleet question is
4104
+ * one indexed scan.
4105
+ */
4106
+ var DeviceRowStore = class {
4107
+ backend;
4108
+ logger;
4109
+ declared = null;
4110
+ constructor(backend, logger) {
4111
+ this.backend = backend;
4112
+ this.logger = logger;
4113
+ }
4114
+ /**
4115
+ * Lazy, idempotent `declareCollection`, memoised on the PROMISE so N
4116
+ * concurrent first-callers issue one declaration rather than N. A rejection
4117
+ * is not latched — the slot is cleared so the next caller retries instead of
4118
+ * inheriting a dead collection forever.
4119
+ */
4120
+ async declare() {
4121
+ const existing = this.declared;
4122
+ if (existing !== null) return existing;
4123
+ const run = (async () => {
4124
+ try {
4125
+ await this.backend.declareCollection({
4126
+ collection: DEVICE_ROWS_COLLECTION,
4127
+ columns: DEVICE_ROWS_COLUMNS,
4128
+ indexes: DEVICE_ROWS_INDEXES
4129
+ });
4130
+ } catch (err) {
4131
+ this.declared = null;
4132
+ throw err;
4133
+ }
4134
+ })();
4135
+ this.declared = run;
4136
+ return run;
4137
+ }
4138
+ /** One device, by numeric id. `null` when the fleet does not know it. */
4139
+ async get(deviceId) {
4140
+ await this.declare();
4141
+ const raw = await this.backend.get({
4142
+ collection: DEVICE_ROWS_COLLECTION,
4143
+ key: String(deviceId)
4144
+ });
4145
+ if (!isPlainObject(raw)) return null;
4146
+ const decoded = decodeDeviceRow(raw);
4147
+ if (decoded === null) {
4148
+ this.logger.warn("device row skipped — identity fields missing", { tags: { deviceId } });
4149
+ return null;
4150
+ }
4151
+ return decoded;
4152
+ }
4153
+ /** Every device, ordered by numeric id. */
4154
+ async listAll() {
4155
+ return this.list({
4156
+ orderBy: {
4157
+ field: "deviceId",
4158
+ direction: "asc"
4159
+ },
4160
+ limit: DEVICE_ROWS_FLEET_LIMIT
4161
+ });
4162
+ }
4163
+ /**
4164
+ * The device an addon knows as `stableId`, or `null`.
4165
+ *
4166
+ * `(addonId, stableId)` is the addon-facing identity — unique by
4167
+ * construction, since `allocateDeviceId` is the only thing that mints a row
4168
+ * and it returns the existing id for a pair it already knows. A second row
4169
+ * for the pair would be a corruption, so this takes the LOWEST id and says
4170
+ * so rather than picking arbitrarily.
4171
+ */
4172
+ async findByStableId(addonId, stableId) {
4173
+ const rows = await this.list({
4174
+ where: {
4175
+ addonId,
4176
+ stableId
4177
+ },
4178
+ orderBy: {
4179
+ field: "deviceId",
4180
+ direction: "asc"
4181
+ },
4182
+ limit: 2
4183
+ });
4184
+ const first = rows[0];
4185
+ if (first === void 0) return null;
4186
+ if (rows.length > 1) this.logger.warn("duplicate device rows for one (addonId, stableId) — using the lowest id", {
4187
+ tags: { deviceId: first.meta.id },
4188
+ meta: {
4189
+ addonId,
4190
+ stableId,
4191
+ ids: rows.map((r) => r.meta.id)
4192
+ }
4193
+ });
4194
+ return first;
4195
+ }
4196
+ /** Every device owned by one addon, ordered by numeric id. */
4197
+ async listByAddon(addonId) {
4198
+ return this.list({
4199
+ where: { addonId },
4200
+ orderBy: {
4201
+ field: "deviceId",
4202
+ direction: "asc"
4203
+ },
4204
+ limit: DEVICE_ROWS_FLEET_LIMIT
4205
+ });
4206
+ }
4207
+ /** Every device an integration owns, ordered by numeric id. */
4208
+ async listByIntegration(integrationId) {
4209
+ return this.list({
4210
+ where: { integrationId },
4211
+ orderBy: {
4212
+ field: "deviceId",
4213
+ direction: "asc"
4214
+ },
4215
+ limit: DEVICE_ROWS_FLEET_LIMIT
4216
+ });
4217
+ }
4218
+ /** Direct children of one device, ordered by numeric id. */
4219
+ async listByParent(parentDeviceId) {
4220
+ return this.list({
4221
+ where: { parentDeviceId },
4222
+ orderBy: {
4223
+ field: "deviceId",
4224
+ direction: "asc"
4225
+ },
4226
+ limit: DEVICE_ROWS_FLEET_LIMIT
4227
+ });
4228
+ }
4229
+ /** How many devices the fleet holds. Used to tell "empty store" from "gone device". */
4230
+ async count() {
4231
+ await this.declare();
4232
+ return this.backend.count({ collection: DEVICE_ROWS_COLLECTION });
4233
+ }
4234
+ async list(filter) {
4235
+ await this.declare();
4236
+ const records = await this.backend.query({
4237
+ collection: DEVICE_ROWS_COLLECTION,
4238
+ filter
4239
+ });
4240
+ const out = [];
4241
+ for (const record of records) {
4242
+ const decoded = decodeDeviceRow(record.data);
4243
+ if (decoded === null) {
4244
+ this.logger.warn("device row skipped — identity fields missing", { meta: { rowId: record.id } });
4245
+ continue;
4246
+ }
4247
+ out.push(decoded);
4248
+ }
4249
+ return out;
4250
+ }
4251
+ /** Insert or replace the whole identity row. */
4252
+ async upsert(meta, extra = {}) {
4253
+ await this.declare();
4254
+ await this.backend.set({
4255
+ collection: DEVICE_ROWS_COLLECTION,
4256
+ key: String(meta.id),
4257
+ value: encodeDeviceRow(meta, extra)
4258
+ });
4259
+ }
4260
+ /**
4261
+ * Write the named columns of an EXISTING row and nothing else.
4262
+ *
4263
+ * An UPDATE, deliberately not an upsert: a partial upsert would try to INSERT
4264
+ * a row carrying only the patched columns, and SQLite rejects that on the
4265
+ * `NOT NULL` identity columns before the primary-key conflict can turn it
4266
+ * into an update. Every caller resolves the row under the write lock and
4267
+ * throws when it is gone, so matching zero rows means a device was removed
4268
+ * between the resolve and the write — the patch is lost, and a lost write
4269
+ * that says nothing reads as a write that happened.
4270
+ */
4271
+ async patch(deviceId, patch) {
4272
+ const columns = encodeDeviceRowPatch(patch);
4273
+ if (Object.keys(columns).length === 0) return;
4274
+ await this.declare();
4275
+ const { updated } = await this.backend.updateWhere({
4276
+ collection: DEVICE_ROWS_COLLECTION,
4277
+ filter: { where: { id: String(deviceId) } },
4278
+ data: columns
4279
+ });
4280
+ if (updated === 0) this.logger.warn("device row patch matched no row — the device was removed under it", {
4281
+ tags: { deviceId },
4282
+ meta: { columns: Object.keys(columns) }
4283
+ });
4284
+ }
4285
+ /**
4286
+ * Insert-or-update the registration columns. See {@link DeviceRegistrationRow}
4287
+ * for why this is a separate, fully-specified statement rather than a patch.
4288
+ */
4289
+ async upsertRegistration(row) {
4290
+ await this.declare();
4291
+ await this.backend.set({
4292
+ collection: DEVICE_ROWS_COLLECTION,
4293
+ key: String(row.deviceId),
4294
+ value: {
4295
+ deviceId: row.deviceId,
4296
+ addonId: row.addonId,
4297
+ stableId: row.stableId,
4298
+ type: row.type,
4299
+ name: row.name,
4300
+ userNamed: row.userNamed,
4301
+ location: row.location,
4302
+ disabled: row.disabled,
4303
+ parentDeviceId: row.parentDeviceId,
4304
+ registered: row.registered,
4305
+ features: [...row.features],
4306
+ exportFingerprint: row.exportFingerprint
4307
+ }
4308
+ });
4309
+ }
4310
+ /** Drop the device's row. Idempotent. */
4311
+ async remove(deviceId) {
4312
+ await this.declare();
4313
+ await this.backend.delete({
4314
+ collection: DEVICE_ROWS_COLLECTION,
4315
+ key: String(deviceId)
4316
+ });
4317
+ }
4318
+ /**
4319
+ * The retirement door for the three blobs this collection replaced.
4320
+ *
4321
+ * The purge is gated on THIS collection being non-empty, and the count has to
4322
+ * be taken after `declare()` — which is why the owning addon runs it and the
4323
+ * settings engine cannot: at the engine's own boot no addon has declared
4324
+ * anything, so every successor would read as unreadable, forever.
4325
+ */
4326
+ retiredRowStore() {
4327
+ return {
4328
+ hasRow: async (spec) => {
4329
+ const raw = await this.backend.get({
4330
+ collection: spec.collection,
4331
+ key: spec.row
4332
+ });
4333
+ return raw !== void 0 && raw !== null;
4334
+ },
4335
+ deleteRow: async (spec) => {
4336
+ await this.backend.delete({
4337
+ collection: spec.collection,
4338
+ key: spec.row
4339
+ });
4340
+ },
4341
+ countRows: async (collection) => {
4342
+ await this.declare();
4343
+ return this.backend.count({ collection });
4344
+ }
4345
+ };
4346
+ }
4347
+ };
4348
+ //#endregion
3992
4349
  //#region src/builtins/device-manager/runtime-state-persist-gate.ts
3993
4350
  /**
3994
4351
  * The subset of `blob` that is allowed on disk: slices whose capability
@@ -4553,12 +4910,21 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
4553
4910
  }
4554
4911
  }))).filter((id) => id !== null);
4555
4912
  }
4913
+ /**
4914
+ * Row access for `device-manager:devices`. Built once in `onInitialize`
4915
+ * (which is also where the collection is declared) — every consumer reaches
4916
+ * it through {@link bindingsDeps} or the `ProviderContext`.
4917
+ */
4918
+ deviceRows = null;
4556
4919
  /** Build the dependency context the extracted binding resolvers consume. */
4557
4920
  get bindingsDeps() {
4921
+ const rows = this.deviceRows;
4922
+ if (rows === null) throw new Error("[device-manager] device row store not initialized");
4558
4923
  return {
4559
4924
  ctx: this.ctx,
4560
4925
  capabilityRegistry: this.capabilityRegistry,
4561
- remoteNativeCaps: this.remoteNativeCaps
4926
+ remoteNativeCaps: this.remoteNativeCaps,
4927
+ rows
4562
4928
  };
4563
4929
  }
4564
4930
  async getBindings(input) {
@@ -4614,17 +4980,23 @@ var DeviceManagerAddon = class extends require_dist.BaseAddon {
4614
4980
  if (!ops) throw new Error(`[device-manager] device-ops native provider not found for '${deviceId}'`);
4615
4981
  return ops;
4616
4982
  };
4617
- const metaStore = new DeviceMetaStore(settings, registry);
4983
+ const settingsStoreApi = this.ctx.api?.settingsStore;
4984
+ if (!settingsStoreApi) throw new Error("[device-manager] settings-store API not available — refusing to serve a fleet it cannot persist");
4985
+ const deviceRows = new DeviceRowStore(deviceRowBackendOf(settingsStoreApi), this.ctx.logger.child("rows"));
4986
+ await deviceRows.declare();
4987
+ this.deviceRows = deviceRows;
4988
+ try {
4989
+ await require_retired_settings_keys.purgeRetiredSettingsRows(deviceRows.retiredRowStore(), this.ctx.logger.child("RetiredRows"));
4990
+ } catch (err) {
4991
+ this.ctx.logger.warn("retired-row purge failed", { meta: { error: require_dist.errMsg(err) } });
4992
+ }
4993
+ const metaStore = new DeviceMetaStore(settings, registry, deviceRows);
4618
4994
  this.stateMirrorImpl = new DeviceStateMirror(this.ctx);
4619
4995
  const stateMirror = this.stateMirrorImpl;
4620
- const readMeta = metaStore.readMeta;
4621
4996
  const resolvePersistedById = metaStore.resolvePersistedById;
4622
4997
  const idToAddonId = metaStore.idToAddonId;
4623
- {
4624
- const meta = await readMeta();
4625
- for (const m of Object.values(meta)) idToAddonId.set(m.id, m.addonId);
4626
- }
4627
- const stampIntegrationId$1 = (deviceId, integrationId) => stampIntegrationId(metaStore, settings, this.ctx, deviceId, integrationId);
4998
+ for (const row of await deviceRows.listAll()) idToAddonId.set(row.meta.id, row.meta.addonId);
4999
+ const stampIntegrationId$1 = (deviceId, integrationId) => stampIntegrationId(metaStore, this.ctx, deviceId, integrationId);
4628
5000
  const pctx = {
4629
5001
  host: this.providerHost,
4630
5002
  metaStore,