@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
|
@@ -11,7 +11,7 @@ import { trpcRouter } from './trpc.middleware'
|
|
|
11
11
|
// streaming → `streamingManagement` cap, events → `eventQuery` cap,
|
|
12
12
|
// logs → kept manual, live → kept manual, processes → `processMgmt`
|
|
13
13
|
// cap, agents → `nodes` cap, sessions → `session` cap, trackMedia /
|
|
14
|
-
// trackTrail → caps,
|
|
14
|
+
// trackTrail → caps, network →
|
|
15
15
|
// `networkQuality` cap, addons → `addons` cap, bridgePipeline removed
|
|
16
16
|
// (legacy), detection → `detectionConfig` cap, capabilities → kept
|
|
17
17
|
// manual, update → addons cap, addonPages → cap, notification →
|
|
@@ -20,7 +20,7 @@ import { trpcRouter } from './trpc.middleware'
|
|
|
20
20
|
// `pipelineExecutor` cap, pipeline → `pipelineConfig` cap,
|
|
21
21
|
// systemEvents → kept manual.
|
|
22
22
|
import type { CapabilityRegistry } from '@camstack/kernel'
|
|
23
|
-
import type { InferProvider } from '@camstack/types'
|
|
23
|
+
import type { InferProvider, BrokerConsumerAttribution } from '@camstack/types'
|
|
24
24
|
import {
|
|
25
25
|
pipelineExecutorCapability,
|
|
26
26
|
pipelineRunnerCapability,
|
|
@@ -76,6 +76,7 @@ import { createCapabilitiesRouter } from '../core/capabilities.router.js'
|
|
|
76
76
|
import { createStreamProbeRouter } from '../core/stream-probe.router.js'
|
|
77
77
|
import { createHwAccelRouter } from '../core/hwaccel.router.js'
|
|
78
78
|
import { requireSingleton, firstSupported, anySupports } from './cap-mount-helpers.js'
|
|
79
|
+
import { extractUserAgent } from './client-ip.js'
|
|
79
80
|
import type { TrpcContext } from './trpc.context.js'
|
|
80
81
|
import type { AuthService } from '../../core/auth/auth.service'
|
|
81
82
|
import type { ConfigService } from '../../core/config/config.service'
|
|
@@ -112,6 +113,28 @@ export interface RouterServices {
|
|
|
112
113
|
}
|
|
113
114
|
|
|
114
115
|
type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
|
|
116
|
+
type CreateSessionInput = Parameters<WebrtcSessionProvider['createSession']>[0]
|
|
117
|
+
type HandleOfferInput = Parameters<WebrtcSessionProvider['handleOffer']>[0]
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Merge the server-read User-Agent into a signaling call's
|
|
121
|
+
* `consumerAttribution`, building a NEW input object (immutable — never
|
|
122
|
+
* mutates the caller's input). When `userAgent` is null (mesh-originated
|
|
123
|
+
* call, or a client that omits the header) the input passes through
|
|
124
|
+
* unchanged. Any client-supplied `userAgent` is OVERWRITTEN — the hub
|
|
125
|
+
* trusts only the request context, never the client.
|
|
126
|
+
*/
|
|
127
|
+
export function enrichInputWithUserAgent<TInput extends { consumerAttribution?: BrokerConsumerAttribution }>(
|
|
128
|
+
input: TInput,
|
|
129
|
+
userAgent: string | null,
|
|
130
|
+
): TInput {
|
|
131
|
+
if (userAgent === null) return input
|
|
132
|
+
const base: BrokerConsumerAttribution = input.consumerAttribution ?? { kind: 'webrtc-browser' }
|
|
133
|
+
return {
|
|
134
|
+
...input,
|
|
135
|
+
consumerAttribution: { ...base, userAgent },
|
|
136
|
+
}
|
|
137
|
+
}
|
|
115
138
|
|
|
116
139
|
/**
|
|
117
140
|
* Relay-only forcing for remote viewers is DISABLED (2026-05-26).
|
|
@@ -124,13 +147,26 @@ type WebrtcSessionProvider = InferProvider<typeof webrtcSessionCapability>
|
|
|
124
147
|
* advertised Tailscale address, srflx, relay) and let ICE nominate the best
|
|
125
148
|
* reachable pair: direct when possible, relay only as a fallback. The
|
|
126
149
|
* `relayOnly` cap field + broker support remain for when relay media-forward
|
|
127
|
-
* is fixed
|
|
150
|
+
* is fixed.
|
|
151
|
+
*
|
|
152
|
+
* The wrapper additionally enriches the `createSession` / `handleOffer`
|
|
153
|
+
* subscriber attribution with the originating client's User-Agent, read
|
|
154
|
+
* from the tRPC request context (browser sessions). All OTHER methods
|
|
155
|
+
* delegate straight through — auth, the remote-proxy factory and every
|
|
156
|
+
* signaling behaviour are untouched.
|
|
128
157
|
*/
|
|
129
|
-
function wrapWebrtcSessionProviderWithRelay(
|
|
158
|
+
export function wrapWebrtcSessionProviderWithRelay(
|
|
130
159
|
provider: WebrtcSessionProvider,
|
|
131
|
-
|
|
160
|
+
ctx: TrpcContext,
|
|
132
161
|
): WebrtcSessionProvider {
|
|
133
|
-
|
|
162
|
+
const userAgent = extractUserAgent(ctx.req)
|
|
163
|
+
return {
|
|
164
|
+
...provider,
|
|
165
|
+
createSession: (input: CreateSessionInput) =>
|
|
166
|
+
provider.createSession(enrichInputWithUserAgent(input, userAgent)),
|
|
167
|
+
handleOffer: (input: HandleOfferInput) =>
|
|
168
|
+
provider.handleOffer(enrichInputWithUserAgent(input, userAgent)),
|
|
169
|
+
}
|
|
134
170
|
}
|
|
135
171
|
|
|
136
172
|
/**
|
|
@@ -191,7 +227,7 @@ function buildCapabilityRouters(services: RouterServices) {
|
|
|
191
227
|
(ctx) => buildToastProvider(services.toastService, ctx),
|
|
192
228
|
),
|
|
193
229
|
integrations: createCapRouter_integrations(
|
|
194
|
-
(_ctx) => buildIntegrationsProvider(services.addonRegistry, services.eventBus, services.loggingService),
|
|
230
|
+
(_ctx) => buildIntegrationsProvider(services.addonRegistry, services.eventBus, services.loggingService, services.capabilityRegistry),
|
|
195
231
|
),
|
|
196
232
|
nodes: createCapRouter_nodes(
|
|
197
233
|
(_ctx) => buildNodesProvider(
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { planIntegrationIdBackfill, planDeleteTimeStamps, runIntegrationIdBackfill } from '../integration-id-backfill'
|
|
3
|
+
|
|
4
|
+
describe('planDeleteTimeStamps', () => {
|
|
5
|
+
it('claims an untagged top-level device of the deleted integration\'s single-integration addon', () => {
|
|
6
|
+
const stamps = planDeleteTimeStamps(
|
|
7
|
+
'int_rtsp',
|
|
8
|
+
[{ id: 'int_rtsp', addonId: 'provider-rtsp' }],
|
|
9
|
+
[
|
|
10
|
+
{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
11
|
+
{ id: 6, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
12
|
+
],
|
|
13
|
+
)
|
|
14
|
+
expect(stamps).toEqual([
|
|
15
|
+
{ deviceId: 4, integrationId: 'int_rtsp' },
|
|
16
|
+
{ deviceId: 6, integrationId: 'int_rtsp' },
|
|
17
|
+
])
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
it('returns no stamps for a multi-integration addon (ambiguous — never auto-claim)', () => {
|
|
21
|
+
const stamps = planDeleteTimeStamps(
|
|
22
|
+
'int_a',
|
|
23
|
+
[{ id: 'int_a', addonId: 'provider-ha' }, { id: 'int_b', addonId: 'provider-ha' }],
|
|
24
|
+
[{ id: 11, addonId: 'provider-ha', parentDeviceId: null }],
|
|
25
|
+
)
|
|
26
|
+
expect(stamps).toEqual([])
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
it('only returns stamps for the integration being deleted, not siblings of other addons', () => {
|
|
30
|
+
const stamps = planDeleteTimeStamps(
|
|
31
|
+
'int_rtsp',
|
|
32
|
+
[{ id: 'int_rtsp', addonId: 'provider-rtsp' }, { id: 'int_onvif', addonId: 'provider-onvif' }],
|
|
33
|
+
[
|
|
34
|
+
{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
35
|
+
{ id: 5, addonId: 'provider-onvif', parentDeviceId: null },
|
|
36
|
+
],
|
|
37
|
+
)
|
|
38
|
+
expect(stamps).toEqual([{ deviceId: 4, integrationId: 'int_rtsp' }])
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
it('skips devices already tagged with the deleted integration', () => {
|
|
42
|
+
const stamps = planDeleteTimeStamps(
|
|
43
|
+
'int_rtsp',
|
|
44
|
+
[{ id: 'int_rtsp', addonId: 'provider-rtsp' }],
|
|
45
|
+
[{ id: 4, addonId: 'provider-rtsp', parentDeviceId: null, integrationId: 'int_rtsp' }],
|
|
46
|
+
)
|
|
47
|
+
expect(stamps).toEqual([])
|
|
48
|
+
})
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
describe('planIntegrationIdBackfill', () => {
|
|
52
|
+
it('stamps a top-level untagged device whose addon has exactly one integration', () => {
|
|
53
|
+
const stamps = planIntegrationIdBackfill(
|
|
54
|
+
[{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
55
|
+
[{ id: 10, addonId: 'provider-rtsp', parentDeviceId: null }],
|
|
56
|
+
)
|
|
57
|
+
expect(stamps).toEqual([{ deviceId: 10, integrationId: 'int_1' }])
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
it('skips devices whose addon hosts multiple integrations (ambiguous)', () => {
|
|
61
|
+
const stamps = planIntegrationIdBackfill(
|
|
62
|
+
[{ id: 'int_a', addonId: 'provider-ha' }, { id: 'int_b', addonId: 'provider-ha' }],
|
|
63
|
+
[{ id: 11, addonId: 'provider-ha', parentDeviceId: null }],
|
|
64
|
+
)
|
|
65
|
+
expect(stamps).toEqual([])
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
it('skips already-tagged devices and child devices', () => {
|
|
69
|
+
const stamps = planIntegrationIdBackfill(
|
|
70
|
+
[{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
71
|
+
[
|
|
72
|
+
{ id: 12, addonId: 'provider-rtsp', parentDeviceId: null, integrationId: 'int_1' },
|
|
73
|
+
{ id: 13, addonId: 'provider-rtsp', parentDeviceId: 12 },
|
|
74
|
+
],
|
|
75
|
+
)
|
|
76
|
+
expect(stamps).toEqual([])
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
it('skips devices whose addon has no integration', () => {
|
|
80
|
+
const stamps = planIntegrationIdBackfill(
|
|
81
|
+
[{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
82
|
+
[{ id: 14, addonId: 'provider-onvif', parentDeviceId: null }],
|
|
83
|
+
)
|
|
84
|
+
expect(stamps).toEqual([])
|
|
85
|
+
})
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
describe('runIntegrationIdBackfill', () => {
|
|
89
|
+
it('applies stamps and reports the count, skipping failures', async () => {
|
|
90
|
+
const stamped: Array<{ deviceId: number; integrationId: string }> = []
|
|
91
|
+
const result = await runIntegrationIdBackfill({
|
|
92
|
+
listIntegrations: async () => [{ id: 'int_1', addonId: 'provider-rtsp' }],
|
|
93
|
+
listDevices: async () => [
|
|
94
|
+
{ id: 10, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
95
|
+
{ id: 11, addonId: 'provider-rtsp', parentDeviceId: null },
|
|
96
|
+
],
|
|
97
|
+
setIntegrationId: async (deviceId, integrationId) => {
|
|
98
|
+
if (deviceId === 11) throw new Error('boom')
|
|
99
|
+
stamped.push({ deviceId, integrationId })
|
|
100
|
+
},
|
|
101
|
+
logger: { info: () => {}, warn: () => {} },
|
|
102
|
+
})
|
|
103
|
+
expect(result).toEqual({ stamped: 1 })
|
|
104
|
+
expect(stamped).toEqual([{ deviceId: 10, integrationId: 'int_1' }])
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it('does nothing when there is nothing to stamp', async () => {
|
|
108
|
+
const result = await runIntegrationIdBackfill({
|
|
109
|
+
listIntegrations: async () => [],
|
|
110
|
+
listDevices: async () => [],
|
|
111
|
+
setIntegrationId: async () => { throw new Error('should not be called') },
|
|
112
|
+
logger: { info: () => {}, warn: () => {} },
|
|
113
|
+
})
|
|
114
|
+
expect(result).toEqual({ stamped: 0 })
|
|
115
|
+
})
|
|
116
|
+
})
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One-time integration-id backfill for devices created before the
|
|
3
|
+
* device-manager forwarder started stamping `integrationId` (camera
|
|
4
|
+
* providers). Maps each addon that hosts EXACTLY ONE integration to that
|
|
5
|
+
* integration id, then stamps top-level untagged devices of those addons.
|
|
6
|
+
* Multi-instance addons (e.g. Home Assistant with several brokers) are
|
|
7
|
+
* ambiguous on `addonId` alone and are skipped — they stamp going forward.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface BackfillIntegration {
|
|
11
|
+
readonly id: string
|
|
12
|
+
readonly addonId: string
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface BackfillDevice {
|
|
16
|
+
readonly id: number
|
|
17
|
+
readonly addonId: string
|
|
18
|
+
readonly parentDeviceId: number | null
|
|
19
|
+
readonly integrationId?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export interface BackfillStamp {
|
|
23
|
+
readonly deviceId: number
|
|
24
|
+
readonly integrationId: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function planIntegrationIdBackfill(
|
|
28
|
+
integrations: readonly BackfillIntegration[],
|
|
29
|
+
devices: readonly BackfillDevice[],
|
|
30
|
+
): readonly BackfillStamp[] {
|
|
31
|
+
const singleByAddon = new Map<string, string>()
|
|
32
|
+
const ambiguous = new Set<string>()
|
|
33
|
+
for (const integration of integrations) {
|
|
34
|
+
if (ambiguous.has(integration.addonId)) continue
|
|
35
|
+
if (singleByAddon.has(integration.addonId)) {
|
|
36
|
+
singleByAddon.delete(integration.addonId)
|
|
37
|
+
ambiguous.add(integration.addonId)
|
|
38
|
+
continue
|
|
39
|
+
}
|
|
40
|
+
singleByAddon.set(integration.addonId, integration.id)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const stamps: BackfillStamp[] = []
|
|
44
|
+
for (const device of devices) {
|
|
45
|
+
if (device.parentDeviceId !== null) continue
|
|
46
|
+
if (device.integrationId !== undefined && device.integrationId !== '') continue
|
|
47
|
+
const integrationId = singleByAddon.get(device.addonId)
|
|
48
|
+
if (integrationId === undefined) continue
|
|
49
|
+
stamps.push({ deviceId: device.id, integrationId })
|
|
50
|
+
}
|
|
51
|
+
return stamps
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Stamps to apply at integration-DELETE time so a cascade removes legacy
|
|
56
|
+
* un-tagged devices too. The boot backfill only runs on startup; a device
|
|
57
|
+
* created before stamping (or whose provider never stamps, e.g. `provider-rtsp`)
|
|
58
|
+
* keeps no `integrationId`, so once its integration is deleted it would orphan
|
|
59
|
+
* forever — `removeByIntegration` matches on `integrationId` and finds nothing.
|
|
60
|
+
*
|
|
61
|
+
* Run this in the delete handler BEFORE deleting the integration record (while
|
|
62
|
+
* it is still present in `integrations`), then stamp the returned devices and
|
|
63
|
+
* let `removeByIntegration` cascade them. Reuses the boot backfill's safety
|
|
64
|
+
* rule (only addons hosting exactly ONE integration are unambiguous) and
|
|
65
|
+
* filters to the integration being deleted so siblings are never touched.
|
|
66
|
+
*/
|
|
67
|
+
export function planDeleteTimeStamps(
|
|
68
|
+
integrationId: string,
|
|
69
|
+
integrations: readonly BackfillIntegration[],
|
|
70
|
+
devices: readonly BackfillDevice[],
|
|
71
|
+
): readonly BackfillStamp[] {
|
|
72
|
+
return planIntegrationIdBackfill(integrations, devices).filter(
|
|
73
|
+
(stamp) => stamp.integrationId === integrationId,
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export interface BackfillLogger {
|
|
78
|
+
readonly info: (message: string, meta?: Record<string, unknown>) => void
|
|
79
|
+
readonly warn: (message: string, meta?: Record<string, unknown>) => void
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export interface IntegrationIdBackfillDeps {
|
|
83
|
+
readonly listIntegrations: () => Promise<readonly BackfillIntegration[]>
|
|
84
|
+
readonly listDevices: () => Promise<readonly BackfillDevice[]>
|
|
85
|
+
readonly setIntegrationId: (deviceId: number, integrationId: string) => Promise<void>
|
|
86
|
+
readonly logger: BackfillLogger
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function runIntegrationIdBackfill(
|
|
90
|
+
deps: IntegrationIdBackfillDeps,
|
|
91
|
+
): Promise<{ stamped: number }> {
|
|
92
|
+
const [integrations, devices] = await Promise.all([deps.listIntegrations(), deps.listDevices()])
|
|
93
|
+
const stamps = planIntegrationIdBackfill(integrations, devices)
|
|
94
|
+
let stamped = 0
|
|
95
|
+
for (const stamp of stamps) {
|
|
96
|
+
try {
|
|
97
|
+
await deps.setIntegrationId(stamp.deviceId, stamp.integrationId)
|
|
98
|
+
stamped++
|
|
99
|
+
} catch (err) {
|
|
100
|
+
deps.logger.warn('integrationId backfill: stamp failed', {
|
|
101
|
+
deviceId: stamp.deviceId,
|
|
102
|
+
integrationId: stamp.integrationId,
|
|
103
|
+
error: err instanceof Error ? err.message : String(err),
|
|
104
|
+
})
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (stamped > 0) deps.logger.info('integrationId backfill complete', { stamped })
|
|
108
|
+
return { stamped }
|
|
109
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
|
|
3
|
+
import { overlayDeclaration } from '../addon-row-manifest.js'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Regression: after the HA broker rework, redeploying provider-homeassistant
|
|
7
|
+
* (broker + device-adoption) left the integration picker without Home
|
|
8
|
+
* Assistant. `loadNewAddons` refreshed `entry.declaration` to the new caps but
|
|
9
|
+
* `listAddons` built its row manifest from the STALE `entry.addon.manifest`
|
|
10
|
+
* (still [broker, ha-discovery]), so `getAvailableTypes` filtered HA out.
|
|
11
|
+
*/
|
|
12
|
+
interface TestManifest {
|
|
13
|
+
id: string
|
|
14
|
+
name: string
|
|
15
|
+
icon?: string
|
|
16
|
+
brokerKind?: string
|
|
17
|
+
capabilities?: ReadonlyArray<{ name: string }>
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
describe('overlayDeclaration — listAddons row manifest freshness', () => {
|
|
21
|
+
const base = { id: 'provider-homeassistant', name: 'Home Assistant' }
|
|
22
|
+
|
|
23
|
+
it('prefers the fresh declaration capabilities over the stale instance manifest', () => {
|
|
24
|
+
const instanceManifest: TestManifest = {
|
|
25
|
+
...base,
|
|
26
|
+
capabilities: [{ name: 'broker' }, { name: 'ha-discovery' }],
|
|
27
|
+
}
|
|
28
|
+
const declaration: Partial<TestManifest> = {
|
|
29
|
+
capabilities: [{ name: 'broker' }, { name: 'device-adoption' }],
|
|
30
|
+
brokerKind: 'home-assistant',
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const merged = overlayDeclaration(instanceManifest, declaration)
|
|
34
|
+
|
|
35
|
+
expect(merged.capabilities).toEqual([{ name: 'broker' }, { name: 'device-adoption' }])
|
|
36
|
+
expect(merged.brokerKind).toBe('home-assistant')
|
|
37
|
+
})
|
|
38
|
+
|
|
39
|
+
it('keeps the instance manifest when no fresh declaration exists', () => {
|
|
40
|
+
const instanceManifest: TestManifest = { ...base, capabilities: [{ name: 'broker' }] }
|
|
41
|
+
|
|
42
|
+
const merged = overlayDeclaration(instanceManifest, undefined)
|
|
43
|
+
|
|
44
|
+
expect(merged.capabilities).toEqual([{ name: 'broker' }])
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
it('fills gaps from the instance manifest for keys the declaration omits', () => {
|
|
48
|
+
const instanceManifest: TestManifest = {
|
|
49
|
+
...base,
|
|
50
|
+
icon: 'assets/icon.svg',
|
|
51
|
+
capabilities: [{ name: 'broker' }],
|
|
52
|
+
}
|
|
53
|
+
const declaration: Partial<TestManifest> = {
|
|
54
|
+
capabilities: [{ name: 'broker' }, { name: 'device-adoption' }],
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const merged = overlayDeclaration(instanceManifest, declaration)
|
|
58
|
+
|
|
59
|
+
expect(merged.icon).toBe('assets/icon.svg')
|
|
60
|
+
expect(merged.capabilities).toContainEqual({ name: 'device-adoption' })
|
|
61
|
+
})
|
|
62
|
+
})
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/* eslint-disable @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-return, @typescript-eslint/no-unsafe-member-access -- pre-existing lint debt across this 2200-line orchestration class. The flagged sites (StorageService.setLocationManager / setSettingsBackend, LoggingService.addDestination, RouteRegistry, etc.) are typed as `unknown` by their owning services to break circular construction-order dependencies; runtime contracts are validated structurally. Tracked separately; do not amend in unrelated edits. */
|
|
2
2
|
import * as os from "node:os";
|
|
3
3
|
import { ConfigService } from "../config/config.service";
|
|
4
|
+
import { overlayDeclaration } from "./addon-row-manifest";
|
|
4
5
|
import { LoggingService } from "../logging/logging.service";
|
|
5
6
|
import { EventBusService } from "../events/event-bus.service";
|
|
6
7
|
import { StorageService } from "../storage/storage.service";
|
|
@@ -60,7 +61,7 @@ import type {
|
|
|
60
61
|
IStorageProvider as INewStorageProvider,
|
|
61
62
|
ISettingsBackend,
|
|
62
63
|
} from "@camstack/types";
|
|
63
|
-
import { AddonRouteRegistry } from "@camstack/core";
|
|
64
|
+
import { AddonRouteRegistry, DataPlaneRegistry } from "@camstack/core";
|
|
64
65
|
import { randomUUID } from "node:crypto";
|
|
65
66
|
import * as path from "node:path";
|
|
66
67
|
import * as fs from "node:fs";
|
|
@@ -193,6 +194,29 @@ function parseSerializableRouteDescriptors(raw: unknown): readonly ParsedRouteDe
|
|
|
193
194
|
});
|
|
194
195
|
}
|
|
195
196
|
|
|
197
|
+
/**
|
|
198
|
+
* Parse the wire-boundary `unknown` from `callForked(..., {target:'data-planes'})`
|
|
199
|
+
* into typed data-plane endpoint descriptors (`prefix/access/baseUrl/secret`).
|
|
200
|
+
* Pure data — the addon's `ctx.dataPlane` facility produced them.
|
|
201
|
+
*/
|
|
202
|
+
function parseDataPlaneEndpoints(raw: unknown): readonly import("@camstack/types").AddonDataPlaneEndpoint[] {
|
|
203
|
+
if (!Array.isArray(raw)) {
|
|
204
|
+
throw new Error("data-planes: child returned a non-array endpoint set");
|
|
205
|
+
}
|
|
206
|
+
return raw.map((entry: unknown): import("@camstack/types").AddonDataPlaneEndpoint => {
|
|
207
|
+
if (entry === null || typeof entry !== "object") {
|
|
208
|
+
throw new Error("data-planes: endpoint descriptor is not an object");
|
|
209
|
+
}
|
|
210
|
+
const prefix = Reflect.get(entry, "prefix");
|
|
211
|
+
const baseUrl = Reflect.get(entry, "baseUrl");
|
|
212
|
+
const secret = Reflect.get(entry, "secret");
|
|
213
|
+
if (typeof prefix !== "string" || typeof baseUrl !== "string" || typeof secret !== "string") {
|
|
214
|
+
throw new Error("data-planes: endpoint descriptor missing prefix/baseUrl/secret");
|
|
215
|
+
}
|
|
216
|
+
return { prefix, access: asRouteAccess(Reflect.get(entry, "access")), baseUrl, secret };
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
|
|
196
220
|
interface AddonEntry {
|
|
197
221
|
readonly addon: ICamstackAddon;
|
|
198
222
|
initialized: boolean;
|
|
@@ -240,6 +264,7 @@ export class AddonRegistryService {
|
|
|
240
264
|
private addonLoader!: AddonLoader;
|
|
241
265
|
private healthMonitor!: AddonHealthMonitor;
|
|
242
266
|
private addonRouteRegistry: AddonRouteRegistry | null = null;
|
|
267
|
+
private dataPlaneRegistry: DataPlaneRegistry | null = null;
|
|
243
268
|
|
|
244
269
|
// Broker-routed AddonApi proxy — every addon's `ctx.api` resolves
|
|
245
270
|
// to this. Calls go through `broker.call('${addonId}.${capName}.${method}')`
|
|
@@ -1029,6 +1054,41 @@ export class AddonRegistryService {
|
|
|
1029
1054
|
this.addonRouteRegistry = registry;
|
|
1030
1055
|
}
|
|
1031
1056
|
|
|
1057
|
+
/** Set the DataPlaneRegistry the hub's `/addon/:id/*` dispatch reverse-proxies
|
|
1058
|
+
* against (addon HTTP data-planes). */
|
|
1059
|
+
setDataPlaneRegistry(registry: DataPlaneRegistry): void {
|
|
1060
|
+
this.dataPlaneRegistry = registry;
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
/**
|
|
1064
|
+
* Pull a forked addon's HTTP data-plane endpoints over UDS and register them so
|
|
1065
|
+
* the hub can reverse-proxy `/addon/<addonId>/<prefix>/*` to the addon's own
|
|
1066
|
+
* listener. Idempotent (replace-all); an addon with no data-plane registers an
|
|
1067
|
+
* empty set (cleared). Called off the capabilities-changed signal — a data-plane
|
|
1068
|
+
* isn't a cap, but the child is UDS-reachable and has served its endpoints by
|
|
1069
|
+
* the time any of its caps register.
|
|
1070
|
+
*/
|
|
1071
|
+
private async mountAddonDataPlanes(addonId: string): Promise<void> {
|
|
1072
|
+
const registry = this.dataPlaneRegistry;
|
|
1073
|
+
if (!registry) return;
|
|
1074
|
+
const entry = this.addonEntries.get(addonId);
|
|
1075
|
+
const childRegistry = this.moleculer.childRegistry;
|
|
1076
|
+
// Forked addons only for now — co-located builtins would publish into the
|
|
1077
|
+
// registry directly via a hub-side sink (deferred; no builtin serves a
|
|
1078
|
+
// data-plane yet).
|
|
1079
|
+
if (!entry || !this.isForkedAddonEntry(entry)) return;
|
|
1080
|
+
if (childRegistry === null || !childRegistry.isChildKnown(addonId)) return;
|
|
1081
|
+
|
|
1082
|
+
const raw = await this.addonCallGateway.callForked(addonId, { target: "data-planes" });
|
|
1083
|
+
const endpoints = parseDataPlaneEndpoints(raw);
|
|
1084
|
+
registry.registerAddon(addonId, endpoints);
|
|
1085
|
+
if (endpoints.length > 0) {
|
|
1086
|
+
this.logger.info("Addon data-planes mounted (reverse-proxy)", {
|
|
1087
|
+
meta: { phase: "v2", addonId, prefixes: endpoints.map((e) => e.prefix) },
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}
|
|
1091
|
+
|
|
1032
1092
|
/**
|
|
1033
1093
|
* Called after app.init() when the tRPC router is available.
|
|
1034
1094
|
* No-op now: addon `ctx.api` resolves to a broker-routed proxy, so
|
|
@@ -1912,7 +1972,14 @@ export class AddonRegistryService {
|
|
|
1912
1972
|
}
|
|
1913
1973
|
return {
|
|
1914
1974
|
manifest: {
|
|
1915
|
-
|
|
1975
|
+
// Overlay the fresh on-disk declaration (refreshed by
|
|
1976
|
+
// `loadNewAddons` on every `camstack deploy`) onto the live
|
|
1977
|
+
// instance manifest, so a redeployed addon's new capabilities /
|
|
1978
|
+
// brokerKind surface here without a full backend restart. See
|
|
1979
|
+
// `overlayDeclaration` — this is what keeps Home Assistant
|
|
1980
|
+
// (post broker rework: broker + device-adoption) visible in the
|
|
1981
|
+
// "+ New Integration" picker.
|
|
1982
|
+
...overlayDeclaration(entry.addon.manifest!, entry.declaration),
|
|
1916
1983
|
packageName: entry.packageName,
|
|
1917
1984
|
packageVersion: entry.packageVersion,
|
|
1918
1985
|
packageDisplayName: entry.packageDisplayName,
|
|
@@ -2452,6 +2519,16 @@ export class AddonRegistryService {
|
|
|
2452
2519
|
default:
|
|
2453
2520
|
break;
|
|
2454
2521
|
}
|
|
2522
|
+
|
|
2523
|
+
// HTTP data-planes aren't capabilities, so no `case` fires for them —
|
|
2524
|
+
// pull them off ANY cap-changed signal for this (forked) addon. Cheap +
|
|
2525
|
+
// idempotent (replace-all), and re-pulls the fresh baseUrl/secret after a
|
|
2526
|
+
// restart (the child re-handshakes → caps re-register → this re-fires).
|
|
2527
|
+
void this.mountAddonDataPlanes(addonId).catch((err: unknown) => {
|
|
2528
|
+
this.logger.error('Failed to mount addon data-planes', {
|
|
2529
|
+
meta: { phase: 'v2', addonId, error: errMsg(err) },
|
|
2530
|
+
})
|
|
2531
|
+
})
|
|
2455
2532
|
},
|
|
2456
2533
|
);
|
|
2457
2534
|
}
|
|
@@ -2872,12 +2949,22 @@ export class AddonRegistryService {
|
|
|
2872
2949
|
streamProbe: kernelStreamProbe,
|
|
2873
2950
|
hwaccel: createKernelHwAccel(),
|
|
2874
2951
|
capabilityRegistry: this.capabilityRegistry,
|
|
2952
|
+
// Per-addon storage-location declarations across every installed
|
|
2953
|
+
// addon — surfaced from the kernel's AddonLoader so the
|
|
2954
|
+
// storage-orchestrator builtin can aggregate them and seed defaults.
|
|
2955
|
+
listStorageLocationDeclarations: () =>
|
|
2956
|
+
this.addonLoader.listStorageLocationDeclarations(),
|
|
2875
2957
|
readinessRegistry: this.moleculer.readinessRegistry,
|
|
2876
2958
|
// D3: handshake-fed native-cap view of the whole cluster. Backed by
|
|
2877
2959
|
// `HubNodeRegistry.listNativeCapEntries()` populated by every
|
|
2878
2960
|
// `$hub.registerNode` re-handshake. Used by device-manager as the
|
|
2879
2961
|
// reliable fallback when push events were lost mid-transport.
|
|
2880
2962
|
listClusterNativeCaps: () => this.moleculer.listClusterNativeCaps(),
|
|
2963
|
+
// Per-device slice of the above — O(caps-for-device) via the registry's
|
|
2964
|
+
// deviceId index. The per-device `getBindings` resolver prefers this
|
|
2965
|
+
// over filtering the whole-cluster flat view.
|
|
2966
|
+
listClusterNativeCapsForDevice: (deviceId: number) =>
|
|
2967
|
+
this.moleculer.listClusterNativeCapsForDevice(deviceId),
|
|
2881
2968
|
},
|
|
2882
2969
|
registerProvider,
|
|
2883
2970
|
resolveProvider: <T = unknown>(capName: string): T | null => {
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Overlay the fresh on-disk `declaration` onto a live addon instance's
|
|
3
|
+
* `manifest` when building the rows returned by `listAddons()`.
|
|
4
|
+
*
|
|
5
|
+
* `loadNewAddons()` — invoked on every `camstack deploy` / package install —
|
|
6
|
+
* refreshes `entry.declaration` from a fresh disk scan but DELIBERATELY leaves
|
|
7
|
+
* the live `entry.addon` instance in place so `restartAddon` can shut the old
|
|
8
|
+
* instance down cleanly. Manifest-affecting changes (a newly declared
|
|
9
|
+
* capability, a new `brokerKind`) therefore land on `entry.declaration` while
|
|
10
|
+
* `entry.addon.manifest` keeps the value captured at the addon's ORIGINAL load.
|
|
11
|
+
*
|
|
12
|
+
* Consumers of `listAddons()` — notably the integration picker's
|
|
13
|
+
* `getAvailableTypes`, which gates a provider into the "+ New Integration" list
|
|
14
|
+
* by its declared capability (`device-provider` / `device-adoption`) — must see
|
|
15
|
+
* the post-deploy manifest. Otherwise a redeployed addon that gained
|
|
16
|
+
* `device-adoption` (e.g. the Home Assistant provider after the broker rework)
|
|
17
|
+
* stays invisible in the picker until a full backend restart re-reads the
|
|
18
|
+
* manifest from disk.
|
|
19
|
+
*
|
|
20
|
+
* The on-disk `declaration` is parsed from `package.json` (no `undefined`
|
|
21
|
+
* values), so the spread overlays only the keys the declaration actually
|
|
22
|
+
* defines; the instance manifest fills any gaps.
|
|
23
|
+
*/
|
|
24
|
+
export function overlayDeclaration<M extends object>(
|
|
25
|
+
instanceManifest: M,
|
|
26
|
+
declaration: Partial<M> | undefined,
|
|
27
|
+
): M {
|
|
28
|
+
return { ...instanceManifest, ...declaration }
|
|
29
|
+
}
|
|
@@ -25,8 +25,13 @@ export class LoggingService extends LogManager {
|
|
|
25
25
|
private readonly deviceNames = new Map<number, string>()
|
|
26
26
|
|
|
27
27
|
constructor(configService: ConfigService) {
|
|
28
|
-
|
|
29
|
-
|
|
28
|
+
// The log buffer is now partitioned per addonId (see PartitionedLogBuffer):
|
|
29
|
+
// this value caps EACH addon's bucket, not the total. A chatty addon evicts
|
|
30
|
+
// only its own lines, so quiet addons (e.g. a HomeAssistant `image` entity)
|
|
31
|
+
// keep their sparse history. `eventBus.ringBufferSize` still sizes the
|
|
32
|
+
// separate system-event ring; logs get their own per-addon cap.
|
|
33
|
+
const perAddonCapacity = configService.get<number>('eventBus.perAddonLogBufferSize') ?? 5000
|
|
34
|
+
super(perAddonCapacity)
|
|
30
35
|
// Enriches every emitted LogEntry with `tags.deviceName` before
|
|
31
36
|
// destinations / subscribers see it — works across bundled copies
|
|
32
37
|
// of `@camstack/core` (addon packages) because the mutation
|
|
@@ -257,6 +257,14 @@ export class MoleculerService {
|
|
|
257
257
|
getResolver: () => this.resolver,
|
|
258
258
|
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment -- moleculer types unresolvable; see `broker` getter docstring
|
|
259
259
|
broker: this.broker,
|
|
260
|
+
// Single capability authority — lets the broker fallback pin a
|
|
261
|
+
// device-scoped call to its owning node instead of load-balancing it.
|
|
262
|
+
nodeRegistry: this.nodeRegistry,
|
|
263
|
+
// Hub-local UDS child dispatcher — routes a device-scoped native cap
|
|
264
|
+
// owned by a hub-local child (reolink/hikvision cameras) directly over
|
|
265
|
+
// UDS before any broker fallback. Getter: `this.localChildRegistry` is
|
|
266
|
+
// assigned later in this method, after the handler is constructed.
|
|
267
|
+
getLocalDispatcher: () => this.localChildRegistry,
|
|
260
268
|
logger: {
|
|
261
269
|
warn: (msg, meta) => logger.warn(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
|
|
262
270
|
},
|
|
@@ -267,6 +275,16 @@ export class MoleculerService {
|
|
|
267
275
|
logger: {
|
|
268
276
|
info: (msg, meta) => logger.info(msg, meta !== null && meta !== undefined ? { meta: meta as Record<string, unknown> } : undefined),
|
|
269
277
|
},
|
|
278
|
+
// Hand the UDS-routing layer a view into the operator's
|
|
279
|
+
// active-singleton preference. Without this, when two local
|
|
280
|
+
// children own the same singleton cap (today: `webrtc-session`
|
|
281
|
+
// → `stream-broker` + `addon-webrtc-native`), routing returns
|
|
282
|
+
// the first-registered child by insertion order — silently
|
|
283
|
+
// bypassing `setActiveSingleton`. The closure reads the live
|
|
284
|
+
// registry on every call so a runtime swap takes effect
|
|
285
|
+
// immediately without rebuilding the resolver snapshot.
|
|
286
|
+
getActiveSingletonAddonId: (capName: string): string | null =>
|
|
287
|
+
this.capabilityService.getRegistry()?.getSingletonAddonId(capName) ?? null,
|
|
270
288
|
})
|
|
271
289
|
await registry.start()
|
|
272
290
|
// E1: apply child manifest + cleanup from the UDS lifecycle (hub-local children).
|
|
@@ -880,6 +898,16 @@ export class MoleculerService {
|
|
|
880
898
|
return this.nodeRegistry.listNativeCapEntries()
|
|
881
899
|
}
|
|
882
900
|
|
|
901
|
+
/**
|
|
902
|
+
* Per-device slice of {@link listClusterNativeCaps}, served from the
|
|
903
|
+
* registry's `deviceId → entries` index — O(caps-for-device). Used by the
|
|
904
|
+
* per-device `getBindings` hot path so `getAllBindings` doesn't flatten the
|
|
905
|
+
* whole cluster once per device.
|
|
906
|
+
*/
|
|
907
|
+
listClusterNativeCapsForDevice(deviceId: number): readonly import('@camstack/kernel').NodeNativeCapEntry[] {
|
|
908
|
+
return this.nodeRegistry.listNativeCapEntriesForDevice(deviceId)
|
|
909
|
+
}
|
|
910
|
+
|
|
883
911
|
/**
|
|
884
912
|
* E2: Send a `set-log-level` UDS message to a hub-local child identified by
|
|
885
913
|
* `nodeId` (e.g. `hub/provider-reolink`). Extracts the `childId` from the
|
|
@@ -32,10 +32,11 @@ describe('NetworkQualityService', () => {
|
|
|
32
32
|
})
|
|
33
33
|
|
|
34
34
|
it('should track client stats', () => {
|
|
35
|
-
service.reportClientStats(1, { rttMs: 50, jitterMs: 5, estimatedBandwidthKbps: 20000 })
|
|
35
|
+
service.reportClientStats(1, { rttMs: 50, jitterMs: 5, estimatedBandwidthKbps: 20000, packetLossPercent: 3 })
|
|
36
36
|
const stats = service.getDeviceStats(1)
|
|
37
37
|
expect(stats!.client?.rttMs).toBe(50)
|
|
38
38
|
expect(stats!.client?.estimatedBandwidthKbps).toBe(20000)
|
|
39
|
+
expect(stats!.client?.packetLossPercent).toBe(3)
|
|
39
40
|
})
|
|
40
41
|
|
|
41
42
|
it('should list all device stats', () => {
|