@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
@@ -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, recording → `recordingEngine` cap, network →
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; this wrapper is a pass-through for now.
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
- _ctx: TrpcContext,
160
+ ctx: TrpcContext,
132
161
  ): WebrtcSessionProvider {
133
- return provider
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
- ...entry.addon.manifest!,
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
- const bufferSize = configService.get<number>('eventBus.ringBufferSize') ?? 10000
29
- super(bufferSize)
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', () => {