@camstack/server 0.1.7 → 0.1.8

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 (29) hide show
  1. package/package.json +3 -3
  2. package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
  3. package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
  4. package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
  5. package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
  6. package/src/__tests__/moleculer/uds-unowned-call.spec.ts +209 -3
  7. package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
  8. package/src/api/core/cap-providers.ts +152 -3
  9. package/src/api/core/logs.router.ts +4 -0
  10. package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
  11. package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
  12. package/src/api/trpc/cap-mount-helpers.ts +12 -1
  13. package/src/api/trpc/client-ip.ts +17 -0
  14. package/src/api/trpc/generated-cap-mounts.ts +281 -8
  15. package/src/api/trpc/generated-cap-routers.ts +2087 -184
  16. package/src/api/trpc/trpc.router.ts +43 -7
  17. package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
  18. package/src/boot/integration-id-backfill.ts +109 -0
  19. package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
  20. package/src/core/addon/addon-registry.service.ts +89 -2
  21. package/src/core/addon/addon-row-manifest.ts +29 -0
  22. package/src/core/logging/logging.service.ts +7 -2
  23. package/src/core/moleculer/moleculer.service.ts +28 -0
  24. package/src/core/network/network-quality.service.spec.ts +2 -1
  25. package/src/main.ts +92 -0
  26. package/src/core/storage/settings-store.spec.ts +0 -213
  27. package/src/core/storage/settings-store.ts +0 -2
  28. package/src/core/storage/sql-schema.spec.ts +0 -140
  29. package/src/core/storage/sql-schema.ts +0 -3
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Tests that `requireDeviceScoped` overlays `getStatus` results via
3
+ * `device-manager`'s `resolveLinkedStatus` when the device has linked
4
+ * properties for the requested cap.
5
+ *
6
+ * The overlay is transparent (null return = no-op) for the vast majority
7
+ * of reads; only devices with active links for the cap get the merged
8
+ * status back.
9
+ */
10
+ import { describe, it, expect, vi } from 'vitest'
11
+ import type { CapabilityRegistry } from '@camstack/kernel'
12
+ import { requireDeviceScoped } from '../../api/trpc/cap-mount-helpers.js'
13
+
14
+ // ── Fake registry ────────────────────────────────────────────────────────────
15
+
16
+ type ResolveLinkedStatus = (i: {
17
+ deviceId: number
18
+ cap: string
19
+ baseStatus: unknown
20
+ }) => Promise<Record<string, unknown> | null>
21
+
22
+ interface FakeDeviceManager {
23
+ resolveLinkedStatus: ResolveLinkedStatus
24
+ }
25
+
26
+ function makeRegistry(opts: {
27
+ nativeGetStatus: () => Promise<Record<string, unknown>>
28
+ nativeSetFanSpeed?: (i: unknown) => Promise<void>
29
+ resolveLinkedStatus: ResolveLinkedStatus
30
+ }): CapabilityRegistry {
31
+ const nativeProvider = {
32
+ getStatus: opts.nativeGetStatus,
33
+ setFanSpeed: opts.nativeSetFanSpeed ?? vi.fn(async () => undefined),
34
+ }
35
+ const deviceManager: FakeDeviceManager = {
36
+ resolveLinkedStatus: opts.resolveLinkedStatus,
37
+ }
38
+ return {
39
+ getNativeProvider<T>(_capName: string, _deviceId: number): T | null {
40
+ return nativeProvider as unknown as T
41
+ },
42
+ getSingleton<T>(capability: string): T | null {
43
+ if (capability === 'device-manager') {
44
+ return deviceManager as unknown as T
45
+ }
46
+ return null
47
+ },
48
+ // Minimal no-op stubs for the rest of the CapabilityRegistry surface
49
+ // so TypeScript is satisfied without pulling in the real kernel class.
50
+ listCapabilities: vi.fn(() => []),
51
+ registerProvider: vi.fn(),
52
+ unregisterProvider: vi.fn(),
53
+ getCollection: vi.fn(() => []),
54
+ getCollectionEntries: vi.fn(() => []),
55
+ registerNativeProvider: vi.fn(),
56
+ unregisterNativeProvider: vi.fn(),
57
+ getProviderForDevice: vi.fn(() => null),
58
+ getBindings: vi.fn(() => ({ entries: [] })),
59
+ setActiveSingleton: vi.fn(),
60
+ getSingletonAddonId: vi.fn(() => null),
61
+ getAddonIdForProvider: vi.fn(() => null),
62
+ on: vi.fn(),
63
+ off: vi.fn(),
64
+ dispose: vi.fn(),
65
+ } as unknown as CapabilityRegistry
66
+ }
67
+
68
+ // ── Tests ────────────────────────────────────────────────────────────────────
69
+
70
+ const DEVICE_ID = 668
71
+ const CAP_NAME = 'vacuum-control' as Parameters<typeof requireDeviceScoped>[1]
72
+
73
+ describe('requireDeviceScoped — getStatus overlay via resolveLinkedStatus', () => {
74
+ it('returns the overlaid status when resolveLinkedStatus returns a non-null object', async () => {
75
+ const base = { state: 'idle', battery: 80 }
76
+ const overlaid = { state: 'paused', cleanWater: { status: 'low', level: null } }
77
+
78
+ const registry = makeRegistry({
79
+ nativeGetStatus: vi.fn(async () => base),
80
+ resolveLinkedStatus: vi.fn(async () => overlaid),
81
+ })
82
+
83
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
84
+ expect(dispatcher).not.toBeNull()
85
+
86
+ // Call via the Proxy — method is resolved lazily
87
+ const result = await (dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> })
88
+ .getStatus({ deviceId: DEVICE_ID })
89
+
90
+ expect(result).toEqual(overlaid)
91
+ expect(result).not.toEqual(base)
92
+ })
93
+
94
+ it('returns the base provider result unchanged when resolveLinkedStatus returns null (no links)', async () => {
95
+ const base = { state: 'cleaning', battery: 60 }
96
+
97
+ const registry = makeRegistry({
98
+ nativeGetStatus: vi.fn(async () => base),
99
+ resolveLinkedStatus: vi.fn(async () => null),
100
+ })
101
+
102
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
103
+ expect(dispatcher).not.toBeNull()
104
+
105
+ const result = await (dispatcher as unknown as { getStatus: (i: { deviceId: number }) => Promise<unknown> })
106
+ .getStatus({ deviceId: DEVICE_ID })
107
+
108
+ expect(result).toEqual(base)
109
+ })
110
+
111
+ it('does NOT call resolveLinkedStatus for non-getStatus methods', async () => {
112
+ const base = { state: 'idle', battery: 90 }
113
+ const resolveLinkedStatus = vi.fn(async () => null)
114
+ const nativeSetFanSpeed = vi.fn(async () => undefined)
115
+
116
+ const registry = makeRegistry({
117
+ nativeGetStatus: vi.fn(async () => base),
118
+ nativeSetFanSpeed,
119
+ resolveLinkedStatus,
120
+ })
121
+
122
+ const dispatcher = requireDeviceScoped(registry, CAP_NAME)
123
+ expect(dispatcher).not.toBeNull()
124
+
125
+ await (dispatcher as unknown as { setFanSpeed: (i: { deviceId: number; speed: string }) => Promise<void> })
126
+ .setFanSpeed({ deviceId: DEVICE_ID, speed: 'high' })
127
+
128
+ expect(nativeSetFanSpeed).toHaveBeenCalledOnce()
129
+ // The overlay path must NOT be consulted for mutations
130
+ expect(resolveLinkedStatus).not.toHaveBeenCalled()
131
+ })
132
+ })
@@ -23,11 +23,16 @@ import { describe, it, expect, vi } from 'vitest'
23
23
  import {
24
24
  createParentUnownedCallHandler,
25
25
  CapRouteResolver,
26
+ CapRouteError,
27
+ HubNodeRegistry,
26
28
  } from '@camstack/kernel'
