@camstack/core 0.1.37 → 0.1.39

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.
Files changed (82) hide show
  1. package/dist/auth/auth-manager.d.ts +12 -1
  2. package/dist/auth/auth-manager.d.ts.map +1 -1
  3. package/dist/auth/scope-matcher.d.ts +8 -0
  4. package/dist/auth/scope-matcher.d.ts.map +1 -0
  5. package/dist/auth/totp-manager.d.ts +0 -1
  6. package/dist/auth/totp-manager.d.ts.map +1 -1
  7. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts +15 -0
  8. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts.map +1 -1
  9. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +27 -6
  10. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js.map +1 -1
  11. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +27 -6
  12. package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs.map +1 -1
  13. package/dist/builtins/device-manager/device-config-contribution.d.ts +33 -0
  14. package/dist/builtins/device-manager/device-config-contribution.d.ts.map +1 -0
  15. package/dist/builtins/device-manager/device-manager.addon.d.ts +52 -17
  16. package/dist/builtins/device-manager/device-manager.addon.d.ts.map +1 -1
  17. package/dist/builtins/device-manager/device-manager.addon.js +285 -161
  18. package/dist/builtins/device-manager/device-manager.addon.js.map +1 -1
  19. package/dist/builtins/device-manager/device-manager.addon.mjs +286 -162
  20. package/dist/builtins/device-manager/device-manager.addon.mjs.map +1 -1
  21. package/dist/builtins/local-auth/auth-schema.d.ts +1 -0
  22. package/dist/builtins/local-auth/auth-schema.d.ts.map +1 -1
  23. package/dist/builtins/local-auth/local-auth.addon.d.ts +1 -0
  24. package/dist/builtins/local-auth/local-auth.addon.d.ts.map +1 -1
  25. package/dist/builtins/local-auth/local-auth.addon.js +354 -3
  26. package/dist/builtins/local-auth/local-auth.addon.js.map +1 -1
  27. package/dist/builtins/local-auth/local-auth.addon.mjs +355 -3
  28. package/dist/builtins/local-auth/local-auth.addon.mjs.map +1 -1
  29. package/dist/builtins/local-auth/oauth-grants.d.ts +46 -0
  30. package/dist/builtins/local-auth/oauth-grants.d.ts.map +1 -0
  31. package/dist/builtins/local-auth/oauth-session-manager.d.ts +51 -0
  32. package/dist/builtins/local-auth/oauth-session-manager.d.ts.map +1 -0
  33. package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts +97 -0
  34. package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts.map +1 -0
  35. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts +24 -1
  36. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts.map +1 -1
  37. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +136 -56
  38. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js.map +1 -1
  39. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +137 -57
  40. package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs.map +1 -1
  41. package/dist/builtins/snapshot/index.js +1 -3
  42. package/dist/builtins/snapshot/index.js.map +1 -1
  43. package/dist/builtins/snapshot/index.mjs +1 -3
  44. package/dist/builtins/snapshot/index.mjs.map +1 -1
  45. package/dist/builtins/snapshot/snapshot.addon.d.ts.map +1 -1
  46. package/dist/index.d.ts +1 -0
  47. package/dist/index.d.ts.map +1 -1
  48. package/dist/index.js +428 -234
  49. package/dist/index.js.map +1 -1
  50. package/dist/index.mjs +428 -235
  51. package/dist/index.mjs.map +1 -1
  52. package/package.json +19 -37
  53. package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.d.ts +0 -8
  54. package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.d.ts.map +0 -1
  55. package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.js +0 -75
  56. package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.js.map +0 -1
  57. package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.mjs +0 -69
  58. package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.mjs.map +0 -1
  59. package/dist/builtins/auth-orchestrator/index.d.ts +0 -2
  60. package/dist/builtins/auth-orchestrator/index.d.ts.map +0 -1
  61. package/dist/builtins/auth-orchestrator/index.js +0 -7
  62. package/dist/builtins/auth-orchestrator/index.mjs +0 -2
  63. package/dist/builtins/mesh-orchestrator/index.d.ts +0 -2
  64. package/dist/builtins/mesh-orchestrator/index.d.ts.map +0 -1
  65. package/dist/builtins/mesh-orchestrator/index.js +0 -7
  66. package/dist/builtins/mesh-orchestrator/index.mjs +0 -2
  67. package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.d.ts +0 -9
  68. package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.d.ts.map +0 -1
  69. package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.js +0 -113
  70. package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.js.map +0 -1
  71. package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.mjs +0 -107
  72. package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.mjs.map +0 -1
  73. package/dist/builtins/turn-orchestrator/index.d.ts +0 -2
  74. package/dist/builtins/turn-orchestrator/index.d.ts.map +0 -1
  75. package/dist/builtins/turn-orchestrator/index.js +0 -7
  76. package/dist/builtins/turn-orchestrator/index.mjs +0 -2
  77. package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.d.ts +0 -34
  78. package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.d.ts.map +0 -1
  79. package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.js +0 -126
  80. package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.js.map +0 -1
  81. package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.mjs +0 -120
  82. package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.mjs.map +0 -1
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceType, EventCategory, WELL_KNOWN_TAB_MAP, deviceManagerCapability, deviceStateCapability, errMsg } from "@camstack/types";
2
+ import { BaseAddon, CAP_NAMES_WITH_STATUS, DeviceFeature, DeviceType, EventCategory, STREAM_PROFILE_META, WELL_KNOWN_TAB_MAP, buildStreamParamsConfigSchema, deviceManagerCapability, deviceStateCapability, errMsg, isDeviceConfigCap, parseStreamParamsFormPatch } from "@camstack/types";
3
3
  //#region src/builtins/device-manager/device-event-propagator.ts
