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