27
29
  import type {
28
30
  NodeCapAuthority,
29
31
  InProcessProviderLookup,
30
32
  CapRouteResolverDeps,
33
+ NodeNativeCapEntry,
34
+ HubLocalChildDispatcher,
35
+ CapCallInput,
31
36
  } from '@camstack/kernel'
32
37
  import type { ServiceBroker } from 'moleculer'
33
38
 
@@ -63,6 +68,42 @@ function hubBrokerFake(): ServiceBroker {
63
68
  return broker as unknown as ServiceBroker
64
69
  }
65
70
 
71
+ /**
72
+ * Minimal `HubNodeRegistry`-shaped fake exposing only the lookup the unowned
73
+ * handler uses: `listNativeCapEntriesForDevice`. The handler depends solely on
74
+ * this method, so we don't need the full registry to exercise its routing.
75
+ */
76
+ function nodeRegistryFake(
77
+ byDevice: Record<number, readonly NodeNativeCapEntry[]> = {},
78
+ ): HubNodeRegistry {
79
+ const fake = {
80
+ listNativeCapEntriesForDevice: (deviceId: number): readonly NodeNativeCapEntry[] =>
81
+ byDevice[deviceId] ?? [],
82
+ }
83
+ return fake as unknown as HubNodeRegistry
84
+ }
85
+
86
+ /**
87
+ * Hub-local UDS dispatcher fake (LocalChildRegistry surface). `resolveChildId`
88
+ * returns the configured childId on a (capName, deviceId) match else null;
89
+ * `callCapOnChild` records the call and echoes a sentinel result.
90
+ */
91
+ function localDispatcherFake(owner?: { capName: string; deviceId: number; childId: string }): {
92
+ dispatcher: HubLocalChildDispatcher
93
+ resolveChildId: ReturnType<typeof vi.fn>
94
+ callCapOnChild: ReturnType<typeof vi.fn>
95
+ } {
96
+ const resolveChildId = vi.fn((capName: string, deviceId?: number): string | null =>
97
+ owner !== undefined && capName === owner.capName && deviceId === owner.deviceId ? owner.childId : null,
98
+ )
99
+ const callCapOnChild = vi.fn(async (childId: string, input: CapCallInput) => ({
100
+ routedOverUds: childId,
101
+ capName: input.capName,
102
+ }))
103
+ const dispatcher: HubLocalChildDispatcher = { resolveChildId, callCapOnChild }
104
+ return { dispatcher, resolveChildId, callCapOnChild }
105
+ }
106
+
66
107
  describe('hub onUnownedCall wiring (F0)', () => {
67
108
  it('resolver-first: resolves a hub-in-process cap through the real CapRouteResolver', async () => {
68
109
  const broker = hubBrokerFake()
@@ -83,7 +124,7 @@ describe('hub onUnownedCall wiring (F0)', () => {
83
124
  }
84
125
  const resolver = new CapRouteResolver(resolverDeps)
85
126
 
86
- const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker })
127
+ const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker, nodeRegistry: nodeRegistryFake() })
87
128
 
88
129
  const result = await handler({ capName: 'settings-store', method: 'get', args: { key: 'k' } })
89
130
  expect(result).toEqual({ value: 'hub-resolved', params: { key: 'k' } })
@@ -104,7 +145,7 @@ describe('hub onUnownedCall wiring (F0)', () => {
104
145
  inProcessProviders: () => null,
105
146
  })
106
147
 
107
- const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker })
148
+ const handler = createParentUnownedCallHandler({ getResolver: () => resolver, broker, nodeRegistry: nodeRegistryFake() })
108
149
 