4
4
  /**
5
5
  * Walks the parent chain for every device-sourced event and re-emits a
@@ -107,6 +107,34 @@ var DeviceEventPropagator = class {
107
107
  return chain;
108
108
  }
109
109
  };
110
+ /**
111
+ * Build the device-detail form section for a `derived-form` device-config
112
+ * cap. Returns null when the camera exposes no configurable property.
113
+ */
114
+ function deriveFormContribution(builderId, options, status) {
115
+ if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
116
+ const schema = buildStreamParamsConfigSchema(options, status ?? null);
117
+ if (!schema) return null;
118
+ return { sections: schema.sections.map((s) => ({
119
+ id: s.id,
120
+ title: s.title,
121
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
122
+ ...s.order !== void 0 ? { order: s.order } : {},
123
+ ...s.description !== void 0 ? { description: s.description } : {},
124
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
125
+ fields: [...s.fields]
126
+ })) };
127
+ }
128
+ /**
129
+ * Route a flat form patch back through the cap's per-profile setter.
130
+ */
131
+ async function applyDerivedFormPatch(builderId, patch, setProfile) {
132
+ if (builderId !== "stream-params") throw new Error(`device-config: unknown derived-form builderId "${builderId}"`);
133
+ for (const meta of STREAM_PROFILE_META) {
134
+ const profilePatch = parseStreamParamsFormPatch(patch, meta.prefix);
135
+ if (profilePatch) await setProfile(meta.profile, profilePatch);
136
+ }
137
+ }
110
138
  //#endregion
111
139
  //#region src/builtins/device-manager/device-manager.addon.ts
112
140
  /**
@@ -133,6 +161,28 @@ var DeviceEventPropagator = class {
133
161
  * - `device-persistence` capability (absorbed here)
134
162
  * - live operations previously served by `device-management.router.ts`
135
163
  */
164
+ /**
165
+ * Return true when `err` is a transient Moleculer error that is worth
166
+ * retrying — specifically any `MoleculerRetryableError` subclass
167
+ * (ServiceNotAvailableError, ServiceNotFoundError, BrokerDisconnectedError,
168
+ * RequestTimeoutError, …). Moleculer sets `retryable: true` on all of them.
169
+ *
170
+ * Falls back to a message-substring check for serialised errors that arrive
171
+ * across the Moleculer transport as plain objects rather than real instances.
172
+ */
173
+ function isTransientMoleculerError(err) {
174
+ if (err !== null && typeof err === "object") {
175
+ const e = err;
176
+ if (e["retryable"] === true) return true;
177
+ const code = typeof e["code"] === "string" ? e["code"] : "";
178
+ if (code === "SERVICE_NOT_FOUND" || code === "SERVICE_NOT_AVAILABLE" || code === "REQUEST_TIMEOUT" || code === "BAD_GATEWAY") return true;
179
+ }
180
+ if (err instanceof Error) {
181
+ const msg = err.message;
182
+ if (msg.includes("is not available") || msg.includes("is not found") || msg.includes("transporter has disconnected") || msg.includes("Request timed out")) return true;
183
+ }
184
+ return false;
185
+ }
136
186
  function shallowEqual(a, b) {
137
187
  const ak = Object.keys(a);
138
188
  const bk = Object.keys(b);
@@ -355,11 +405,21 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
355
405
  static RUNTIME_STATE_DEBOUNCE_MS = 1e3;
356
406
  /**
357
407
  * Cross-process native-provider cache: deviceId (numeric) → capName → { addonId, nodeId }.
358
- * Kept in sync with `device.bindings-changed` events emitted by forked
359
- * workers. Union'd into `getBindings` so hub-side consumers see every
360
- * native cap regardless of which process owns the IDevice. No persistence
361
- * entries re-register when the worker restarts. Purged on
362
- * `$node.disconnected` to avoid stale routing after a worker crash.
408
+ * Kept in sync with `DeviceBindingsChanged` push events emitted by forked
409
+ * workers on `ctx.registerNativeCap` / device removal. Union'd into
410
+ * `getBindings` so hub-side consumers see every native cap regardless of
411
+ * which process owns the IDevice.
412
+ *
413
+ * No persistence — entries re-populate when the worker re-handshakes or
414
+ * re-emits its native-cap registrations after restart. Entries that were
415
+ * lost in the Moleculer transport handshake window are recovered lazily:
416
+ * `resolveNativeCapOwnerSync` and `getBindings` fall through to
417
+ * `ctx.kernel.listClusterNativeCaps()` (the handshake-fed
418
+ * `HubNodeRegistry`) when the push-based cache misses.
419
+ *
420
+ * The previous pull-based recovery (`syncWorkerNativeCaps`, driven by
421
+ * `$node.connected` + `addon.restarted`) has been removed in Task 13 —
422
+ * the D3 re-handshake after device restore is the reliable replacement.
363
423
  */
364
424
  remoteNativeCaps = /* @__PURE__ */ new Map();
365
425
  /** Wait for a device-provider by addonId, returning null on timeout. */
@@ -383,79 +443,34 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
383
443
  return "hub";
384
444
  }
