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