@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.
- package/package.json +3 -3
- package/src/__tests__/cap-providers/cap-providers-location-import.spec.ts +186 -0
- package/src/__tests__/cap-providers/integrations-delete-cascade.spec.ts +243 -0
- package/src/__tests__/cap-routers/broker-routing.router.spec.ts +169 -0
- package/src/__tests__/cap-routers/device-link-overlay.spec.ts +132 -0
- package/src/__tests__/moleculer/uds-unowned-call.spec.ts +209 -3
- package/src/api/core/__tests__/integration-markers.spec.ts +10 -0
- package/src/api/core/cap-providers.ts +152 -3
- package/src/api/core/logs.router.ts +4 -0
- package/src/api/trpc/__tests__/client-ip.spec.ts +27 -1
- package/src/api/trpc/__tests__/webrtc-session-ua-enrich.spec.ts +128 -0
- package/src/api/trpc/cap-mount-helpers.ts +12 -1
- package/src/api/trpc/client-ip.ts +17 -0
- package/src/api/trpc/generated-cap-mounts.ts +281 -8
- package/src/api/trpc/generated-cap-routers.ts +2087 -184
- package/src/api/trpc/trpc.router.ts +43 -7
- package/src/boot/__tests__/integration-id-backfill.spec.ts +116 -0
- package/src/boot/integration-id-backfill.ts +109 -0
- package/src/core/addon/__tests__/addon-row-manifest.spec.ts +62 -0
- package/src/core/addon/addon-registry.service.ts +89 -2
- package/src/core/addon/addon-row-manifest.ts +29 -0
- package/src/core/logging/logging.service.ts +7 -2
- package/src/core/moleculer/moleculer.service.ts +28 -0
- package/src/core/network/network-quality.service.spec.ts +2 -1
- package/src/main.ts +92 -0
- package/src/core/storage/settings-store.spec.ts +0 -213
- package/src/core/storage/settings-store.ts +0 -2
- package/src/core/storage/sql-schema.spec.ts +0 -140
- 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
|
|
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)
|