109
150
  const result = await handler({ capName: 'system', method: 'info', args: undefined })
110
151
  expect(result).toEqual({ uptimeSec: 99, params: undefined })
@@ -115,9 +156,174 @@ describe('hub onUnownedCall wiring (F0)', () => {
115
156
  const broker = hubBrokerFake()
116
157
  // Mirrors the window before `this.resolver` is constructed: getResolver
117
158
  // returns null, so the handler goes straight to the broker fallback.
118
- const handler = createParentUnownedCallHandler({ getResolver: () => null, broker })
159
+ const handler = createParentUnownedCallHandler({ getResolver: () => null, broker, nodeRegistry: nodeRegistryFake() })
160
+
161
+ const result = await handler({ capName: 'system', method: 'info', args: undefined })
162
+ expect(result).toEqual({ uptimeSec: 99, params: undefined })
163
+ })
164
+
165
+ it('deviceId-aware resolver: derives deviceId from args and routes via the resolver (no broker fallback)', async () => {
166
+ const broker = hubBrokerFake()
167
+
168
+ // A resolver-shaped fake that resolves a route ONLY when invoked with a
169
+ // deviceId — mirroring the real resolver routing a device-scoped native cap
170
+ // to its owning provider. Without a deviceId it returns no-provider.
171
+ let resolvedWithDeviceId: number | undefined
172
+ const resolver = {
173
+ resolveCapRoute: (capName: string, opts: { deviceId?: number }) => {
174
+ if (opts.deviceId === undefined) {
175
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
176
+ }
177
+ resolvedWithDeviceId = opts.deviceId
178
+ return { kind: 'hub-in-process', capName, deviceId: opts.deviceId }
179
+ },
180
+ dispatch: async () => ({ catalog: ['stream-a'] }),
181
+ }
182
+
183
+ const handler = createParentUnownedCallHandler({
184
+ getResolver: () => resolver as unknown as CapRouteResolver,
185
+ broker,
186
+ nodeRegistry: nodeRegistryFake(),
187
+ })
188
+
189
+ // deviceId lives ONLY in args, NOT top-level — the handler must derive it.
190
+ const result = await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } })
191
+ expect(result).toEqual({ catalog: ['stream-a'] })
192
+ expect(resolvedWithDeviceId).toBe(7)
193
+ // Resolver served it via the derived deviceId — broker untouched.
194
+ expect(broker.call).not.toHaveBeenCalled()
195
+ })
196
+
197
+ it('pinned broker fallback: resolver finds nothing → call is pinned to the owning node', async () => {
198
+ const broker = hubBrokerFake()
199
+
200
+ // Resolver always returns no-provider for this device-scoped cap.
201
+ const resolver = {
202
+ resolveCapRoute: (capName: string) => {
203
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
204
+ },
205
+ dispatch: async () => {
206
+ throw new Error('dispatch should not be reached')
207
+ },
208
+ }
209
+
210
+ const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
211
+ const handler = createParentUnownedCallHandler({
212
+ getResolver: () => resolver as unknown as CapRouteResolver,
213
+ broker,
214
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
215
+ })
216
+
217
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
218
+
219
+ // The broker call is pinned to the owning node via call-opts `{ nodeID }`.
220
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
221
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
222
+ })
223
+
224
+ it('back-compat: no resolvable device-owner → broker call stays UNPINNED (load-balanced)', async () => {
225
+ const broker = hubBrokerFake()
226
+
227
+ const resolver = {
228
+ resolveCapRoute: (capName: string) => {
229
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
230
+ },
231
+ dispatch: async () => {
232
+ throw new Error('dispatch should not be reached')
233
+ },
234
+ }
235
+
236
+ const handler = createParentUnownedCallHandler({
237
+ getResolver: () => resolver as unknown as CapRouteResolver,
238
+ broker,
239
+ nodeRegistry: nodeRegistryFake(), // no owners for any device
240
+ })
119
241
 
120
242
  const result = await handler({ capName: 'system', method: 'info', args: undefined })
121
243
  expect(result).toEqual({ uptimeSec: 99, params: undefined })
244
+ // Unpinned: third arg (call-opts) is undefined — today's behavior preserved.
245
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
246
+ expect(callArgs[2]).toBeUndefined()
247
+ })
248
+
249
+ it('hub-local owner: device-native cap owned by a hub-local UDS child routes over UDS (broker untouched)', async () => {
250
+ const broker = hubBrokerFake()
251
+ // Resolver misses the device-scoped native cap (mirrors live behavior).
252
+ const resolver = {
253
+ resolveCapRoute: (capName: string) => {
254
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
255
+ },
256
+ dispatch: async () => { throw new Error('dispatch should not be reached') },
257
+ }
258
+ const { dispatcher, resolveChildId, callCapOnChild } = localDispatcherFake({
259
+ capName: 'stream-catalog',
260
+ deviceId: 7,
261
+ childId: 'child-reolink',
262
+ })
263
+
264
+ const handler = createParentUnownedCallHandler({
265
+ getResolver: () => resolver as unknown as CapRouteResolver,
266
+ broker,
267
+ nodeRegistry: nodeRegistryFake(), // empty — hub-local child is NOT in HubNodeRegistry
268
+ getLocalDispatcher: () => dispatcher,
269
+ })
270
+
271
+ const result = await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } })
272
+ expect(result).toEqual({ routedOverUds: 'child-reolink', capName: 'stream-catalog' })
273
+ expect(resolveChildId).toHaveBeenCalledWith('stream-catalog', 7)
274
+ expect(callCapOnChild).toHaveBeenCalledWith('child-reolink', {
275
+ capName: 'stream-catalog',
276
+ method: 'getCatalog',
277
+ args: { deviceId: 7 },
278
+ deviceId: 7,
279
+ })
280
+ expect(broker.call).not.toHaveBeenCalled()
281
+ })
282
+
283
+ it('remote owner: hub-local dispatcher misses → pinned broker call', async () => {
284
+ const broker = hubBrokerFake()
285
+ const resolver = {
286
+ resolveCapRoute: (capName: string) => {
287
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
288
+ },
289
+ dispatch: async () => { throw new Error('dispatch should not be reached') },
290
+ }
291
+ const { dispatcher, callCapOnChild } = localDispatcherFake() // no local owner
292
+ const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
293
+
294
+ const handler = createParentUnownedCallHandler({
295
+ getResolver: () => resolver as unknown as CapRouteResolver,
296
+ broker,
297
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
298
+ getLocalDispatcher: () => dispatcher,
299
+ })
300
+
301
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
302
+
303
+ expect(callCapOnChild).not.toHaveBeenCalled()
304
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
305
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
306
+ })
307
+
308
+ it('no local dispatcher (getter returns null): behaves as before — HubNodeRegistry → broker', async () => {
309
+ const broker = hubBrokerFake()
310
+ const resolver = {
311
+ resolveCapRoute: (capName: string) => {
312
+ throw new CapRouteError(capName, undefined, { reason: 'no-provider', rejected: [] })
313
+ },
314
+ dispatch: async () => { throw new Error('dispatch should not be reached') },
315
+ }
316
+ const owner: NodeNativeCapEntry = { nodeId: 'agent', addonId: 'reolink', capName: 'stream-catalog', deviceId: 7 }
317
+
318
+ const handler = createParentUnownedCallHandler({
319
+ getResolver: () => resolver as unknown as CapRouteResolver,
320
+ broker,
321
+ nodeRegistry: nodeRegistryFake({ 7: [owner] }),
322
+ getLocalDispatcher: () => null,
323
+ })
324
+
325
+ await handler({ capName: 'stream-catalog', method: 'getCatalog', args: { deviceId: 7 } }).catch(() => undefined)
326
+ const callArgs = (broker.call as ReturnType<typeof vi.fn>).mock.calls[0]
327
+ expect(callArgs[2]).toEqual({ nodeID: 'agent' })
122
328
  })