385
445
  /**
386
- * Active discovery of native caps registered on a worker node.
387
- * Called from the `$node.connected` handler to recover from lost
388
- * `DeviceBindingsChanged` broadcasts after worker restart.
446
+ * Resolve a remote native cap entry for a given `(capName, deviceId)` by
447
+ * consulting the handshake-fed `HubNodeRegistry` via
448
+ * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
449
+ * `remoteNativeCaps` cache misses — covers the Moleculer transport
450
+ * handshake window where `DeviceBindingsChanged` events were lost but the
451
+ * D3 re-handshake (post device restore) has already populated the registry.
389
452
  *
390
- * Iterates the broker's service registry, picks
391
- * `<addonId>.native-provider.<capName>` services owned by the
392
- * connected node, calls each service's `$listDeviceIds` action to
393
- * fetch the deviceIds the worker has registered that cap on, then
394
- * writes entries into `remoteNativeCaps`.
395
- *
396
- * Best-effort: per-service failures are logged and swallowed so a
397
- * single bad service doesn't abort the whole rebuild.
453
+ * Returns `null` when the entry is genuinely not present in the cluster
454
+ * view (cap not registered on any worker for that device).
398
455
  */
399
- async discoverWorkerNativeCaps(connectedNodeId, _connectedAddonId) {
400
- const cluster = this.ctx.kernel.cluster;
401
- if (!cluster) return;
402
- const broker = cluster.broker;
403
- const services = broker.registry?.getServiceList?.({
404
- onlyAvailable: true,
405
- withActions: false
406
- }) ?? [];
407
- const NATIVE_INFIX = ".native-provider.";
408
- const matched = services.filter((s) => s.nodeID === connectedNodeId && s.name.includes(NATIVE_INFIX));
409
- if (matched.length === 0) return;
410
- for (const svc of matched) {
411
- const idx = svc.name.indexOf(NATIVE_INFIX);
412
- if (idx <= 0) continue;
413
- const addonId = svc.name.slice(0, idx);
414
- const capName = svc.name.slice(idx + 17);
415
- if (!addonId || !capName) continue;
416
- try {
417
- const action = `${svc.name}.$listDeviceIds`;
418
- const deviceIds = await broker.call?.(action, {}, { nodeID: connectedNodeId });
419
- if (!deviceIds || deviceIds.length === 0) continue;
420
- for (const deviceId of deviceIds) {
421
- if (this.capabilityRegistry?.getNativeAddonId(capName, deviceId)) continue;
422
- let perDevice = this.remoteNativeCaps.get(deviceId);
423
- if (!perDevice) {
424
- perDevice = /* @__PURE__ */ new Map();
425
- this.remoteNativeCaps.set(deviceId, perDevice);
426
- }
427
- perDevice.set(capName, {
428
- addonId,
429
- nodeId: connectedNodeId
430
- });
431
- }
432
- this.ctx.logger.debug("worker native-cap discovered", { meta: {
433
- nodeId: connectedNodeId,
434
- addonId,
435
- capName,
436
- deviceIds
437
- } });
438
- } catch (err) {
439
- this.ctx.logger.debug("worker native-cap $listDeviceIds failed", { meta: {
440
- service: svc.name,
441
- nodeId: connectedNodeId,
442
- error: errMsg(err)
443
- } });
444
- }
445
- }
446
- this.ctx.logger.info("worker native-cap discovery completed", { meta: {
447
- nodeId: connectedNodeId,
448
- services: matched.length
449
- } });
456
+ resolveRemoteNativeCapFromRegistry(capName, deviceId) {
457
+ const clusterCaps = this.ctx.kernel.listClusterNativeCaps?.();
458
+ if (!clusterCaps) return null;
459
+ for (const entry of clusterCaps) if (entry.capName === capName && entry.deviceId === deviceId && entry.addonId) return {
460
+ addonId: entry.addonId,
461
+ nodeId: entry.nodeId
462
+ };
463
+ return null;
450
464
  }
451
465
  async getBindings(input) {
452
466
  const storeKey = String(input.deviceId);
453
467
  const perDevice = (await this.readBindingsStore()).deviceBindings[storeKey] ?? {};
454
468
  const entries = [];
455
469
  const seenCaps = /* @__PURE__ */ new Set();
470
+ const resolveRemote = (capName) => this.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? this.resolveRemoteNativeCapFromRegistry(capName, input.deviceId);
456
471
  for (const [capName, { wrapperAddonId }] of Object.entries(perDevice)) {
457
472
  const hubLocalNative = this.capabilityRegistry?.getNativeAddonId(capName, input.deviceId) ?? null;
458
- const remoteNative = this.remoteNativeCaps.get(input.deviceId)?.get(capName) ?? null;
473
+ const remoteNative = resolveRemote(capName);
459
474
  const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
460
475
  const nativeNodeId = hubLocalNative ? this.ctx.kernel.localNodeId ?? "hub" : remoteNative?.nodeId ?? this.ctx.kernel.localNodeId ?? "hub";
461
476
  if (wrapperAddonId === null && !nativeAddonId) {
@@ -471,13 +486,12 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
471
486
  });
472
487
  seenCaps.add(capName);
473
488
  }
474
- const remote = this.remoteNativeCaps.get(input.deviceId);
475
489
  if (this.capabilityRegistry) for (const capName of this.capabilityRegistry.getCapsWithDefaultWrapper()) {
476
490
  if (seenCaps.has(capName)) continue;
477
491
  const defaultWrapperAddonId = this.capabilityRegistry.getDefaultWrapperForCap(capName);
478
492
  if (!defaultWrapperAddonId) continue;
479
493
  const hubLocalNative = this.capabilityRegistry.getNativeAddonId(capName, input.deviceId) ?? null;
480
- const remoteNative = remote?.get(capName) ?? null;
494
+ const remoteNative = resolveRemote(capName);
481
495
  const nativeAddonId = hubLocalNative ?? remoteNative?.addonId ?? "";
482
496
  entries.push({
483
497
  capName,
@@ -500,7 +514,8 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
500
514
  });
501
515
  seenCaps.add(capName);
502
516
  }
503
- if (remote) for (const [capName, info] of remote) {
517
+ const pushFed = this.remoteNativeCaps.get(input.deviceId);
518
+ if (pushFed) for (const [capName, info] of pushFed) {
504
519
  if (seenCaps.has(capName)) continue;
505
520
  entries.push({
506
521
  capName,
@@ -511,6 +526,22 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
511
526
  });
512
527
  seenCaps.add(capName);
513
528
  }
529
+ const clusterCaps = this.ctx.kernel.listClusterNativeCaps?.();
530
+ if (clusterCaps) for (const entry of clusterCaps) {
531
+ if (entry.deviceId !== input.deviceId) continue;
532
+ if (seenCaps.has(entry.capName)) continue;
533
+ if (!entry.addonId) continue;
534
+ const localNodeId = this.ctx.kernel.localNodeId ?? "hub";
535
+ if (entry.nodeId === localNodeId) continue;
536
+ entries.push({
537
+ capName: entry.capName,
538
+ kind: "native",
539
+ providerAddonId: entry.addonId,
540
+ providerNodeId: entry.nodeId,
541
+ nativeAddonId: entry.addonId
542
+ });
543
+ seenCaps.add(entry.capName);
544
+ }
514
545
  return {
515
546
  deviceId: input.deviceId,
516
547
  entries
@@ -556,42 +587,26 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
556
587
  return null;
557
588
  }
558
589
  const method = kind === "settings" ? "getDeviceSettingsContribution" : "getDeviceLiveContribution";
559
- const contributors = [];
560
- for (const info of registry.listCapabilities()) {
561
- if (!registry.getDefinition(info.name)?.exposesDeviceSettings) continue;
562
- const seen = /* @__PURE__ */ new Set();
563
- const sorted = [...info.providers].sort((a, b) => a.length - b.length);
564
- for (const addonId of sorted) {
565
- if (addonId.includes("::native-")) continue;
566
- const baseId = addonId.includes("@") ? addonId.slice(0, addonId.indexOf("@")) : addonId;
567
- if (seen.has(baseId)) continue;
568
- seen.add(baseId);
569
- contributors.push({
570
- capName: info.name,
571
- addonId
572
- });
573
- }
574
- }
575
- const results = await Promise.all(contributors.map(async ({ capName, addonId }) => {
576
- const provider = registry.getProviderByAddon(capName, addonId);
577
- if (!provider) throw new Error(`[device-manager] capability "${capName}" lists provider "${addonId}" but getProviderByAddon returned null — registry inconsistency`);
590
+ const { entries: bindingEntries } = await this.getBindings({ deviceId });
591
+ const results = await Promise.all(bindingEntries.map(async (entry) => {
592
+ const def = registry.getDefinition(entry.capName);
593
+ if (isDeviceConfigCap(def)) return this.deriveDeviceConfigContribution(registry, def, entry, deviceId, kind);
594
+ if (!def?.exposesDeviceSettings) return null;
595
+ const provider = registry.getProviderForDevice(entry.capName, deviceId);
596
+ if (!provider) return null;
578
597
  try {
579
598
  const contribution = await provider[method]({ deviceId });
580
599
  if (!contribution) return null;
581
- return {
582
- capName,
583
- addonId,
584
- contribution: tagContribution(toWireShape(contribution), capName, addonId, kind)
585
- };
600
+ return tagContribution(toWireShape(contribution), entry.capName, entry.providerAddonId, kind);
586
601
  } catch (err) {
587
602
  const msg = err instanceof Error ? err.message : String(err);
588
603
  this.ctx.logger.warn("contribution method failed", {
589
604
  tags: {
590
605
  deviceId,
591
- addonId
606
+ addonId: entry.providerAddonId
592
607
  },
593
608
  meta: {
594
- capName,
609
+ capName: entry.capName,
595
610
  method,
596
611
  error: msg
597
612
  }
@@ -600,11 +615,75 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
600
615
  }
601
616
  }));
602
617
  const base = kind === "settings" ? await this.buildBaseDeviceSection(deviceId) : null;
603
- const parts = [...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [], ...results.filter((r) => r !== null).map((r) => r.contribution)];
618
+ const parts = [...base ? [tagContribution(base, "device-manager", "device-manager", kind)] : [], ...results.filter((r) => r !== null)];
604
619
  if (parts.length === 0) return null;
605
620
  return mergeAggregates(parts);
606
621
  }
607
622
  /**
623
+ * D14: framework-derived device-config contribution.
624
+ *
625
+ * `kind === 'live'` — device-config caps contribute nothing to the live
626
+ * aggregate (they hold editable config, not live observables).
627
+ *
628
+ * `ui.kind === 'widget'` — emits a single structural `type:'widget'`
629
+ * section; the widget self-persists via the cap's own mutations.
630
+ *
631
+ * `ui.kind === 'derived-form'` — calls `getOptions`/`getStatus` on the
632
+ * bound provider, runs the registered pure builder, and returns the
633
+ * derived form sections. Returns null when the camera exposes nothing
634
+ * configurable or the provider is not yet registered.
635
+ */
636
+ async deriveDeviceConfigContribution(registry, def, entry, deviceId, kind) {
637
+ if (kind === "live") return null;
638
+ const ui = def.deviceConfig.ui;
639
+ if (ui.kind === "widget") return tagContribution({ sections: [{
640
+ id: `${entry.capName}-widget`,
641
+ title: ui.label,
642
+ tab: ui.tab,
643
+ order: ui.order ?? 0,
644
+ ...ui.topTab ? { location: "top-tab" } : {},
645
+ fields: [{
646
+ type: "widget",
647
+ key: `${entry.capName}Widget`,
648
+ label: ui.label,
649
+ widgetId: ui.widgetId
650
+ }]
651
+ }] }, entry.capName, entry.providerAddonId, kind);
652
+ const provider = registry.getProviderForDevice(entry.capName, deviceId);
653
+ if (!provider || typeof provider.getOptions !== "function") return null;
654
+ try {
655
+ const options = await provider.getOptions({ deviceId });
656
+ const status = typeof provider.getStatus === "function" ? await provider.getStatus({ deviceId }) : null;
657
+ const derived = deriveFormContribution(ui.builderId, options, status ?? null);
658
+ if (!derived) return null;
659
+ return tagContribution({
660
+ ...derived.tabs ? { tabs: derived.tabs.map((t) => ({ ...t })) } : {},
661
+ sections: derived.sections.map((s) => ({
662
+ id: s.id,
663
+ title: s.title,
664
+ ...s.tab !== void 0 ? { tab: s.tab } : {},
665
+ ...s.order !== void 0 ? { order: s.order } : {},
666
+ ...s.description !== void 0 ? { description: s.description } : {},
667
+ ...s.columns !== void 0 ? { columns: s.columns } : {},
668
+ fields: [...s.fields]
669
+ }))
670
+ }, entry.capName, entry.providerAddonId, kind);
671
+ } catch (err) {
672
+ const msg = err instanceof Error ? err.message : String(err);
673
+ this.ctx.logger.warn("device-config derivation failed", {
674
+ tags: {
675
+ deviceId,
676
+ addonId: entry.providerAddonId
677
+ },
678
+ meta: {
679
+ capName: entry.capName,
680
+ error: msg
681
+ }
682
+ });
683
+ return null;
684
+ }
685
+ }
686
+ /**
608
687
  * Build the device-manager's own contribution to the aggregator — the
609
688
  * device identity (id, stableId, addonId, type, online) + the
610
689
  * driver-specific config exposed by the device class via
@@ -674,8 +753,8 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
674
753
  }] : []
675
754
  ]
676
755
  }];
677
- const driverSchema = await this.resolveDriverConfigSchema(deviceId, hubLocal);
678
- if (driverSchema) for (const section of driverSchema.sections) sections.push({
756
+ const driverResult = await this.resolveDriverConfigSchema(deviceId, hubLocal);
757
+ if (driverResult.status === "ok") for (const section of driverResult.schema.sections) sections.push({
679
758
  id: section.id,
680
759
  title: section.title,
681
760
  tab: section.tab ?? "general",
@@ -684,9 +763,22 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
684
763
  ...section.description !== void 0 ? { description: section.description } : {},
685
764
  ...section.columns !== void 0 ? { columns: section.columns } : {}
686
765
  });
766
+ else if (driverResult.status === "unavailable") sections.push({
767
+ id: "driver-settings-unavailable",
768
+ title: "Driver Settings",
769
+ tab: "general",
770
+ order: 1,
771
+ fields: [{
772
+ type: "info",
773
+ key: "driver-settings-unavailable-notice",
774
+ label: "Temporarily unavailable",
775
+ content: "Driver settings are temporarily unavailable — the addon may be restarting. Refresh the page in a moment.",
776
+ variant: "warning"
777
+ }]
778
+ });
687
779
  return {
688
780
  sections,
689
- ...driverSchema?.tabs ? { tabs: [...driverSchema.tabs] } : {}
781
+ ...driverResult.status === "ok" && driverResult.schema.tabs ? { tabs: [...driverResult.schema.tabs] } : {}
690
782
  };
691
783
  }
692
784
  /**
@@ -699,7 +791,7 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
699
791
  addonId: local,
700
792
  nodeId: this.ctx.kernel.localNodeId ?? "hub"
701
793
  };
702
- const remote = this.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? null;
794
+ const remote = this.remoteNativeCaps.get(deviceId)?.get("device-ops") ?? this.resolveRemoteNativeCapFromRegistry("device-ops", deviceId);
703
795
  return remote ? {
704
796
  addonId: remote.addonId,
705
797
  nodeId: remote.nodeId
@@ -735,7 +827,7 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
735
827
  out[capName] = null;
736
828
  return;
737
829
  }
738
- const provider = registry.getNativeProvider(capName, input.deviceId);
830
+ const provider = registry.getProviderForDevice(capName, input.deviceId);
739
831
  if (!provider || typeof provider.getStatus !== "function") {
740
832
  out[capName] = null;
741
833
  return;
@@ -776,27 +868,54 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
776
868
  * devices call `getSettingsUISchema()` directly; forked-worker devices
777
869
  * go through the `device-ops.getSettingsSchema` cap method on the
778
870
  * numeric-id-keyed native registry.
871
+ *
872
+ * Returns a discriminated result so callers can distinguish three states:
873
+ * 'ok' – schema obtained successfully
874
+ * 'none' – driver genuinely has no settings schema
875
+ * 'unavailable' – worker was unreachable after retries (transient)
779
876
  */
780
877
  async resolveDriverConfigSchema(deviceId, hubLocal) {
781
878
  if (hubLocal) {
782
879
  const schema = hubLocal.device.getSettingsUISchema();
783
- return schema.sections.length === 0 ? null : toWireShape(schema);
880
+ return schema.sections.length === 0 ? { status: "none" } : {
881
+ status: "ok",
882
+ schema: toWireShape(schema)
883
+ };
784
884
  }
785
885
  const ops = this.capabilityRegistry?.getNativeProvider("device-ops", deviceId);
786
- if (!ops) return null;
787
- try {
788
- const schema = await ops.getSettingsSchema({ deviceId });
789
- if (!schema) return null;
790
- const wire = schema;
791
- return wire.sections.length === 0 ? null : toWireShape(wire);
792
- } catch (err) {
793
- const msg = err instanceof Error ? err.message : String(err);
794
- this.ctx.logger.warn("cross-process getSettingsSchema failed", {
795
- tags: { deviceId },
796
- meta: { error: msg }
797
- });
798
- return null;
886
+ if (!ops) return { status: "none" };
887
+ const MAX_RETRIES = 2;
888
+ const RETRY_DELAY_MS = 750;
889
+ let lastErr;
890
+ for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
891
+ if (attempt > 0) await new Promise((resolve) => setTimeout(resolve, RETRY_DELAY_MS));
892
+ try {
893
+ const schema = await ops.getSettingsSchema({ deviceId });
894
+ if (!schema) return { status: "none" };
895
+ const wire = schema;
896
+ return wire.sections.length === 0 ? { status: "none" } : {
897
+ status: "ok",
898
+ schema: toWireShape(wire)
899
+ };
900
+ } catch (err) {
901
+ lastErr = err;
902
+ if (!isTransientMoleculerError(err)) break;
903
+ if (attempt < MAX_RETRIES) this.ctx.logger.debug("cross-process getSettingsSchema transient failure, retrying", {
904
+ tags: { deviceId },
905
+ meta: {
906
+ attempt: attempt + 1,
907
+ maxRetries: MAX_RETRIES,
908
+ error: errMsg(err)
909
+ }
910
+ });
911
+ }
799
912
  }
913
+ const msg = errMsg(lastErr);
914
+ this.ctx.logger.warn("cross-process getSettingsSchema failed after retries — driver settings temporarily unavailable", {
915
+ tags: { deviceId },
916
+ meta: { error: msg }
917
+ });
918
+ return { status: "unavailable" };
800
919
  }
801
920
  async updateDeviceField(input) {
802
921
  if (input.writerCapName === "device-manager") {
@@ -816,16 +935,38 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
816
935
  }
817
936
  const registry = this.capabilityRegistry;
818
937
  if (!registry) throw new Error("[device-manager] updateDeviceField requires capability registry — unavailable on this node");
819
- if (!registry.getDefinition(input.writerCapName)?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
820
- const provider = registry.getProviderByAddon(input.writerCapName, input.writerAddonId);
821
- if (!provider) throw new Error(`[device-manager] provider "${input.writerAddonId}" not registered for cap "${input.writerCapName}"`);
822
- await provider.applyDeviceSettingsPatch({
938
+ const def = registry.getDefinition(input.writerCapName);
939
+ if (isDeviceConfigCap(def)) {
940
+ if (def.deviceConfig.ui.kind === "widget") return { success: true };
941
+ const dcProvider = registry.getProviderForDevice(input.writerCapName, input.deviceId);
942
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${input.writerCapName}" on device ${input.deviceId}`);
943
+ await applyDerivedFormPatch(def.deviceConfig.ui.builderId, { [input.key]: input.value }, (profile, patch) => dcProvider.setProfile({
944
+ deviceId: input.deviceId,
945
+ profile,
946
+ patch
947
+ }));
948
+ return { success: true };
949
+ }
950
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${input.writerCapName}" does not expose device settings`);
951
+ await this.resolveContributionProvider(registry, def, input.writerCapName, input.writerAddonId, input.deviceId).applyDeviceSettingsPatch({
823
952
  deviceId: input.deviceId,
824
953
  patch: { [input.key]: input.value }
825
954
  });
826
955
  return { success: true };
827
956
  }
828
957
  /**
958
+ * Resolve the `DeviceSettingsContribution` provider that owns a tagged
959
+ * field. System-scoped caps resolve via `getProviderByAddon`; native
960
+ * device-scoped caps (registered per-device via `registerNativeCap`)
961
+ * live in the separate native map and resolve via `getNativeProvider`.
962
+ * Throws only if BOTH lookups miss.
963
+ */
964
+ resolveContributionProvider(registry, _def, writerCapName, writerAddonId, deviceId) {
965
+ const provider = registry.getProviderForDevice(writerCapName, deviceId);
966
+ if (provider) return provider;
967
+ throw new Error(`[device-manager] no provider for cap "${writerCapName}" (addon "${writerAddonId}") on device ${deviceId}`);
968
+ }
969
+ /**
829
970
  * Batched counterpart of `updateDeviceField`. Groups changes by
830
971
  * `(writerCapName, writerAddonId)` so each contributor receives a
831
972
  * single `applyDeviceSettingsPatch` with all of its updates merged —
@@ -883,10 +1024,20 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
883
1024
  }
884
1025
  const registry = this.capabilityRegistry;
885
1026
  if (!registry) throw new Error("[device-manager] capability registry unavailable");
886
- if (!registry.getDefinition(group.writerCapName)?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
887
- const provider = registry.getProviderByAddon(group.writerCapName, group.writerAddonId);
888
- if (!provider) throw new Error(`[device-manager] provider "${group.writerAddonId}" not registered for cap "${group.writerCapName}"`);
889
- await provider.applyDeviceSettingsPatch({
1027
+ const def = registry.getDefinition(group.writerCapName);
1028
+ if (isDeviceConfigCap(def)) {
1029
+ if (def.deviceConfig.ui.kind === "widget") return;
1030
+ const dcProvider = registry.getProviderForDevice(group.writerCapName, deviceId);
1031
+ if (!dcProvider) throw new Error(`[device-manager] no provider for device-config cap "${group.writerCapName}" on device ${deviceId}`);
1032
+ await applyDerivedFormPatch(def.deviceConfig.ui.builderId, group.patch, (profile, patch) => dcProvider.setProfile({
1033
+ deviceId,
1034
+ profile,
1035
+ patch
1036
+ }));
1037
+ return;
1038
+ }
1039
+ if (!def?.exposesDeviceSettings) throw new Error(`[device-manager] cap "${group.writerCapName}" does not expose device settings`);
1040
+ await this.resolveContributionProvider(registry, def, group.writerCapName, group.writerAddonId, deviceId).applyDeviceSettingsPatch({
890
1041
  deviceId,
891
1042
  patch: group.patch
892
1043
  });
@@ -1040,35 +1191,6 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
1040
1191
  idToAddonId.set(m.id, key.slice(0, sep));
1041
1192
  }
1042
1193
  }
1043
- if (cluster) cluster.broker.localBus.on("$node.connected", (payload) => {
1044
- const connectedNodeId = payload.node.id;
1045
- const lastSlash = connectedNodeId.lastIndexOf("/");
1046
- if (lastSlash < 0) return;
1047
- const connectedAddonId = connectedNodeId.slice(lastSlash + 1);
1048
- if (connectedAddonId.length === 0) return;
1049
- for (const [deviceId, ownerAddonId] of idToAddonId) {
1050
- if (ownerAddonId !== connectedAddonId) continue;
1051
- if (this.capabilityRegistry?.getNativeAddonId("device-ops", deviceId)) continue;
1052
- let perDevice = this.remoteNativeCaps.get(deviceId);
1053
- if (!perDevice) {
1054
- perDevice = /* @__PURE__ */ new Map();
1055
- this.remoteNativeCaps.set(deviceId, perDevice);
1056
- }
1057
- if (!perDevice.has("device-ops")) perDevice.set("device-ops", {
1058
- addonId: connectedAddonId,
1059
- nodeId: connectedNodeId
1060
- });
1061
- }
1062
- setTimeout(() => {
1063
- this.discoverWorkerNativeCaps(connectedNodeId, connectedAddonId).catch((err) => {
1064
- this.ctx.logger.warn("worker native-cap discovery failed", { meta: {
1065
- nodeId: connectedNodeId,
1066
- addonId: connectedAddonId,
1067
- error: errMsg(err)
1068
- } });
1069
- });
1070
- }, 500);
1071
- });
1072
1194
  const allocateNextDeviceId = async () => {
1073
1195
  const current = (await readStore()).nextDeviceId ?? 1;
1074
1196
  await settings.writeAddonStore({ nextDeviceId: current + 1 });
@@ -1091,12 +1213,14 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
1091
1213
  * so the hub only synthesizes a cross-process proxy when the cap is
1092
1214
  * actually published — never on speculative device ownership.
1093
1215
  *
1094
- * Consults hub-local registrations first (in-process natives),
1095
- * then the `remoteNativeCaps` map populated from
1096
- * `DeviceBindingsChanged` events emitted by forked-worker
1097
- * `registerNativeCap` calls. Both are generic: any addon that hosts
1098
- * devices and registers caps via the standard context API shows up
1099
- * here without per-addon branching.
1216
+ * Resolution order:
1217
+ * 1. Hub-local `capabilityRegistry` (in-process natives — fastest path).
1218
+ * 2. Push-fed `remoteNativeCaps` cache (`DeviceBindingsChanged` events
1219
+ * from forked workers accurate in steady state).
1220
+ * 3. Handshake-fed `HubNodeRegistry` via `listClusterNativeCaps()`
1221
+ * (D3 re-handshake after device restore — covers the Moleculer
1222
+ * transport window where push events were lost). This is the
1223
+ * reliable replacement for the deleted `syncWorkerNativeCaps` pull.
1100
1224
  */
1101
1225
  resolveNativeCapOwnerSync: (capName, deviceId) => {
1102
1226
  const localAddonId = this.capabilityRegistry?.getNativeAddonId(capName, deviceId) ?? null;
@@ -1109,7 +1233,7 @@ var DeviceManagerAddon = class DeviceManagerAddon extends BaseAddon {
1109
1233
  addonId: remote.addonId,
1110
1234
  nodeId: remote.nodeId
1111
1235
  };
1112
- return null;
1236
+ return this.resolveRemoteNativeCapFromRegistry(capName, deviceId);
1113
1237
  },
1114
1238
  /** Idempotent numeric-id reservation. Callers invoke this before
1115
1239
  * constructing the owning `IDevice` so `DeviceContext.id` is bound