@camstack/system 1.1.11 → 1.1.12

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,33 +1,8 @@
1
1
  import { ProviderRegistration, BaseAddon, DeviceBindingEntry } from '@camstack/types';
2
- /**
3
- * Wire shape matching `z.infer<typeof SettingsSchemaWithValuesSchema>` —
4
- * duplicated as a plain interface because importing the Zod schema across
5
- * package boundaries confuses `tsc` when the types package pins a
6
- * different `zod` minor than the core package. Keeping the shape local
7
- * keeps the addon decoupled from the schema's internal type encoding.
8
- */
9
- interface ContributionShape {
10
- tabs?: Array<{
11
- id: string;
12
- label: string;
13
- icon: string;
14
- order?: number;
15
- }>;
16
- sections: Array<{
17
- id: string;
18
- title: string;
19
- description?: string;
20
- style?: 'card' | 'accordion';
21
- defaultCollapsed?: boolean;
22
- columns?: 1 | 2 | 3 | 4;
23
- tab?: string;
24
- /** Where the section renders. Default 'settings' (Config tab); 'top-tab' hoists into the device-detail tab bar via DeviceDetail discovery. */
25
- location?: 'settings' | 'top-tab';
26
- order?: number;
27
- fields: unknown[];
28
- }>;
29
- }
30
- export declare function mergeAggregates(parts: readonly ContributionShape[]): ContributionShape;
2
+ import { ContributionShape } from './device-meta-types.js';
3
+ import { mergeAggregates } from './device-aggregation-merge.js';
4
+ import { ResolvedTargetLink, DependentLink } from './device-link-overlay.js';
5
+ export { mergeAggregates };
31
6
  export declare class DeviceManagerAddon extends BaseAddon {
32
7
  constructor();
33
8
  /** Shorthand for the kernel-injected capability registry. */
@@ -48,22 +23,13 @@ export declare class DeviceManagerAddon extends BaseAddon {
48
23
  * (e.g. `battery.onStatusChanged`) get the same data via
49
24
  * cap-specific events still emitted by the owning device.
50
25
  *
51
- * Key: deviceId. Value: per-cap slice map. Empty by default
52
- * slices show up as `setCapSlice` calls trickle in.
53
- */
54
- private readonly stateMirror;
55
- /**
56
- * Per-device disk-write debouncer for runtime-state. `setCapSlice`
57
- * updates the in-memory mirror synchronously and emits the change
58
- * event immediately, but the disk write is coalesced — frequent
59
- * back-to-back writes (motion phase transitions, battery pushes,
60
- * etc.) collapse to one `writeDeviceRuntimeState` per
61
- * `RUNTIME_STATE_DEBOUNCE_MS` window. `flushRuntimeStateWrites`
62
- * awaits any in-flight write + scheduled flush so shutdown is
63
- * lossless.
26
+ * Key: deviceId. Value: per-cap slice map. Empty by default.
27
+ *
28
+ * The mirror + its debounced disk-writer + the cross-device link overlay
29
+ * live in `DeviceStateMirror`. Constructed once in `onInitialize` (needs
30
+ * `ctx`); `null` before initialise / when `ctx.settings` is absent.
64
31
  */
65
- private readonly runtimeStateDebounce;
66
- private static readonly RUNTIME_STATE_DEBOUNCE_MS;
32
+ private stateMirrorImpl;
67
33
  /**
68
34
  * Cross-process native-provider cache: deviceId (numeric) → capName → { addonId, nodeId }.
69
35
  * Kept in sync with `DeviceBindingsChanged` push events emitted by forked
@@ -93,8 +59,6 @@ export declare class DeviceManagerAddon extends BaseAddon {
93
59
  private linkTargets;
94
60
  /** `${sourceDeviceId}:${sourceCap}` → targets to recompute when that source changes. */
95
61
  private linkDependents;
96
- /** Loop/churn guard: last overlaid slice emitted per `${deviceId}:${cap}`. */
97
- private readonly lastEmittedOverlay;
98
62
  /** Expected source `stableId`s (`${container}-${sourceKey}`) across all links,
99
63
  * resolved or not — gates the `registerDevice` rebuild so only a registering
100
64
  * device that IS a link source triggers a reindex (not every boot restore). */
@@ -102,173 +66,54 @@ export declare class DeviceManagerAddon extends BaseAddon {
102
66
  /** Test/diagnostic accessors. */
103
67
  linkTargetKeys(): string[];
104
68
  linkDependentsKeys(): string[];
69
+ /** Replace the cross-device link reverse-index maps in one atomic swap.
70
+ * Sole writer of `linkTargets` / `linkDependents` / `expectedSourceStableIds`;
71
+ * called by `DeviceMetaStore.rebuildLinkDependents` via the `LinkIndexHost`. */
72
+ setLinkIndexes(targets: Map<string, ResolvedTargetLink[]>, dependents: Map<string, DependentLink[]>, expectedSourceStableIds: Set<string>): void;
73
+ /** Build the `LinkIndexHost` the meta store mutates — exposes this addon's
74
+ * `devicesWithLinks` gate (read) + `setLinkIndexes` (write) without leaking
75
+ * the private maps. */
76
+ private get linkIndexHost();
77
+ /** The runtime-state mirror, asserted present (constructed in onInitialize). */
78
+ private get stateMirror();
79
+ /** Build the `LinkOverlayHost` the state mirror reads when overlaying linked
80
+ * values — exposes the link reverse-index maps + registry via LIVE thunks
81
+ * (the maps are reassigned on every `rebuildLinkDependents`). Arrow thunks
82
+ * capture `this` lexically so the getters never go stale. */
83
+ private get linkOverlayHost();
84
+ /** Build the `ProviderHost` the extracted provider-method modules
85
+ * (`device-meta-actions`, `device-queries`) reach — exposes the addon's
86
+ * private state/methods they need without leaking the class internals.
87
+ *
88
+ * `expectedSourceStableIds` is exposed via a LIVE thunk: the field is
89
+ * reassigned to a fresh Set on every `rebuildLinkDependents`, so a captured
90
+ * reference would go stale. `devicesWithLinks` / `remoteNativeCaps` are
91
+ * mutated in place (never reassigned) so a direct reference preserves the
92
+ * original live semantics. The overlay-emit guard now lives in the state
93
+ * mirror — pruning is routed through `dropDeviceOverlays`. */
94
+ private get providerHost();
105
95
  /** Wait for a device-provider by addonId, returning null on timeout. */
106
96
  private waitDeviceProvider;
107
97
  /** Require a device-provider by addonId — throws if not found. */
108
98
  private requireDeviceProvider;
109
99
  /** Require a device-adoption provider by addonId — throws if not found. */
110
100
  private requireDeviceAdoptionProvider;
111
- private readBindingsStore;
112
- private writeBindingsStore;
113
- private resolveWrapperNodeId;
114
- /**
115
- * Reduce a provider node id to the routable form `DeviceProxy` can pin.
116
- *
117
- * Every addon runs in its own `addon-runner` with the composite node id
118
- * `${parentNodeId}/${runnerId}` (see `addon-runner.ts`) — e.g.
119
- * `hub/provider-reolink` or `dev-agent-0/provider-reolink`. That composite is
120
- * an IDENTITY, not a routable target: the `CapRouteResolver` reaches a child
121
- * only THROUGH its parent (the hub resolves a hub-local-uds child by
122
- * cap+device; an agent forwards to its own child). `DeviceProxy` pins
123
- * `entry.providerNodeId` on every cap call, so a binding entry must expose the
124
- * parent node id — otherwise the explicit pin classifies as `remote-moleculer`
125
- * to an unknown node → `no-provider`, which surfaces as
126
- * "this camera doesn't expose …" for client-proxy-driven widget caps
127
- * (motion-zones, privacy-mask). Wrappers already report the parent via
128
- * `resolveWrapperNodeId`; this aligns natives with the same contract.
129
- *
130
- * A flat node id (a genuine standalone node with no `/`) is returned
131
- * unchanged.
132
- */
133
- private toRoutableProviderNodeId;
134
- /**
135
- * Resolve a remote native cap entry for a given `(capName, deviceId)` by
136
- * consulting the handshake-fed `HubNodeRegistry` via
137
- * `ctx.kernel.listClusterNativeCaps()`. Called when the push-based
138
- * `remoteNativeCaps` cache misses — covers the Moleculer transport
139
- * handshake window where `DeviceBindingsChanged` events were lost but the
140
- * D3 re-handshake (post device restore) has already populated the registry.
141
- *
142
- * Returns `null` when the entry is genuinely not present in the cluster
143
- * view (cap not registered on any worker for that device).
144
- */
145
- private resolveRemoteNativeCapFromRegistry;
101
+ /** Build the dependency context the extracted binding resolvers consume. */
102
+ private get bindingsDeps();
146
103
  getBindings(input: {
147
104
  deviceId: number;
148
105
  }): Promise<{
149
106
  deviceId: number;
150
107
  entries: DeviceBindingEntry[];
151
108
  }>;
152
- /**
153
- * Whole-fleet binding dump. Iterates every device known to the
154
- * deviceRegistry and reuses the per-device `getBindings` resolver
155
- * for each — same routing rules, single round-trip. Used by
156
- * `SystemManager.init()` for warm-boot.
157
- *
158
- * Bindings change rarely (wrapper toggle, device add/remove) so
159
- * clients invalidate via the existing
160
- * `capability.binding-changed` event rather than re-fetching this
161
- * payload periodically.
162
- */
163
109
  getAllBindings(): Promise<Array<{
164
110
  deviceId: number;
165
111
  entries: DeviceBindingEntry[];
166
112
  }>>;
167
- /**
168
- * Resolve a numeric deviceId to a stableId via persisted meta.
169
- * Used only by the device-identity section of the device-details
170
- * aggregator (see `buildBaseDeviceSection`) to surface the stableId as
171
- * a readonly display field. All runtime/registry lookups are keyed by
172
- * numeric deviceId; this helper is display-only.
173
- */
174
- private lookupPersistedStableId;
113
+ /** Build the dependency context the extracted aggregation functions consume. */
114
+ private get aggregationDeps();
175
115
  getDeviceAggregate(deviceId: number, kind: 'settings' | 'live'): Promise<ContributionShape | null>;
176
- /**
177
- * System-scoped per-device contributions — the deliberate exception to
178
- * binding-driven aggregation (D12).
179
- *
180
- * A `scope: 'system'` cap with `exposesDeviceSettings: true` holds per-device
181
- * state but is NOT bound per-device: it is a system addon, not a BaseDevice,
182
- * so it never calls `registerNativeCap` and never appears in
183
- * `getBindings(deviceId)`. The binding-driven loop therefore skips it and its
184
- * per-device panel never renders. Two such caps ship today:
185
- * - `stream-broker` (singleton) — the RTSP-restream / pre-buffer settings
186
- * tab, scoped to the cameras whose streams it serves.
187
- * - `device-export` (collection) — the "Export" panel; a device can be
188
- * exposed to MULTIPLE exporters at once (Alexa + HomeKit + HA-MQTT).
189
- *
190
- * D12 bans fanning a per-device call out to every same-named provider of a
191
- * `scope: 'device'` cap, because vendor providers SHARE cap names
192
- * (`stream-params`/`ptz` from reolink AND hikvision). System caps are NOT
193
- * vendor-shared — a system cap name maps to one active provider (singleton)
194
- * or one declared collection — so consulting the cap's own provider(s)
195
- * directly is safe. Each provider gates internally by deviceId and returns
196
- * null for devices it does not own.
197
- *
198
- * `seenCaps` skips any system cap that already contributed via a binding so
199
- * its section is never duplicated.
200
- */
201
- private collectSystemDeviceContributions;
202
- /**
203
- * D14: framework-derived device-config contribution.
204
- *
205
- * `kind === 'live'` — device-config caps contribute nothing to the live
206
- * aggregate (they hold editable config, not live observables).
207
- *
208
- * `ui.kind === 'widget'` — emits a single structural `type:'widget'`
209
- * section; the widget self-persists via the cap's own mutations.
210
- *
211
- * `ui.kind === 'derived-form'` — calls `getOptions`/`getStatus` on the
212
- * bound provider, runs the registered pure builder, and returns the
213
- * derived form sections. Returns null when the camera exposes nothing
214
- * configurable or the provider is not yet registered.
215
- */
216
- private deriveDeviceConfigContribution;
217
- /**
218
- * Build the device-manager's own contribution to the aggregator — the
219
- * device identity (id, stableId, addonId, type, online) + the
220
- * driver-specific config exposed by the device class via
221
- * `zodEntriesToConfigUI`.
222
- *
223
- * Two paths, deliberately symmetric with `getSettingsSchema`:
224
- *
225
- * - Hub-local: device's IDevice instance lives in this process'
226
- * DeviceRegistry, we read config + schema directly by reference.
227
- * - Cross-process: device lives in a forked worker (RtspCamera on
228
- * provider-rtsp, ONVIF on provider-onvif, …). We ask the worker's
229
- * `device-ops.getSettingsSchema` native provider for a wire-
230
- * serializable ConfigUISchema and merge it in under the same
231
- * "Driver Config" section, so the UI sees the same shape regardless
232
- * of where the IDevice physically runs.
233
- *
234
- * Returns `null` only when the device genuinely doesn't exist anywhere
235
- * (no hub-local, no persisted ownership, no device-ops native). The
236
- * aggregator falls back to contributor sections only in that case.
237
- */
238
- private buildBaseDeviceSection;
239
- /**
240
- * Lookup the native owner for `device-ops` on `deviceId` — the native-cap
241
- * registry (hub-local and remote) is keyed by numeric id.
242
- */
243
- private resolveNativeDeviceOwner;
244
- /**
245
- * Aggregate `status` across every registered cap for a device.
246
- *
247
- * Walks the supplied cap list (or `CAP_NAMES_WITH_STATUS` when
248
- * omitted), looks up a native provider per cap via the capability
249
- * registry, calls `provider.getStatus({ deviceId })`, and validates
250
- * the return against the cap's own `status.schema`. Validation
251
- * failures log a warning and yield `null` for that cap so the
252
- * overall aggregate stays usable — a single misbehaving provider
253
- * must not blank out a device's entire status view.
254
- *
255
- * Returned shape is `Record<capName, unknown | null>`; the client-
256
- * side hook tightens this to `CapStatusTypeMap` via the generated
257
- * `cap-status-types.ts`.
258
- */
259
116
  private getDeviceStatusAggregate;
260
- /**
261
- * Return the driver-specific device-settings contribution. Hub-local
262
- * devices call `getSettingsUISchema()` directly; forked-worker devices
263
- * go through the `device-ops.getSettingsSchema` cap method on the
264
- * numeric-id-keyed native registry.
265
- *
266
- * Returns a discriminated result so callers can distinguish three states:
267
- * 'ok' – schema obtained successfully
268
- * 'none' – driver genuinely has no settings schema
269
- * 'unavailable' – worker was unreachable after retries (transient)
270
- */
271
- private resolveDriverConfigSchema;
272
117
  updateDeviceField(input: {
273
118
  deviceId: number;
274
119
  writerCapName: string;
@@ -278,45 +123,6 @@ export declare class DeviceManagerAddon extends BaseAddon {
278
123
  }): Promise<{
279
124
  success: true;
280
125
  }>;
281
- /**
282
- * Dispatch a device custom action. Hub-local devices run it directly;
283
- * forked/remote devices route through the `device-ops` native cap
284
- * (`runAction`) — the same hub-local-then-device-ops fan-out that
285
- * `updateDeviceField` uses for `applySettingsPatch`.
286
- */
287
- private dispatchDeviceAction;
288
- /**
289
- * Resolve the `DeviceSettingsContribution` provider that owns a tagged
290
- * field. The `writerAddonId` on a tagged field equals `entry.providerAddonId`
291
- * from the binding that produced it — for `kind:'wrapped'` entries, that is
292
- * the wrapper addon id (e.g. 'snapshot-addon'); for native-only entries,
293
- * the system addon id.
294
- *
295
- * Resolution order (Bug-3 fix):
296
- * 1. `getProviderByAddon(capName, writerAddonId)` — resolves the
297
- * system-registered provider by the addon id from the tagged field.
298
- * For wrapper bindings (snapshot, motion-detection, etc.) this directly
299
- * returns the wrapper provider, bypassing the native-first resolution
300
- * order of `getProviderForDevice` that caused "method not found".
301
- * 2. `getSingleton(capName)` — fallback for stale/mismatched writerAddonIds.
302
- * The active singleton handles the contribution even if the addonId
303
- * stored in the field is out of date (e.g. after an addon rename).
304
- *
305
- * `getProviderForDevice` is intentionally NOT used here: it returns the
306
- * per-device native first when present (e.g. Reolink/ONVIF), and the native
307
- * does NOT implement contribution methods — the root cause of Bug-3.
308
- */
309
- private resolveContributionProvider;
310
- /**
311
- * Batched counterpart of `updateDeviceField`. Groups changes by
312
- * `(writerCapName, writerAddonId)` so each contributor receives a
313
- * single `applyDeviceSettingsPatch` with all of its updates merged —
314
- * avoids N round-trips for simultaneous edits in the same save.
315
- *
316
- * Per-provider failures are captured in the `failures[]` output so the
317
- * admin UI can highlight which sections didn't persist; a failure on
318
- * one provider does NOT abort the others.
319
- */
320
126
  updateDeviceFieldsBatch(input: {
321
127
  deviceId: number;
322
128
  changes: ReadonlyArray<{
@@ -333,10 +139,7 @@ export declare class DeviceManagerAddon extends BaseAddon {
333
139
  error: string;
334
140
  }[];
335
141
  }>;
336
- /** Apply a single grouped patch to the appropriate provider. Mirrors
337
- * `updateDeviceField` routing (special-case device-manager, else
338
- * registry lookup). Used by `updateDeviceFieldsBatch`. */
339
- private applyGroupPatch;
142
+ private dispatchDeviceAction;
340
143
  listWrappersForCap(input: {
341
144
  capName: string;
342
145
  }): Promise<string[]>;
@@ -353,100 +156,6 @@ export declare class DeviceManagerAddon extends BaseAddon {
353
156
  active: boolean;
354
157
  }): Promise<void>;
355
158
  protected onInitialize(): Promise<ProviderRegistration[] | void>;
356
- /**
357
- * Single-cap mirror update — diff against the current mirror,
358
- * persist the new slice in-memory, emit `DeviceStateChanged` for
359
- * this cap. No-op on identical writes (both same shape and same
360
- * values). Called by `setCapSlice` provider.
361
- */
362
- private applySingleCapUpdate;
363
- /**
364
- * Debounced disk writer. Coalesces frequent writes (motion phase
365
- * transitions, battery pushes) into one `writeDeviceRuntimeState`
366
- * per `RUNTIME_STATE_DEBOUNCE_MS` window. Reads the per-device
367
- * blob from the live mirror at flush time so the disk picture is
368
- * always the latest state — no risk of writing a stale snapshot.
369
- */
370
- private scheduleRuntimeStateDiskWrite;
371
- /**
372
- * One-shot mirror seed used by `loadRuntimeState` at boot so the
373
- * hub knows about every persisted slice without waiting for the
374
- * first `setCapSlice` call. No events emitted — this is
375
- * initial-state population, not a transition.
376
- *
377
- * Callers that must not carry a stale per-session probe across a
378
- * restart pass the blob through `withResetSessionProbe` first (see
379
- * `loadRuntimeState`).
380
- */
381
- private seedMirror;
382
- /**
383
- * The hub mirror's `feature-probe.lastProbedAt` is a PER-SESSION liveness
384
- * signal — it means "this worker process completed a probe THIS session".
385
- * Persisted runtime state carries the PRE-RESTART timestamp, which is stale
386
- * after a hub or worker restart: the device has not re-probed yet. Seeding it
387
- * verbatim makes `resolveDeviceProbed` report `probed:true` during the
388
- * restart→reprobe window, which defeats the export carry-forward gate
389
- * (`resolveExportFingerprint`, gated on `device.probed`) and posts a spurious
390
- * partial `AddOrUpdateReport` to Alexa/HAP before the real probe lands.
391
- *
392
- * Reset `lastProbedAt` to 0 for the MIRROR seed only — `probed:false` carries
393
- * the last-advertised fingerprint forward until the worker republishes a
394
- * fresh probe (its post-probe `setCapSlice` raises `lastProbedAt` again, which
395
- * also fires `DeviceReady`). The worker's returned `initialRuntimeState` blob
396
- * is untouched, and every non-probe slice (e.g. `device-status`/online) is
397
- * preserved.
398
- */
399
- private withResetSessionProbe;
400
- /**
401
- * Resolve a device's REAL `online` flag for the persisted/forked-worker
402
- * list branch. Forked workers own the live `IDevice` in their own process,
403
- * so the hub registry can't read `device.online` directly. The owning
404
- * driver instead publishes its liveness through the auto-registered
405
- * `device-status` runtime-state slice (`markOnline` → `setCapState`), which
406
- * the canonical `deviceState.setCapSlice` write entrypoint mirrors into the
407
- * hub-side `stateMirror`. We read that mirrored slice here so the list
408
- * payload reflects the device's actual reachability instead of a constant.
409
- *
410
- * Fallback (`fallbackOnline`) preserves the legacy behaviour when no slice
411
- * has been published yet: a persisted device with a live registry is
412
- * assumed online (it was successfully registered by its owning process),
413
- * and the null-registry "offline view" keeps reporting offline. We never
414
- * regress a device to offline merely because its mirror is empty.
415
- */
416
- private resolveDeviceOnline;
417
- /**
418
- * Derive the `probed` flag for an offline-view (forked-worker, not
419
- * live in the hub registry) device projection. Reads the mirrored
420
- * `feature-probe` slice the owning worker publishes. Mirrors the
421
- * `toDeviceInfo` rule: no mirrored slice → ready (`true`, no probe seen);
422
- * slice present → ready iff `lastProbedAt` has advanced past 0. The
423
- * mirror is populated when the worker's first `setCapSlice` RPC arrives
424
- * (BaseDevice seeds `feature-probe` `lastProbedAt:0` at construction, but
425
- * cross-process delivery is async); until then the no-entry path returns
426
- * `true` — a brief transient window, same as `resolveDeviceOnline`.
427
- */
428
- private resolveDeviceProbed;
429
- private snapshotForDevice;
430
- /**
431
- * Read-time overlay of a cap slice with its cross-device linked values.
432
- * Returns a cloned raw mirror slice when the (device, cap) pair has no
433
- * links. Sources are read from the same in-hub stateMirror — sync, no
434
- * cross-process call. The disk writer must NOT use this method; it must
435
- * persist raw provider truth via snapshotForDevice.
436
- */
437
- private overlayedSlice;
438
- /**
439
- * Like snapshotForDevice but applies the device-link overlay per cap.
440
- * Used exclusively by the device-state READ methods (getSnapshot,
441
- * getAllSnapshots) so callers see overlayed values. The debounced disk
442
- * writer must continue to call snapshotForDevice (raw truth).
443
- */
444
- private snapshotForDeviceOverlayed;
445
- private emitStateChanged;
446
- /** Emit DeviceStateChanged for (deviceId, cap) using the OVERLAID slice,
447
- * skipping when the overlay is unchanged since the last emit (loop/churn
448
- * guard). Used for the written pair AND its dependent targets. */
449
- private emitOverlayed;
450
159
  protected onShutdown(): Promise<void>;
451
160
  }
452
161
  export default DeviceManagerAddon;