123
329
  })
@@ -0,0 +1,10 @@
1
+ import { describe, it, expect } from 'vitest'
2
+ import { INTEGRATION_CAP_MARKERS } from '../cap-providers.js'
3
+
4
+ describe('integration cap markers', () => {
5
+ it('recognises device-adoption (not the old ha-discovery)', () => {
6
+ expect(INTEGRATION_CAP_MARKERS.has('device-adoption')).toBe(true)
7
+ expect(INTEGRATION_CAP_MARKERS.has('ha-discovery')).toBe(false)
8
+ expect(INTEGRATION_CAP_MARKERS.has('device-provider')).toBe(true)
9
+ })
10
+ })
@@ -41,6 +41,7 @@ import type {
41
41
  Integration,
42
42
  IIntegrationRegistry,
43
43
  IDeviceProvider,
44
+ IBrokerProvider,
44
45
  CapabilityMethodAuth,
45
46
  } from '@camstack/types'
46
47
  import { asJsonObject, asJsonArray, errMsg } from '@camstack/types'
@@ -57,6 +58,7 @@ import type { AddonRegistryService } from '../../core/addon/addon-registry.servi
57
58
  import type { AddonPackageService } from '../../core/addon/addon-package.service'
58
59
  import type { NetworkQualityService } from '../../core/network/network-quality.service'
59
60
  import type { ConfigService } from '../../core/config/config.service'
61
+ import { planDeleteTimeStamps } from '../../boot/integration-id-backfill'
60
62
  import { persistCollectionDisabled } from './collection-preference.js'
61
63
  import { BulkUpdateCoordinator } from './bulk-update-coordinator.js'
62
64
  import { FRAMEWORK_PACKAGE_ALLOWLIST } from '../../core/addon/addon-package.service.js'
@@ -116,6 +118,7 @@ export function buildNetworkQualityProvider(
116
118
  rttMs: input.rttMs,
117
119
  jitterMs: input.jitterMs,
118
120
  estimatedBandwidthKbps: input.estimatedBandwidthKbps,
121
+ packetLossPercent: input.packetLossPercent,
119
122
  })
120
123
  },
121
124
  }
@@ -525,10 +528,27 @@ function getDeviceProvider(ar: AddonRegistryService, addonId: string): IDevicePr
525
528
  return isDeviceProvider(provider) ? provider : null
526
529
  }
527
530
 
531
+ /**
532
+ * Marker caps that flag an addon as a creatable integration type:
533
+ * - `device-provider` — classic providers (Reolink/ONVIF/Frigate)
534
+ * that expose `createDevice` + `discoverDevices` via their
535
+ * device-provider cap.
536
+ * - `device-adoption` — integration-style providers (Home Assistant
537
+ * and future siblings) that materialise devices via a generic
538
+ * adoption cap instead of a manual create-form. The picker treats
539
+ * them the same way; the wizard's discovery step routes through the
540
+ * specific cap based on the addon's declared surface.
541
+ *
542
+ * Exported so the integration-markers spec can assert the recognised set
543
+ * without booting the whole provider factory.
544
+ */
545
+ export const INTEGRATION_CAP_MARKERS = new Set(['device-provider', 'device-adoption'])
546
+
528
547
  export function buildIntegrationsProvider(
529
548
  ar: AddonRegistryService,
530
549
  eb: EventBusService,
531
550
  loggingService: LoggingService,
551
+ capabilityRegistry: CapabilityRegistry | null,
532
552
  ): IIntegrationsProvider {
533
553
  const logger = loggingService.createLogger('integrations')
534
554
  const withProcessState = (i: Integration): IntegrationWithProcessState => ({
@@ -655,6 +675,77 @@ export function buildIntegrationsProvider(
655
675
  'removing',
656
676
  { tags: { integrationId: input.id, addonId: integration.addonId }, meta: { phase: 'delete', name: integration.name } },
657
677
  )
678
+
679
+ // Cascade-delete every live device whose integrationId matches.
680
+ // Best-effort: a device-removal hiccup must not abort the integration
681
+ // delete — log a warning and continue so the record + event always fire.
682
+ const dm = capabilityRegistry?.getSingleton<{
683
+ removeByIntegration?: (input: { integrationId: string }) => Promise<{ removed: number }>
684
+ listAll?: (input: Record<string, never>) => Promise<readonly {
685
+ id: number; addonId: string; parentDeviceId: number | null; integrationId?: string
686
+ }[]>
687
+ setIntegrationId?: (input: { deviceId: number; integrationId: string }) => Promise<void>
688
+ }>('device-manager') ?? null
689
+
690
+ // Claim legacy un-tagged devices BEFORE the cascade. Devices created
691
+ // before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
692
+ // carry no integrationId, so `removeByIntegration` (which matches on
693
+ // integrationId) would leave them orphaned forever once their integration
694
+ // is gone. While the integration record still exists, stamp the
695
+ // unambiguous ones (addons hosting exactly one integration) so the cascade
696
+ // below removes them too. Best-effort: never abort the delete.
697
+ if (dm?.listAll && dm?.setIntegrationId) {
698
+ try {
699
+ const [integrations, devices] = await Promise.all([
700
+ reg.listIntegrations(),
701
+ dm.listAll({}),
702
+ ])
703
+ const stamps = planDeleteTimeStamps(
704
+ input.id,
705
+ integrations.map((i) => ({ id: i.id, addonId: i.addonId })),
706
+ devices.map((d) => ({
707
+ id: d.id,
708
+ addonId: d.addonId,
709
+ parentDeviceId: d.parentDeviceId,
710
+ ...(d.integrationId !== undefined ? { integrationId: d.integrationId } : {}),
711
+ })),
712
+ )
713
+ for (const stamp of stamps) {
714
+ await dm.setIntegrationId({ deviceId: stamp.deviceId, integrationId: stamp.integrationId })
715
+ }
716
+ if (stamps.length > 0) {
717
+ logger.info('claimed legacy un-tagged devices for cascade', {
718
+ tags: { integrationId: input.id, addonId: integration.addonId },
719
+ meta: { phase: 'delete', claimed: stamps.length },
720
+ })
721
+ }
722
+ } catch (err) {
723
+ logger.warn('legacy device claim failed (best-effort — continuing)', {
724
+ tags: { integrationId: input.id }, meta: { phase: 'delete', error: errMsg(err) },
725
+ })
726
+ }
727
+ }
728
+
729
+ if (dm?.removeByIntegration) {
730
+ try {
731
+ const result = await dm.removeByIntegration({ integrationId: input.id })
732
+ logger.info(
733
+ 'cascade-removed devices',
734
+ { tags: { integrationId: input.id }, meta: { phase: 'delete', removed: result.removed } },
735
+ )
736
+ } catch (err) {
737
+ logger.warn(
738
+ 'device cascade-remove failed (best-effort — continuing)',
739
+ { tags: { integrationId: input.id }, meta: { phase: 'delete', error: errMsg(err) } },
740
+ )
741
+ }
742
+ } else {
743
+ logger.warn(
744
+ 'device-manager not available — skipping cascade device removal',
745
+ { tags: { integrationId: input.id }, meta: { phase: 'delete' } },
746
+ )
747
+ }
748
+
658
749
  await reg.deleteIntegration(input.id)
659
750
 
660
751
  eb.emit({
@@ -704,11 +795,16 @@ export function buildIntegrationsProvider(
704
795
  // integration against an addon that didn't load produces an orphaned
705
796
  // row that `createFilteredRegistry` then filters out — silent data
706
797
  // loss from the operator's POV. Filter at the source instead.
798
+ //
799
+ // Markers that flag an addon as a creatable integration type live
800
+ // in the module-level `INTEGRATION_CAP_MARKERS` set (exported so the
801
+ // integration-markers spec can assert the recognised caps).
707
802
  const providerAddons = addons.filter(a =>
708
803
  a.process?.state !== 'failed' &&
709
- a.manifest.capabilities?.some(c =>
710
- typeof c === 'string' ? c === 'device-provider' : c.name === 'device-provider',
711
- ),
804
+ a.manifest.capabilities?.some(c => {
805
+ const name = typeof c === 'string' ? c : c.name
806
+ return typeof name === 'string' && INTEGRATION_CAP_MARKERS.has(name)
807
+ }),
712
808
  )
713
809
  const integrations = await reg.listIntegrations()
714
810
  return providerAddons.map(addon => {
@@ -720,6 +816,29 @@ export function buildIntegrationsProvider(
720
816
  const existing = integrations.filter(i => i.addonId === m.id)
721
817
  const provider = getDeviceProvider(ar, m.id)
722
818
  const discoveryMode = provider?.discoveryMode ?? 'manual'
819
+
820
+ // Branch by CAP, not by addon name. Surface which integration-marker
821
+ // cap the addon declared so the wizard routes `device-adoption`
822
+ // (Approach A: pick/create a broker, store `{ brokerId }`) vs the
823
+ // legacy `device-provider` config → discovery flow. A `device-adoption`
824
+ // marker wins when both are present (an integration-style addon may
825
+ // also expose a `device-provider` shim); the broker step is the
826
+ // intended entry point for it.
827
+ const capNames = (m.capabilities ?? []).map(c => (typeof c === 'string' ? c : c.name))
828
+ const kind: 'device-adoption' | 'device-provider' =
829
+ capNames.includes('device-adoption') ? 'device-adoption' : 'device-provider'
830
+
831
+ // For device-adoption addons, the broker kind to create/link comes
832
+ // from the addon manifest (`brokerKind`). Null for device-provider
833
+ // addons, which carry no broker.
834
+ const brokerKind = kind === 'device-adoption'
835
+ ? (d?.brokerKind ?? m.brokerKind ?? null)
836
+ : null
837
+
838
+ const supportsLocationImport = kind === 'device-adoption'
839
+ ? (d?.supportsLocationImport ?? m.supportsLocationImport ?? false)
840
+ : false
841
+
723
842
  return {
724
843
  addonId: m.id,
725
844
  name: m.name ?? m.id,
@@ -728,6 +847,9 @@ export function buildIntegrationsProvider(
728
847
  color,
729
848
  instanceMode,
730
849
  discoveryMode,
850
+ kind,
851
+ brokerKind,
852
+ supportsLocationImport,
731
853
  existingInstances: existing.map(i => ({
732
854
  id: i.id,
733
855
  name: i.name,
@@ -737,6 +859,33 @@ export function buildIntegrationsProvider(
737
859
  })
738
860
  },
739
861
  testConnection: async (input) => {
862
+ // Broker-backed integrations (Approach A) carry their connection
863
+ // identity as a `brokerId` in settings — testing is a broker
864
+ // concern now, so delegate to the addon's `broker` cap. The broker
865
+ // already owns the real semantic check (HA opens a temporary WS
866
+ // handshake; MQTT pings the bridge). We translate the broker's
867
+ // discriminated result (`{ok:true,latencyMs}|{ok:false,error}`) into
868
+ // the integrations `{success, error?}` output shape. Falls back to
869
+ // the default RTSP/ffprobe path below for legacy device-provider
870
+ // addons (Reolink/Frigate/ONVIF) that probe a stream URL.
871
+ const registry = ar.getCapabilityRegistry()
872
+ const brokerId = input.settings['brokerId']
873
+ if (typeof brokerId === 'string' && brokerId.length > 0) {
874
+ const brokerProvider = registry.getProviderByAddonId<IBrokerProvider>('broker', input.addonId)
875
+ if (!brokerProvider) {
876
+ return { success: false, error: `Broker provider for addon '${input.addonId}' is not available` }
877
+ }
878
+ try {
879
+ const result = await brokerProvider.testConnection({ id: brokerId })
880
+ return result.ok
881
+ ? { success: true }
882
+ : { success: false, error: result.error }
883
+ } catch (err) {
884
+ return { success: false, error: errMsg(err) }
885
+ }
886
+ }
887
+
888
+ // Default — RTSP/Frigate/ONVIF legacy path: probe the stream URL.
740
889
  const url = String(
741
890
  input.settings['main_stream_url'] ?? input.settings['url'] ?? '',
742
891
  ).trim()
@@ -18,6 +18,10 @@ const LogTagsSchema = z.object({
18
18
  nodeId: z.string().optional(),
19
19
  /** Numeric progressive id (or its string form for legacy callers). */
20
20
  deviceId: z.union([z.string(), z.number()]).optional(),
21
+ /** Parent container id — set on every accessory child's logs (and on the
22
+ * container's own logs). Filtering by it returns the whole container subtree
23
+ * (container + all children) in one query. */
24
+ containerDeviceId: z.union([z.string(), z.number()]).optional(),
21
25
  deviceName: z.string().optional(),
22
26
  integrationId: z.string().optional(),
23
27
  addonId: z.string().optional(),
@@ -11,15 +11,17 @@
11
11
  */
12
12
  import { describe, it, expect } from 'vitest'
13
13
  import type { IncomingMessage } from 'node:http'
14
- import { extractClientIp, isRemoteClientIp } from '../client-ip.js'
14
+ import { extractClientIp, extractUserAgent, isRemoteClientIp } from '../client-ip.js'
15
15
 
16
16
  function reqWith(opts: {
17
17
  xff?: string | string[]
18
18
  ip?: string
19
19
  remoteAddress?: string
20
+ userAgent?: string | string[]
20
21
  }): IncomingMessage {
21
22
  const headers: Record<string, string | string[]> = {}
22
23
  if (opts.xff !== undefined) headers['x-forwarded-for'] = opts.xff
24
+ if (opts.userAgent !== undefined) headers['user-agent'] = opts.userAgent
23
25
  const req = {
24
26
  headers,
25
27
  socket: { remoteAddress: opts.remoteAddress },
@@ -66,6 +68,30 @@ describe('extractClientIp', () => {
66
68
  })
67
69
  })
68
70
 
71
+ describe('extractUserAgent', () => {
72
+ it('returns null for an absent request (mesh-originated call)', () => {
73
+ expect(extractUserAgent(undefined)).toBeNull()
74
+ })
75
+
76
+ it('returns null when the header is missing', () => {
77
+ expect(extractUserAgent(reqWith({}))).toBeNull()
78
+ })
79
+
80
+ it('reads the user-agent header', () => {
81
+ const req = reqWith({ userAgent: 'Mozilla/5.0 (Macintosh) Chrome/120' })
82
+ expect(extractUserAgent(req)).toBe('Mozilla/5.0 (Macintosh) Chrome/120')
83
+ })
84
+
85
+ it('reads the first entry when the header is an array', () => {
86
+ const req = reqWith({ userAgent: ['Mozilla/5.0 (X11) Firefox/121', 'ignored'] })
87
+ expect(extractUserAgent(req)).toBe('Mozilla/5.0 (X11) Firefox/121')
88
+ })
89
+
90
+ it('returns null for an empty header value', () => {
91
+ expect(extractUserAgent(reqWith({ userAgent: '' }))).toBeNull()
92
+ })
93
+ })
94
+
69
95
  describe('isRemoteClientIp', () => {
70
96
  it('null → false (treated as LAN — safe default)', () => {
71
97
  expect(isRemoteClientIp(null)).toBe(false)