@camstack/core 0.1.37 → 0.1.39
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/dist/auth/auth-manager.d.ts +12 -1
- package/dist/auth/auth-manager.d.ts.map +1 -1
- package/dist/auth/scope-matcher.d.ts +8 -0
- package/dist/auth/scope-matcher.d.ts.map +1 -0
- package/dist/auth/totp-manager.d.ts +0 -1
- package/dist/auth/totp-manager.d.ts.map +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts +15 -0
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.d.ts.map +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js +27 -6
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.js.map +1 -1
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs +27 -6
- package/dist/builtins/addon-widgets-aggregator/addon-widgets-aggregator.addon.mjs.map +1 -1
- package/dist/builtins/device-manager/device-config-contribution.d.ts +33 -0
- package/dist/builtins/device-manager/device-config-contribution.d.ts.map +1 -0
- package/dist/builtins/device-manager/device-manager.addon.d.ts +52 -17
- package/dist/builtins/device-manager/device-manager.addon.d.ts.map +1 -1
- package/dist/builtins/device-manager/device-manager.addon.js +285 -161
- package/dist/builtins/device-manager/device-manager.addon.js.map +1 -1
- package/dist/builtins/device-manager/device-manager.addon.mjs +286 -162
- package/dist/builtins/device-manager/device-manager.addon.mjs.map +1 -1
- package/dist/builtins/local-auth/auth-schema.d.ts +1 -0
- package/dist/builtins/local-auth/auth-schema.d.ts.map +1 -1
- package/dist/builtins/local-auth/local-auth.addon.d.ts +1 -0
- package/dist/builtins/local-auth/local-auth.addon.d.ts.map +1 -1
- package/dist/builtins/local-auth/local-auth.addon.js +354 -3
- package/dist/builtins/local-auth/local-auth.addon.js.map +1 -1
- package/dist/builtins/local-auth/local-auth.addon.mjs +355 -3
- package/dist/builtins/local-auth/local-auth.addon.mjs.map +1 -1
- package/dist/builtins/local-auth/oauth-grants.d.ts +46 -0
- package/dist/builtins/local-auth/oauth-grants.d.ts.map +1 -0
- package/dist/builtins/local-auth/oauth-session-manager.d.ts +51 -0
- package/dist/builtins/local-auth/oauth-session-manager.d.ts.map +1 -0
- package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts +97 -0
- package/dist/builtins/remote-access-orchestrator/enabled-providers-reconcile.d.ts.map +1 -0
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts +24 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.d.ts.map +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js +136 -56
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.js.map +1 -1
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs +137 -57
- package/dist/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.mjs.map +1 -1
- package/dist/builtins/snapshot/index.js +1 -3
- package/dist/builtins/snapshot/index.js.map +1 -1
- package/dist/builtins/snapshot/index.mjs +1 -3
- package/dist/builtins/snapshot/index.mjs.map +1 -1
- package/dist/builtins/snapshot/snapshot.addon.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +428 -234
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +428 -235
- package/dist/index.mjs.map +1 -1
- package/package.json +19 -37
- package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.d.ts +0 -8
- package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.d.ts.map +0 -1
- package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.js +0 -75
- package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.js.map +0 -1
- package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.mjs +0 -69
- package/dist/builtins/auth-orchestrator/auth-orchestrator.addon.mjs.map +0 -1
- package/dist/builtins/auth-orchestrator/index.d.ts +0 -2
- package/dist/builtins/auth-orchestrator/index.d.ts.map +0 -1
- package/dist/builtins/auth-orchestrator/index.js +0 -7
- package/dist/builtins/auth-orchestrator/index.mjs +0 -2
- package/dist/builtins/mesh-orchestrator/index.d.ts +0 -2
- package/dist/builtins/mesh-orchestrator/index.d.ts.map +0 -1
- package/dist/builtins/mesh-orchestrator/index.js +0 -7
- package/dist/builtins/mesh-orchestrator/index.mjs +0 -2
- package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.d.ts +0 -9
- package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.d.ts.map +0 -1
- package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.js +0 -113
- package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.js.map +0 -1
- package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.mjs +0 -107
- package/dist/builtins/mesh-orchestrator/mesh-orchestrator.addon.mjs.map +0 -1
- package/dist/builtins/turn-orchestrator/index.d.ts +0 -2
- package/dist/builtins/turn-orchestrator/index.d.ts.map +0 -1
- package/dist/builtins/turn-orchestrator/index.js +0 -7
- package/dist/builtins/turn-orchestrator/index.mjs +0 -2
- package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.d.ts +0 -34
- package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.d.ts.map +0 -1
- package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.js +0 -126
- package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.js.map +0 -1
- package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.mjs +0 -120
- package/dist/builtins/turn-orchestrator/turn-orchestrator.addon.mjs.map +0 -1
|
@@ -1,40 +1,79 @@
|
|
|
1
|
-
import { BaseAddon,
|
|
1
|
+
import { BaseAddon, EventCategory } from "@camstack/types";
|
|
2
|
+
//#region src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts
|
|
3
|
+
/**
|
|
4
|
+
* Reconcile the durable `enabledProviders` set against authoritative
|
|
5
|
+
* RPC probe results. Pure — no I/O. ADD-ONLY: only adds providers whose
|
|
6
|
+
* acked `getStatus()` reports `connected: true`. Never removes on
|
|
7
|
+
* `connected: false` — see module doc for the full rationale.
|
|
8
|
+
*
|
|
9
|
+
* @param currentEnabled - the persisted enabled-set before reconcile.
|
|
10
|
+
* @param probes - one entry per provider whose `getStatus()` RPC was
|
|
11
|
+
* attempted this pass. Providers absent from this list are untouched.
|
|
12
|
+
*/
|
|
13
|
+
function reconcileEnabledProviders(currentEnabled, probes) {
|
|
14
|
+
const next = new Set(currentEnabled);
|
|
15
|
+
const added = [];
|
|
16
|
+
for (const probe of probes) {
|
|
17
|
+
if (!probe.ok) continue;
|
|
18
|
+
if (probe.connected) {
|
|
19
|
+
if (!next.has(probe.addonId)) {
|
|
20
|
+
next.add(probe.addonId);
|
|
21
|
+
added.push(probe.addonId);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return {
|
|
26
|
+
nextEnabled: [...next].sort(),
|
|
27
|
+
changed: added.length > 0,
|
|
28
|
+
added,
|
|
29
|
+
removed: []
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
//#endregion
|
|
2
33
|
//#region src/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.ts
|
|
3
34
|
/**
|
|
4
|
-
* Remote-access orchestrator —
|
|
5
|
-
* `network-access` collection (Cloudflare Tunnel, ngrok, Tailscale, …).
|
|
6
|
-
*
|
|
35
|
+
* Remote-access orchestrator — backend-only boot-autostart service for
|
|
36
|
+
* the `network-access` collection (Cloudflare Tunnel, ngrok, Tailscale, …).
|
|
37
|
+
*
|
|
38
|
+
* Retired its `remote-access` facade cap (2026-05-15): the admin UI now
|
|
39
|
+
* talks to the `network-access` collection cap directly via generic
|
|
40
|
+
* per-`addonId` routing, so this addon registers NO capability.
|
|
7
41
|
*
|
|
8
|
-
*
|
|
42
|
+
* What it still owns — the load-bearing logic:
|
|
9
43
|
* The orchestrator owns the "operator wants this provider running"
|
|
10
|
-
* intent —
|
|
44
|
+
* intent — an `enabledProviders: string[]` slice in its addon-store
|
|
11
45
|
* blob (BaseAddon.config). On boot we iterate the list and call
|
|
12
|
-
* `provider.start()` for each enabled entry
|
|
13
|
-
*
|
|
14
|
-
*
|
|
46
|
+
* `provider.start()` for each enabled entry, so a tunnel set up once
|
|
47
|
+
* stays up across hub restarts.
|
|
48
|
+
*
|
|
49
|
+
* Since start/stop no longer flow through this addon, the enabled-set
|
|
50
|
+
* is kept in sync from two sources:
|
|
51
|
+
* 1. `NetworkTunnelStarted` / `NetworkTunnelStopped` bus events —
|
|
52
|
+
* fast, but lossy (fire-and-forget broadcasts). They drive both
|
|
53
|
+
* the live UI and removal from the durable set (`Stopped` is the
|
|
54
|
+
* only reliable "operator stopped it" signal).
|
|
55
|
+
* 2. An RPC-driven ADD-ONLY RECONCILE pass (`reconcileEnabledProviders`)
|
|
56
|
+
* that pulls authoritative `connected` state via the `network-access`
|
|
57
|
+
* cap's `getStatus()` on every provider (re)connect. THIS covers
|
|
58
|
+
* dropped `NetworkTunnelStarted` events — if `connected: true` is
|
|
59
|
+
* acked, the provider is added to `enabledProviders`. It NEVER
|
|
60
|
+
* removes on `connected: false` because that signal is ambiguous
|
|
61
|
+
* (registered-not-yet-started vs transiently-down vs intentionally
|
|
62
|
+
* stopped). Running before `autoStartEnabledProviders` is safe:
|
|
63
|
+
* it can only add already-connected providers, never evict.
|
|
15
64
|
*/
|
|
16
65
|
var RemoteAccessOrchestratorAddon = class extends BaseAddon {
|
|
17
66
|
constructor() {
|
|
18
67
|
super({ enabledProviders: [] });
|
|
19
68
|
}
|
|
20
69
|
async onInitialize() {
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
return endpoint;
|
|
29
|
-
},
|
|
30
|
-
stopProvider: async ({ addonId }) => {
|
|
31
|
-
const impl = this.resolveImpl(addonId);
|
|
32
|
-
if (impl?.stop) await impl.stop();
|
|
33
|
-
await this.markEnabled(addonId, false);
|
|
34
|
-
return { success: true };
|
|
35
|
-
}
|
|
36
|
-
};
|
|
37
|
-
this.ctx.logger.info("Remote-access orchestrator initialized", { meta: { enabledCount: this.config.enabledProviders.length } });
|
|
70
|
+
this.ctx.logger.info("Remote-access orchestrator initialized (backend-only)", { meta: { enabledCount: this.config.enabledProviders.length } });
|
|
71
|
+
this.ctx.eventBus?.subscribe({ category: EventCategory.NetworkTunnelStarted }, (event) => {
|
|
72
|
+
this.onTunnelLifecycle(event.source, true);
|
|
73
|
+
});
|
|
74
|
+
this.ctx.eventBus?.subscribe({ category: EventCategory.NetworkTunnelStopped }, (event) => {
|
|
75
|
+
this.onTunnelLifecycle(event.source, false);
|
|
76
|
+
});
|
|
38
77
|
setImmediate(() => {
|
|
39
78
|
this.autoStartEnabledProviders();
|
|
40
79
|
});
|
|
@@ -44,12 +83,76 @@ var RemoteAccessOrchestratorAddon = class extends BaseAddon {
|
|
|
44
83
|
this.watchCapability("mesh-network", { onReady: () => {
|
|
45
84
|
this.autoStartEnabledProviders();
|
|
46
85
|
} });
|
|
47
|
-
return [
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
86
|
+
return [];
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Maintain the persisted `enabledProviders` set from a tunnel
|
|
90
|
+
* lifecycle event. `source.id` is `string | number`; `network-access`
|
|
91
|
+
* providers emit with `type: 'addon'` so it is always the addonId
|
|
92
|
+
* string. Non-string / non-addon sources are ignored defensively.
|
|
93
|
+
*/
|
|
94
|
+
async onTunnelLifecycle(source, started) {
|
|
95
|
+
if (source.type !== "addon" || typeof source.id !== "string") {
|
|
96
|
+
this.ctx.logger.warn("tunnel lifecycle event with non-addon source — ignoring", { meta: {
|
|
97
|
+
sourceType: source.type,
|
|
98
|
+
sourceId: source.id,
|
|
99
|
+
started
|
|
100
|
+
} });
|
|
101
|
+
return;
|
|
102
|
+
}
|
|
103
|
+
await this.markEnabled(source.id, started);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Probe every locally-visible `network-access` provider's
|
|
107
|
+
* `getStatus()` over RPC and reconcile the durable `enabledProviders`
|
|
108
|
+
* set against the result. This is the D8 backstop for dropped
|
|
109
|
+
* `NetworkTunnelStarted` events: if `connected: true` is acked, the
|
|
110
|
+
* provider is added to `enabledProviders`. ADD-ONLY — never removes on
|
|
111
|
+
* `connected: false` (see enabled-providers-reconcile.ts for rationale).
|
|
112
|
+
*
|
|
113
|
+
* `getStatus()` is an acknowledged cap RPC — a transport blip surfaces
|
|
114
|
+
* as a rejected promise (recorded as `ok: false`, membership left
|
|
115
|
+
* untouched), never as a silent lossy write. Runs on every provider
|
|
116
|
+
* (re)connect via the `watchCapability` hooks, mirroring the kernel
|
|
117
|
+
* readiness-registry hydration on `$node.connected`. Safe to call
|
|
118
|
+
* before `autoStartEnabledProviders`: add-only semantics mean it cannot
|
|
119
|
+
* wipe the enabled-set even when providers are registered-not-yet-started.
|
|
120
|
+
*/
|
|
121
|
+
async reconcileAndPersist() {
|
|
122
|
+
const entries = this.capabilities?.getCollectionEntries("network-access") ?? [];
|
|
123
|
+
if (entries.length === 0) return;
|
|
124
|
+
const probes = await Promise.all(entries.map(async ([addonId, impl]) => {
|
|
125
|
+
if (!impl.getStatus) return {
|
|
126
|
+
addonId,
|
|
127
|
+
ok: false
|
|
128
|
+
};
|
|
129
|
+
try {
|
|
130
|
+
return {
|
|
131
|
+
addonId,
|
|
132
|
+
ok: true,
|
|
133
|
+
connected: (await impl.getStatus()).connected
|
|
134
|
+
};
|
|
135
|
+
} catch (err) {
|
|
136
|
+
this.ctx.logger.warn("reconcile: getStatus RPC failed — leaving membership as-is", { meta: {
|
|
137
|
+
addonId,
|
|
138
|
+
error: err instanceof Error ? err.message : String(err)
|
|
139
|
+
} });
|
|
140
|
+
return {
|
|
141
|
+
addonId,
|
|
142
|
+
ok: false
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
}));
|
|
146
|
+
const result = reconcileEnabledProviders(this.config.enabledProviders, probes);
|
|
147
|
+
if (!result.changed) return;
|
|
148
|
+
this.ctx.logger.info("reconcile: corrected enabledProviders from RPC-pulled state", { meta: {
|
|
149
|
+
added: result.added,
|
|
150
|
+
removed: result.removed
|
|
151
|
+
} });
|
|
152
|
+
await this.updateGlobalSettings({ enabledProviders: result.nextEnabled });
|
|
51
153
|
}
|
|
52
154
|
async autoStartEnabledProviders() {
|
|
155
|
+
await this.reconcileAndPersist();
|
|
53
156
|
const ids = this.config.enabledProviders;
|
|
54
157
|
if (ids.length === 0) return;
|
|
55
158
|
this.ctx.logger.info("Auto-starting enabled remote-access providers", { meta: { addonIds: [...ids] } });
|
|
@@ -91,38 +194,15 @@ var RemoteAccessOrchestratorAddon = class extends BaseAddon {
|
|
|
91
194
|
if (enabled) current.add(addonId);
|
|
92
195
|
else current.delete(addonId);
|
|
93
196
|
if (wasEnabled === enabled) return;
|
|
197
|
+
this.ctx.logger.info("remote-access intent updated", { meta: {
|
|
198
|
+
addonId,
|
|
199
|
+
enabled
|
|
200
|
+
} });
|
|
94
201
|
await this.updateGlobalSettings({ enabledProviders: [...current] });
|
|
95
202
|
}
|
|
96
203
|
resolveImpl(addonId) {
|
|
97
204
|
return (this.capabilities?.getCollectionEntries("network-access") ?? []).find(([id]) => id === addonId)?.[1] ?? null;
|
|
98
205
|
}
|
|
99
|
-
async listProviders() {
|
|
100
|
-
const entries = this.capabilities?.getCollectionEntries("network-access") ?? [];
|
|
101
|
-
const enabled = new Set(this.config.enabledProviders);
|
|
102
|
-
const out = [];
|
|
103
|
-
for (const [addonId, impl] of entries) {
|
|
104
|
-
let connected = false;
|
|
105
|
-
let endpoint = null;
|
|
106
|
-
let error;
|
|
107
|
-
if (impl.getStatus) try {
|
|
108
|
-
const s = await impl.getStatus();
|
|
109
|
-
connected = s.connected;
|
|
110
|
-
endpoint = s.endpoint;
|
|
111
|
-
error = s.error;
|
|
112
|
-
} catch (err) {
|
|
113
|
-
error = err instanceof Error ? err.message : String(err);
|
|
114
|
-
}
|
|
115
|
-
out.push({
|
|
116
|
-
addonId,
|
|
117
|
-
displayName: impl.displayName ?? addonId,
|
|
118
|
-
enabled: enabled.has(addonId),
|
|
119
|
-
connected,
|
|
120
|
-
endpoint,
|
|
121
|
-
...error !== void 0 ? { error } : {}
|
|
122
|
-
});
|
|
123
|
-
}
|
|
124
|
-
return out;
|
|
125
|
-
}
|
|
126
206
|
};
|
|
127
207
|
//#endregion
|
|
128
208
|
export { RemoteAccessOrchestratorAddon, RemoteAccessOrchestratorAddon as default };
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"remote-access-orchestrator.addon.mjs","names":[],"sources":["../../../src/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.ts"],"sourcesContent":["/**\n * Remote-access orchestrator — singleton facade over the\n * `network-access` collection (Cloudflare Tunnel, ngrok, Tailscale, …).\n * Mirrors the auth-orchestrator and backup-orchestrator patterns.\n *\n * Persistence + autostart contract:\n * The orchestrator owns the \"operator wants this provider running\"\n * intent — a `enabledProviders: string[]` slice in its addon-store\n * blob (BaseAddon.config). On boot we iterate the list and call\n * `provider.start()` for each enabled entry. `startProvider` /\n * `stopProvider` mutate this list so a Start press persists across\n * restarts. Same shape as turn-orchestrator's setProviderEnabled.\n */\nimport {\n BaseAddon,\n remoteAccessCapability,\n type IRemoteAccessOrchestrator,\n type RemoteAccessProviderInfo,\n type ProviderRegistration,\n} from '@camstack/types'\n\ninterface NetworkAccessLike {\n start?: () => Promise<{ url: string; hostname: string; port: number; protocol: 'http' | 'https' }>\n stop?: () => Promise<void>\n getStatus?: () => Promise<{\n connected: boolean\n endpoint: { url: string; hostname: string; port: number; protocol: 'http' | 'https' } | null\n error?: string\n }>\n}\n\ninterface NetworkAccessRegistrationMeta {\n readonly displayName?: string\n}\n\ninterface RemoteAccessOrchestratorConfig {\n /**\n * addonIds the operator has explicitly Started. Auto-respawned on\n * boot so a tunnel set up once stays up across hub restarts.\n */\n readonly enabledProviders: readonly string[]\n}\n\nexport class RemoteAccessOrchestratorAddon extends BaseAddon<RemoteAccessOrchestratorConfig> {\n constructor() {\n super({ enabledProviders: [] })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n const provider: IRemoteAccessOrchestrator = {\n listProviders: async () => this.listProviders(),\n startProvider: async ({ addonId }) => {\n const impl = this.resolveImpl(addonId)\n if (!impl?.start) throw new Error(`Remote-access provider \"${addonId}\" does not support start`)\n const endpoint = await impl.start()\n // Persist intent — next boot will auto-respawn this provider.\n await this.markEnabled(addonId, true)\n return endpoint\n },\n stopProvider: async ({ addonId }) => {\n const impl = this.resolveImpl(addonId)\n if (impl?.stop) await impl.stop()\n // Clear intent — boot must NOT respawn this on next start.\n await this.markEnabled(addonId, false)\n return { success: true as const }\n },\n } satisfies IRemoteAccessOrchestrator\n this.ctx.logger.info('Remote-access orchestrator initialized', {\n meta: { enabledCount: this.config.enabledProviders.length },\n })\n // Defer autostart to next tick so the orchestrator's own provider\n // registration completes first. `resolveImpl` reads from the\n // capabilities registry which only sees in-process / cluster-mirrored\n // providers once they've ALSO registered — small delay gives the\n // cluster bridge time to discover them on cold boot. Errors are\n // logged but never block init.\n setImmediate(() => { this.autoStartEnabledProviders() })\n\n // Lazy retry — forked providers (cloudflare-tunnel etc) typically\n // register 15-20 s after this orchestrator boots, well past the\n // `setImmediate` above. Hook BaseAddon's `system.ready-state`\n // subscription so we re-run autoStart every time the\n // `network-access` cap transitions to ready (whichever node holds\n // it). The inner logic is idempotent + skips already-connected\n // providers.\n this.watchCapability('network-access', {\n onReady: () => { void this.autoStartEnabledProviders() },\n })\n\n // Same watch for `mesh-network`. The tailscale-ingress provider\n // registers `network-access` synchronously at boot, but `start()`\n // throws when the tailnet isn't joined yet — so the boot-time\n // autoStart call fails for tailscale ingresses if the tailscale\n // daemon hadn't logged in by then. Watching `mesh-network` here\n // re-triggers autoStart the moment the client transitions to\n // joined (manual operator login or auto-rejoin), without needing\n // a server restart. Same idempotency rules: providers already\n // connected are skipped.\n this.watchCapability('mesh-network', {\n onReady: () => { void this.autoStartEnabledProviders() },\n })\n\n return [{ capability: remoteAccessCapability, provider }]\n }\n\n private async autoStartEnabledProviders(): Promise<void> {\n const ids = this.config.enabledProviders\n if (ids.length === 0) return\n this.ctx.logger.info('Auto-starting enabled remote-access providers', {\n meta: { addonIds: [...ids] },\n })\n for (const addonId of ids) {\n try {\n const impl = this.resolveImpl(addonId)\n if (!impl?.start) {\n // Provider isn't loaded yet (worker bridge still hydrating)\n // OR it doesn't implement start. Log at debug level — the\n // provider-registered subscription below will retry as soon\n // as the addon appears.\n this.ctx.logger.warn('autostart: provider not ready or unsupported', {\n meta: { addonId, hasImpl: !!impl, hasStart: !!impl?.start },\n })\n continue\n }\n // Idempotent: skip when the provider is already connected.\n // Avoids spamming start() on every provider-registered event\n // and prevents respawning a child process that's already alive.\n if (impl.getStatus) {\n const status = await impl.getStatus().catch(() => null)\n if (status?.connected) {\n this.ctx.logger.info('autostart: provider already connected — skipping', {\n meta: { addonId, url: status.endpoint?.url },\n })\n continue\n }\n }\n const endpoint = await impl.start()\n this.ctx.logger.info('autostart: provider started', {\n meta: { addonId, url: endpoint.url },\n })\n } catch (err) {\n this.ctx.logger.error('autostart: provider start failed', {\n meta: {\n addonId,\n error: err instanceof Error ? err.message : String(err),\n },\n })\n }\n }\n }\n\n private async markEnabled(addonId: string, enabled: boolean): Promise<void> {\n const current = new Set(this.config.enabledProviders)\n const wasEnabled = current.has(addonId)\n if (enabled) current.add(addonId); else current.delete(addonId)\n if (wasEnabled === enabled) return\n await this.updateGlobalSettings({ enabledProviders: [...current] })\n }\n\n private resolveImpl(addonId: string): (NetworkAccessLike & NetworkAccessRegistrationMeta) | null {\n const entries = this.capabilities?.getCollectionEntries<NetworkAccessLike & NetworkAccessRegistrationMeta>(\n 'network-access',\n ) ?? []\n const found = entries.find(([id]) => id === addonId)\n return found?.[1] ?? null\n }\n\n private async listProviders(): Promise<readonly RemoteAccessProviderInfo[]> {\n const entries = this.capabilities?.getCollectionEntries<NetworkAccessLike & NetworkAccessRegistrationMeta>(\n 'network-access',\n ) ?? []\n const enabled = new Set(this.config.enabledProviders)\n const out: RemoteAccessProviderInfo[] = []\n for (const [addonId, impl] of entries) {\n let connected = false\n let endpoint: RemoteAccessProviderInfo['endpoint'] = null\n let error: string | undefined\n if (impl.getStatus) {\n try {\n const s = await impl.getStatus()\n connected = s.connected\n endpoint = s.endpoint\n error = s.error\n } catch (err) {\n error = err instanceof Error ? err.message : String(err)\n }\n }\n out.push({\n addonId,\n displayName: impl.displayName ?? addonId,\n // `enabled` is now the operator's persisted intent — orthogonal\n // to `connected` (which reflects the live tunnel state). Boot\n // tries to bring enabled→connected automatically.\n enabled: enabled.has(addonId),\n connected,\n endpoint,\n ...(error !== undefined ? { error } : {}),\n })\n }\n return out\n }\n}\n\nexport default RemoteAccessOrchestratorAddon\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"remote-access-orchestrator.addon.mjs","names":[],"sources":["../../../src/builtins/remote-access-orchestrator/enabled-providers-reconcile.ts","../../../src/builtins/remote-access-orchestrator/remote-access-orchestrator.addon.ts"],"sourcesContent":["/**\n * Pure reconciliation core for the remote-access orchestrator's durable\n * `enabledProviders` set (D8 delivery-rule fix).\n *\n * Why this module exists\n * ----------------------\n * `enabledProviders: string[]` is the operator's \"auto-start on boot\"\n * intent — a DURABLE setting. Historically it was written ONLY from two\n * fire-and-forget bus events (`NetworkTunnelStarted` / `Stopped`). A\n * dropped event (hub restart, addon crash-respawn, hub↔orchestrator\n * partition) silently diverges the persisted set from reality:\n * - dropped `Started` → tunnel running, not recorded → no auto-start\n * - dropped `Stopped` → tunnel stopped, still recorded → boot restarts\n * an operator-stopped tunnel.\n *\n * A durable setting must not be written by a lossy event. The fix is an\n * idempotent reconcile pass that pulls authoritative state via an\n * ACKNOWLEDGED RPC (`network-access` cap `getStatus`) on every provider\n * (re)connect — mirroring the kernel readiness-registry hydration via\n * `$readiness.getSnapshot`. The lifecycle events stay (still fine for\n * live UI dashboards); only the PERSISTENCE WRITE moves to the RPC pull.\n *\n * Reconcile semantics — ADD-ONLY (deliberately conservative)\n * ----------------------------------------------------------\n * `connected: true` from an acked RPC is UNAMBIGUOUS: the tunnel is up,\n * so the operator must have started it → safe to ADD to `enabledProviders`\n * (recovers a dropped `NetworkTunnelStarted` event).\n *\n * `connected: false` is AMBIGUOUS:\n * - Provider registered but not yet started (normal cold-boot state,\n * BEFORE `autoStartEnabledProviders` has called `start()`).\n * - Provider transiently down (network blip, restart in progress).\n * - Provider intentionally stopped by the operator.\n * `getStatus()` reports connectivity, NOT operator intent. Acting on\n * `connected: false` would wipe `enabledProviders` on every hub restart\n * (providers re-register ~15-20 s before `start()` is called), defeating\n * the purpose of the durable enabled-set. Therefore the reconcile pass\n * NEVER removes on `connected: false`.\n *\n * Removal of operator intent stays event-driven (`NetworkTunnelStopped`\n * → `markEnabled(id, false)`). That event is emitted synchronously by\n * the provider's own `stop()` call — it is the reliable signal that the\n * operator actually stopped the tunnel. A failed/absent RPC probe also\n * leaves the provider untouched.\n *\n * // Follow-up: covering the dropped-`Stopped` direction (a `Stopped`\n * // event lost → tunnel stopped but still in enabledProviders → boot\n * // wrongly restarts it) would require a provider-side persisted\n * // operator-intent flag that `getStatus()` surfaces. That is a larger\n * // multi-addon change (the audit's \"approach 2\") and is OUT OF SCOPE\n * // for this backstop. The current fix covers the higher-impact direction\n * // (dropped `Started` → operator's running tunnel not auto-restarted).\n *\n * The function is pure: the addon owns the RPC fan-out + the persist\n * call, so this core is unit-testable without a CapabilityRegistry.\n */\n\n/**\n * Outcome of one provider's `getStatus()` RPC probe.\n *\n * `ok: true` → the RPC was acknowledged; `connected` is authoritative.\n * `ok: false` → the RPC threw / timed out; state is unknown and the\n * provider's membership must be left as-is.\n */\nexport type ProviderProbeResult =\n | { readonly addonId: string; readonly ok: true; readonly connected: boolean }\n | { readonly addonId: string; readonly ok: false }\n\nexport interface ReconcileResult {\n /** The corrected enabled-set, sorted for stable persistence. */\n readonly nextEnabled: readonly string[]\n /** `true` when `nextEnabled` differs from the input — caller persists only then. */\n readonly changed: boolean\n /** addonIds added because an acked RPC reported them connected. */\n readonly added: readonly string[]\n /**\n * Always empty — reconcile is add-only. Removal of operator intent is\n * event-driven (`NetworkTunnelStopped`). Kept in the type so callers\n * that log `result.removed` continue to compile without changes.\n */\n readonly removed: readonly string[]\n}\n\n/**\n * Reconcile the durable `enabledProviders` set against authoritative\n * RPC probe results. Pure — no I/O. ADD-ONLY: only adds providers whose\n * acked `getStatus()` reports `connected: true`. Never removes on\n * `connected: false` — see module doc for the full rationale.\n *\n * @param currentEnabled - the persisted enabled-set before reconcile.\n * @param probes - one entry per provider whose `getStatus()` RPC was\n * attempted this pass. Providers absent from this list are untouched.\n */\nexport function reconcileEnabledProviders(\n currentEnabled: readonly string[],\n probes: readonly ProviderProbeResult[],\n): ReconcileResult {\n const next = new Set<string>(currentEnabled)\n const added: string[] = []\n\n for (const probe of probes) {\n if (!probe.ok) continue // RPC failed → state unknown → leave membership as-is\n if (probe.connected) {\n // Acked connected:true is unambiguous → operator must have started\n // this tunnel → ensure it is in the enabled-set (recovers a dropped\n // NetworkTunnelStarted event).\n if (!next.has(probe.addonId)) {\n next.add(probe.addonId)\n added.push(probe.addonId)\n }\n }\n // connected:false → ambiguous (not-yet-started / transient blip /\n // operator-stopped). Do NOT remove. Removal is event-driven only.\n }\n\n const nextEnabled = [...next].sort()\n return {\n nextEnabled,\n changed: added.length > 0,\n added,\n removed: [], // always empty — reconcile is add-only\n }\n}\n","/**\n * Remote-access orchestrator — backend-only boot-autostart service for\n * the `network-access` collection (Cloudflare Tunnel, ngrok, Tailscale, …).\n *\n * Retired its `remote-access` facade cap (2026-05-15): the admin UI now\n * talks to the `network-access` collection cap directly via generic\n * per-`addonId` routing, so this addon registers NO capability.\n *\n * What it still owns — the load-bearing logic:\n * The orchestrator owns the \"operator wants this provider running\"\n * intent — an `enabledProviders: string[]` slice in its addon-store\n * blob (BaseAddon.config). On boot we iterate the list and call\n * `provider.start()` for each enabled entry, so a tunnel set up once\n * stays up across hub restarts.\n *\n * Since start/stop no longer flow through this addon, the enabled-set\n * is kept in sync from two sources:\n * 1. `NetworkTunnelStarted` / `NetworkTunnelStopped` bus events —\n * fast, but lossy (fire-and-forget broadcasts). They drive both\n * the live UI and removal from the durable set (`Stopped` is the\n * only reliable \"operator stopped it\" signal).\n * 2. An RPC-driven ADD-ONLY RECONCILE pass (`reconcileEnabledProviders`)\n * that pulls authoritative `connected` state via the `network-access`\n * cap's `getStatus()` on every provider (re)connect. THIS covers\n * dropped `NetworkTunnelStarted` events — if `connected: true` is\n * acked, the provider is added to `enabledProviders`. It NEVER\n * removes on `connected: false` because that signal is ambiguous\n * (registered-not-yet-started vs transiently-down vs intentionally\n * stopped). Running before `autoStartEnabledProviders` is safe:\n * it can only add already-connected providers, never evict.\n */\nimport {\n BaseAddon,\n EventCategory,\n type ProviderRegistration,\n} from '@camstack/types'\nimport {\n reconcileEnabledProviders,\n type ProviderProbeResult,\n} from './enabled-providers-reconcile.js'\n\ninterface NetworkAccessLike {\n start?: () => Promise<{ url: string; hostname: string; port: number; protocol: 'http' | 'https' }>\n getStatus?: () => Promise<{\n connected: boolean\n endpoint: { url: string; hostname: string; port: number; protocol: 'http' | 'https' } | null\n error?: string\n }>\n}\n\ninterface RemoteAccessOrchestratorConfig {\n /**\n * addonIds the operator has explicitly Started. Auto-respawned on\n * boot so a tunnel set up once stays up across hub restarts.\n */\n readonly enabledProviders: readonly string[]\n}\n\nexport class RemoteAccessOrchestratorAddon extends BaseAddon<RemoteAccessOrchestratorConfig> {\n constructor() {\n super({ enabledProviders: [] })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n this.ctx.logger.info('Remote-access orchestrator initialized (backend-only)', {\n meta: { enabledCount: this.config.enabledProviders.length },\n })\n\n // Lifecycle events give the persisted set a PROMPT update — but\n // they are lossy fire-and-forget broadcasts, so they are NOT the\n // authority. The RPC-driven `reconcileAndPersist` pass below is the\n // backstop that corrects any divergence from a dropped event. The\n // emitting addonId is carried on `event.source.id` (events are\n // emitted with `source: { type: 'addon', id: ctx.id }`).\n this.ctx.eventBus?.subscribe(\n { category: EventCategory.NetworkTunnelStarted },\n (event) => { void this.onTunnelLifecycle(event.source, true) },\n )\n this.ctx.eventBus?.subscribe(\n { category: EventCategory.NetworkTunnelStopped },\n (event) => { void this.onTunnelLifecycle(event.source, false) },\n )\n\n // Defer autostart to next tick so provider registrations from\n // co-located addons settle first. `resolveImpl` reads from the\n // capabilities registry which only sees in-process / cluster-mirrored\n // providers once they've ALSO registered — small delay gives the\n // cluster bridge time to discover them on cold boot. Errors are\n // logged but never block init.\n setImmediate(() => { void this.autoStartEnabledProviders() })\n\n // Lazy retry — forked providers (cloudflare-tunnel etc) typically\n // register 15-20 s after this orchestrator boots, well past the\n // `setImmediate` above. Hook BaseAddon's `system.ready-state`\n // subscription so we re-run autoStart every time the\n // `network-access` cap transitions to ready (whichever node holds\n // it). The inner logic is idempotent + skips already-connected\n // providers.\n this.watchCapability('network-access', {\n onReady: () => { void this.autoStartEnabledProviders() },\n })\n\n // Same watch for `mesh-network`. The tailscale-ingress provider\n // registers `network-access` synchronously at boot, but `start()`\n // throws when the tailnet isn't joined yet — so the boot-time\n // autoStart call fails for tailscale ingresses if the tailscale\n // daemon hadn't logged in by then. Watching `mesh-network` here\n // re-triggers autoStart the moment the client transitions to\n // joined (manual operator login or auto-rejoin), without needing\n // a server restart. Same idempotency rules: providers already\n // connected are skipped.\n this.watchCapability('mesh-network', {\n onReady: () => { void this.autoStartEnabledProviders() },\n })\n\n // Backend-only addon — registers no capability.\n return []\n }\n\n /**\n * Maintain the persisted `enabledProviders` set from a tunnel\n * lifecycle event. `source.id` is `string | number`; `network-access`\n * providers emit with `type: 'addon'` so it is always the addonId\n * string. Non-string / non-addon sources are ignored defensively.\n */\n private async onTunnelLifecycle(\n source: { readonly type: string; readonly id: string | number },\n started: boolean,\n ): Promise<void> {\n if (source.type !== 'addon' || typeof source.id !== 'string') {\n this.ctx.logger.warn('tunnel lifecycle event with non-addon source — ignoring', {\n meta: { sourceType: source.type, sourceId: source.id, started },\n })\n return\n }\n await this.markEnabled(source.id, started)\n }\n\n /**\n * Probe every locally-visible `network-access` provider's\n * `getStatus()` over RPC and reconcile the durable `enabledProviders`\n * set against the result. This is the D8 backstop for dropped\n * `NetworkTunnelStarted` events: if `connected: true` is acked, the\n * provider is added to `enabledProviders`. ADD-ONLY — never removes on\n * `connected: false` (see enabled-providers-reconcile.ts for rationale).\n *\n * `getStatus()` is an acknowledged cap RPC — a transport blip surfaces\n * as a rejected promise (recorded as `ok: false`, membership left\n * untouched), never as a silent lossy write. Runs on every provider\n * (re)connect via the `watchCapability` hooks, mirroring the kernel\n * readiness-registry hydration on `$node.connected`. Safe to call\n * before `autoStartEnabledProviders`: add-only semantics mean it cannot\n * wipe the enabled-set even when providers are registered-not-yet-started.\n */\n private async reconcileAndPersist(): Promise<void> {\n const entries = this.capabilities?.getCollectionEntries<NetworkAccessLike>(\n 'network-access',\n ) ?? []\n if (entries.length === 0) return\n\n const probes: ProviderProbeResult[] = await Promise.all(\n entries.map(async ([addonId, impl]): Promise<ProviderProbeResult> => {\n if (!impl.getStatus) {\n // Provider can't report status — treat as unknown, leave as-is.\n return { addonId, ok: false }\n }\n try {\n const status = await impl.getStatus()\n return { addonId, ok: true, connected: status.connected }\n } catch (err) {\n this.ctx.logger.warn('reconcile: getStatus RPC failed — leaving membership as-is', {\n meta: { addonId, error: err instanceof Error ? err.message : String(err) },\n })\n return { addonId, ok: false }\n }\n }),\n )\n\n const result = reconcileEnabledProviders(this.config.enabledProviders, probes)\n if (!result.changed) return\n\n this.ctx.logger.info('reconcile: corrected enabledProviders from RPC-pulled state', {\n meta: { added: result.added, removed: result.removed },\n })\n await this.updateGlobalSettings({ enabledProviders: result.nextEnabled })\n }\n\n private async autoStartEnabledProviders(): Promise<void> {\n // RPC-driven ADD-ONLY reconcile first: recovers any dropped\n // `NetworkTunnelStarted` events by adding providers that are already\n // connected. Cannot evict — safe to run before start() calls.\n await this.reconcileAndPersist()\n\n const ids = this.config.enabledProviders\n if (ids.length === 0) return\n this.ctx.logger.info('Auto-starting enabled remote-access providers', {\n meta: { addonIds: [...ids] },\n })\n for (const addonId of ids) {\n try {\n const impl = this.resolveImpl(addonId)\n if (!impl?.start) {\n // Provider isn't loaded yet (worker bridge still hydrating)\n // OR it doesn't implement start. Log at warn level — the\n // `watchCapability` subscriptions above retry as soon as the\n // provider appears.\n this.ctx.logger.warn('autostart: provider not ready or unsupported', {\n meta: { addonId, hasImpl: !!impl, hasStart: !!impl?.start },\n })\n continue\n }\n // Idempotent: skip when the provider is already connected.\n // Avoids spamming start() on every ready-state event and\n // prevents respawning a child process that's already alive.\n if (impl.getStatus) {\n const status = await impl.getStatus().catch(() => null)\n if (status?.connected) {\n this.ctx.logger.info('autostart: provider already connected — skipping', {\n meta: { addonId, url: status.endpoint?.url },\n })\n continue\n }\n }\n const endpoint = await impl.start()\n this.ctx.logger.info('autostart: provider started', {\n meta: { addonId, url: endpoint.url },\n })\n } catch (err) {\n this.ctx.logger.error('autostart: provider start failed', {\n meta: {\n addonId,\n error: err instanceof Error ? err.message : String(err),\n },\n })\n }\n }\n }\n\n private async markEnabled(addonId: string, enabled: boolean): Promise<void> {\n const current = new Set(this.config.enabledProviders)\n const wasEnabled = current.has(addonId)\n if (enabled) current.add(addonId); else current.delete(addonId)\n if (wasEnabled === enabled) return\n this.ctx.logger.info('remote-access intent updated', {\n meta: { addonId, enabled },\n })\n await this.updateGlobalSettings({ enabledProviders: [...current] })\n }\n\n private resolveImpl(addonId: string): NetworkAccessLike | null {\n const entries = this.capabilities?.getCollectionEntries<NetworkAccessLike>(\n 'network-access',\n ) ?? []\n const found = entries.find(([id]) => id === addonId)\n return found?.[1] ?? null\n }\n}\n\nexport default RemoteAccessOrchestratorAddon\n"],"mappings":"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"}
|
|
@@ -44,9 +44,7 @@ var SnapshotAddon = class extends _camstack_types.BaseAddon {
|
|
|
44
44
|
getDeviceLiveContribution: async () => null,
|
|
45
45
|
applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
|
|
46
46
|
getStatus: async (input) => this.getStatus(input.deviceId)
|
|
47
|
-
}
|
|
48
|
-
kind: "wrapper",
|
|
49
|
-
defaultActive: true
|
|
47
|
+
}
|
|
50
48
|
}];
|
|
51
49
|
}
|
|
52
50
|
async onShutdown() {
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.js","names":[],"sources":["../../../src/builtins/snapshot/snapshot.addon.ts"],"sourcesContent":["import { execFile } from 'node:child_process'\nimport { DeviceFeature, DeviceType, snapshotCapability } from '@camstack/types'\nimport type { ConfigUISchemaWithValues, InferProvider, ProfileSlot, ProviderRegistration } from '@camstack/types'\nimport { BaseAddon, errMsg, streamQualityLabel } from '@camstack/types'\n\ntype ISnapshotProvider = InferProvider<typeof snapshotCapability>\ntype SnapshotImage = { base64: string; contentType: string }\n\n/**\n * Per-device snapshot settings stored via `ctx.settings.writeDeviceStore`.\n * Kept as a plain interface (not Zod) because the value set is tiny and\n * `snapshot` isn't worth the extra schema plumbing — runtime coercion\n * happens inline in `getDeviceStoreTyped` below.\n */\ninterface SnapshotDeviceSettings {\n /** Quality profile to prefer when grabbing a snapshot (e.g. `high`, `mid`). Empty = auto. */\n readonly snapshotStreamId?: string\n /** If true, log extra detail (timing, selected stream, error paths). */\n readonly snapshotDebug?: boolean\n /**\n * Maximum acceptable cache age in seconds. Below it the wrapper\n * serves the cached image; above it (or on `force`) it falls\n * through to the native (which on battery cams wakes the\n * firmware). When unset the per-device default applies:\n * `BATTERY_DEFAULT_MAX_AGE_S` for battery cams and\n * `NON_BATTERY_DEFAULT_MAX_AGE_S` for everything else.\n */\n readonly snapshotMaxAgeS?: number\n}\n\n/** Default cache window for non-battery cams (seconds). 10s feels live. */\nconst NON_BATTERY_DEFAULT_MAX_AGE_S = 10\n/** Default cache window for battery cams (seconds). 1h ≈ \"don't wake the cam unless asked\". */\nconst BATTERY_DEFAULT_MAX_AGE_S = 3600\n\ninterface CacheEntry {\n readonly data: SnapshotImage\n readonly ts: number\n /** Stream id resolved at capture time — 'high' | 'mid' | 'low' | custom | null. */\n readonly streamId: string | null\n}\n\ninterface SnapshotAddonConfig {\n /**\n * Last-resort cache age (ms): if every live capture path fails\n * (native + stream-broker fallback) AND a stale entry is older\n * than this, the wrapper still returns the stale image rather\n * than `null`. Keeps the UI from going blank during transient\n * camera unreachability. Per-device freshness is governed by\n * `snapshotMaxAgeS` instead.\n */\n readonly staleTtlMs: number\n}\n\n/**\n * SnapshotAddon — wrapper over the `snapshot` capability.\n *\n * Activated per-device (toggleable by user; default active). When active,\n * caches fresh snapshots in memory. On cache miss, delegates to the native\n * provider for this device via `ctx.getNativeProvider(snapshotCapability, id)`.\n *\n * No silent fallback — if the native fails, propagate the error up. Stale\n * cache is returned ONLY when the native throws AND a stale entry exists,\n * and the failure is logged so the fallback is never silent.\n *\n * Frame-grab fallback: when the native provider is absent (or returns null /\n * throws), we ask the `stream-broker` capability for the device's RTSP\n * restream URL and pipe one JPEG out of ffmpeg. stream-broker intentionally\n * does NOT expose a `grabFrame` method — the orchestration (native-first,\n * ffmpeg fallback, caching) is this addon's concern; stream-broker only\n * publishes stream endpoints.\n */\nexport class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {\n private readonly cache = new Map<number, CacheEntry>()\n\n constructor() {\n super({\n staleTtlMs: 60_000,\n })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n this.ctx.logger.info('Snapshot wrapper initialized')\n const provider: ISnapshotProvider = {\n getSnapshot: (input) => this.getSnapshot(input),\n invalidateCache: (input) => this.invalidateCache(input),\n // DeviceSettingsContribution surface — numeric deviceId end-to-end.\n getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),\n getDeviceLiveContribution: async () => null,\n applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),\n // Status surface — derives a diagnostic snapshot from the cache\n // bookkeeping (lastCapturedAt, cacheAgeMs, lastBytes, lastStreamId).\n // Returns null when the device has never been captured.\n getStatus: async (input) => this.getStatus(input.deviceId),\n }\n return [{\n capability: snapshotCapability,\n provider,\n kind: 'wrapper',\n defaultActive: true,\n }]\n }\n\n protected async onShutdown(): Promise<void> {\n this.cache.clear()\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [{\n id: 'snapshot-cache',\n title: 'Snapshot Cache',\n description: 'Stale fallback when live capture fails. Per-device freshness is configured under Device → Snapshot → Max cache age.',\n columns: 1,\n fields: [\n this.field({\n type: 'number', key: 'staleTtlMs', label: 'Stale fallback TTL (ms)',\n description: 'If live capture fails, cached snapshot younger than this is still returned.',\n min: 0, max: 3_600_000, step: 1_000, default: 60_000,\n }),\n ],\n }],\n })\n }\n\n // ── Capability methods ────────────────────────────────────────────────\n\n private async getSnapshot(input: { deviceId: number; streamId?: string; force?: boolean }): Promise<SnapshotImage | null> {\n const { deviceId, force } = input\n // Pull device name + battery feature with a single device-scoped\n // query. `getDevice` is cheaper than `listAll().find(...)` (one row\n // returned, no client-side filter) and surfaces the same fields.\n const meta = await this.lookupDeviceMeta(deviceId)\n const deviceName = meta?.name\n const isBatteryDevice = meta?.isBattery ?? false\n const log = this.ctx.logger.withTags({ deviceId, ...(deviceName ? { deviceName } : {}) })\n const now = Date.now()\n const hit = this.cache.get(deviceId)\n\n // Honour the per-device stored preference unless the caller specified\n // an explicit streamId. `snapshotDebug` just flips log verbosity —\n // read once per call so the flag can be toggled without restart.\n const prefs: SnapshotDeviceSettings = await this.readDeviceSettings(deviceId).catch(() => ({}))\n const rawPref = prefs.snapshotStreamId\n const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== 'auto' ? rawPref : undefined)\n if (prefs.snapshotDebug) {\n log.info('debug on', { tags: { deviceId }, meta: { stream: effectiveStreamId ?? 'auto' } })\n }\n\n // 1. Effective max-age check. A single per-device knob replaces\n // the legacy fresh/battery/sleeping cascade — defaults are 10s\n // for non-battery cams (matches the live-feel UI poll cadence)\n // and 1h for battery cams (avoids gratuitous wake-ups). The\n // operator can override via the per-device `snapshotMaxAgeS`\n // setting; a `force=true` call (UI refresh button) bypasses\n // the gate entirely. Behaviour matrix:\n //\n // hit + ageMs < maxAgeMs + !force → serve cache\n // hit + sleeping + ageMs < maxAgeMs + !force → serve cache (no wake)\n // hit + sleeping + ageMs >= maxAgeMs + !force → cache is stale,\n // let the native\n // decide whether\n // to wake (the\n // native cap layer\n // handles the\n // \"are we awake?\"\n // question)\n // no hit → fall through (will wake)\n // force === true → fall through (will wake)\n const defaultMaxAgeS = isBatteryDevice ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S\n const effectiveMaxAgeMs = (typeof prefs.snapshotMaxAgeS === 'number' && prefs.snapshotMaxAgeS >= 0\n ? prefs.snapshotMaxAgeS\n : defaultMaxAgeS) * 1_000\n if (!force && hit && now - hit.ts < effectiveMaxAgeMs) {\n if (prefs.snapshotDebug) {\n log.debug('snapshot: cache hit', {\n tags: { deviceId },\n meta: { ageMs: now - hit.ts, maxAgeMs: effectiveMaxAgeMs, isBattery: isBatteryDevice },\n })\n }\n return hit.data\n }\n\n // 2. Native provider (per-device, registered by the device-class addon).\n // Missing native is expected for cameras without a vendor snapshot\n // endpoint (e.g. a plain RTSP camera with no `snapshotUrl` configured).\n // We catch and fall through to the stream-broker ffmpeg path.\n let nativeError: unknown = null\n let nativeAbsent = false\n try {\n const native = this.ctx.getNativeProvider(snapshotCapability, deviceId)\n if (native) {\n const result = await native.getSnapshot(input)\n if (result) {\n this.cache.set(deviceId, { data: result, ts: now, streamId: effectiveStreamId ?? null })\n return result\n }\n } else {\n nativeAbsent = true\n }\n } catch (err) {\n const msg = errMsg(err)\n if (isAbsentNativeError(msg)) {\n nativeAbsent = true\n log.debug('native snapshot absent', { tags: { deviceId }, meta: { error: msg } })\n } else {\n nativeError = err\n log.warn('native snapshot failed', { tags: { deviceId }, meta: { error: msg } })\n }\n }\n\n // 3. Frame-grab fallback via stream-broker's RTSP restream. Passes\n // the effective streamId (explicit caller or stored preference) so\n // the fallback hits the same stream the user asked for instead of\n // always grabbing `main`.\n //\n // Battery-cam guard: skip the broker fallback only when the native\n // was ABSENT for a battery device — that's the case where invoking\n // the broker would be the first wake-up signal. If the native was\n // present (whether it succeeded with null, threw, or returned a\n // value), the camera is already being talked to (and likely awake);\n // the additional broker dial costs nothing extra. When a streaming\n // broker is already active, the fallback is unconditionally free\n // (someone is watching), so allow it regardless.\n const skipBrokerForBattery = isBatteryDevice\n && nativeAbsent\n && !(await this.hasStreamingBrokerForDevice(deviceId))\n if (!skipBrokerForBattery) {\n try {\n const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId)\n if (fallback) {\n this.cache.set(deviceId, { data: fallback, ts: now, streamId: effectiveStreamId ?? null })\n return fallback\n }\n } catch (err) {\n log.warn('stream-broker snapshot fallback failed', { tags: { deviceId }, meta: { error: errMsg(err) } })\n }\n } else {\n log.debug('snapshot: skipping broker fallback — battery device with absent native and no streaming broker', {\n tags: { deviceId },\n })\n }\n\n // 4. Stale cache — last-resort \"don't go blank\" path.\n //\n // If ALL live paths failed and we have any cached entry, return\n // it and log the age. The UI keeps showing the last known frame\n // rather than a broken-image icon. The freshness gate at step 1\n // (per-device `snapshotMaxAgeS`) is the cap that decides whether\n // the cache is \"fresh enough\" in normal flow; this branch only\n // runs when every live source refused.\n if (hit) {\n const ageMs = now - hit.ts\n if (ageMs > this.config.staleTtlMs) {\n log.warn('snapshot: all live paths failed — serving stale cache', { tags: { deviceId }, meta: { ageMs } })\n }\n return hit.data\n }\n\n // Nothing produced a frame and nothing in cache. If the native provider\n // threw a real error (not just \"no provider registered for this\n // device\"), surface it so callers see the cause. If the native was\n // simply absent (camera has no snapshotUrl + no wrapper active),\n // return null so the UI renders \"no image yet\" instead of a 500.\n if (nativeError) throw nativeError\n if (nativeAbsent) return null\n return null\n }\n\n /**\n * Tell apart \"native provider isn't registered for this device\" from\n * \"native provider ran and threw a real error\". The former is the steady\n * state for cameras without a vendor snapshot endpoint and should not\n * propagate as a 500; the latter should.\n */\n\n /**\n * Pull one JPEG from the device's stream-broker RTSP restream using\n * a short-lived ffmpeg invocation.\n *\n * Stream selection strategy (picks the broker that won't stall):\n * 1. Explicit `preferredStreamId` (user set in per-device settings)\n * — always honoured, even if currently idle. Operator choice\n * wins.\n * 2. Auto: highest-quality broker currently in `streaming` state.\n * This is the whole point — prefer the stream that has active\n * subscribers (usually `low` for detection, but `mid`/`high`\n * if WebRTC is watching) so ffmpeg hits a warm pipe and\n * doesn't race the broker's resume.\n * 3. Fallback: highest-quality enabled entry regardless of status\n * (will wake a suspended broker — retry-guarded against the\n * cold-start error).\n *\n * The stream-broker auto-suspends idle streams on the \"no demand\"\n * signal; snapshots used to default to `high` which was often the\n * first to go idle, racing every snapshot with a broker resume.\n * Now we ask the orchestrator of streams which one is warm and grab\n * from there.\n */\n private async grabFrameFromBroker(\n deviceId: number,\n preferredStreamId?: string,\n ): Promise<SnapshotImage | null> {\n const dev = await this.ctx.fetchDevice(deviceId)\n const prefix = `${deviceId}/`\n\n // Device-scoped fetch: broker filters by deviceId server-side, no\n // cluster-wide list + client-side filter needed. Profile slot\n // statuses come from the same per-device facade for the warmth\n // ranking below.\n const [deviceEntries, profileSlots] = await Promise.all([\n dev.cameraStreams?.getRtspEntries({}) ?? [],\n dev.cameraStreams?.getBrokerStreams({}) ?? [],\n ])\n const usable = deviceEntries.filter(e => e.enabled && !!e.url)\n if (usable.length === 0) return null\n\n // 1. Explicit preference — try it first without status gating.\n if (preferredStreamId && preferredStreamId !== 'auto') {\n const explicit = usable.find(e => e.brokerId === `${prefix}${preferredStreamId}`)\n if (explicit) {\n const grabbed = await this.runGrabWithResumeRetry(explicit.url, deviceId)\n if (grabbed) return grabbed\n // Explicit pick failed even after retry — fall through to auto\n // rather than give up. Keeps the snapshot useful when the\n // operator's preferred stream is momentarily unreachable.\n this.ctx.logger.debug('grabFrame: explicit stream failed — falling back to auto', { meta: { preferredStreamId } })\n }\n }\n\n // Rank entries by quality (high → mid → low → other).\n const ranked = [...usable].sort(\n (a, b) => qualityRank(b.brokerId, prefix) - qualityRank(a.brokerId, prefix),\n )\n\n // Per-device slot status lookup keyed by brokerId, sourced from\n // the same `cameraStreams` facade — `status: 'streaming'` means the\n // broker is warm and an ffmpeg grab is free of dial overhead.\n const statusByBrokerId = new Map<string, ProfileSlot['status']>()\n for (const slot of profileSlots) statusByBrokerId.set(slot.brokerId, slot.status)\n const statuses = ranked.map(e => ({ entry: e, status: statusByBrokerId.get(e.brokerId) ?? 'idle' as const }))\n\n // 2. Highest-quality broker currently `streaming`.\n const warm = statuses.find(s => s.status === 'streaming')\n if (warm) {\n const grabbed = await this.runGrabWithResumeRetry(warm.entry.url, deviceId)\n if (grabbed) return grabbed\n }\n\n // 3. Final fallback: wake whichever broker has the highest quality.\n // The retry handles the resume race window.\n for (const { entry } of statuses) {\n const grabbed = await this.runGrabWithResumeRetry(entry.url, deviceId).catch(() => null)\n if (grabbed) return grabbed\n }\n return null\n }\n\n /**\n * Ffmpeg grab with one retry on the broker-cold-start error\n * signature. Covers the window between \"client connected\" and\n * \"first keyframe\" when a suspended broker resumes.\n */\n private async runGrabWithResumeRetry(url: string, deviceId: number): Promise<SnapshotImage | null> {\n let buf: Buffer\n try {\n buf = await runFfmpegFrameGrab(url, 15000)\n } catch (err) {\n const msg = errMsg(err)\n if (isBrokerColdError(msg)) {\n this.ctx.logger.debug('grabFrame: broker-resume race — retrying in 1500ms', { tags: { deviceId } })\n await new Promise((r) => setTimeout(r, 1500))\n buf = await runFfmpegFrameGrab(url, 15000)\n } else {\n throw err\n }\n }\n if (buf.length === 0) return null\n return { base64: buf.toString('base64'), contentType: 'image/jpeg' }\n }\n\n private async invalidateCache(input: { deviceId: number }): Promise<void> {\n this.cache.delete(input.deviceId)\n }\n\n /**\n * Non-throwing probe of the device's battery cap. Returns true only\n * when a battery native is registered AND its current status says\n * `sleeping: true`. Any error (no provider, native absent, getStatus\n * missing, RPC timeout) is swallowed and treated as \"awake\" — we'd\n * rather pay a wake-up than strand the caller on a cache that's\n * semantically stale. Debug-logged for observability.\n */\n /**\n * True when at least one of the device's brokers is actively\n * streaming (status === 'streaming'). Used by the battery-cam guard\n * around `grabFrameFromBroker` to allow the fallback ONLY when\n * grabbing a frame is free (a consumer is already keeping the\n * stream warm). When everything is suspended, the fallback would\n * dial the camera and wake it — defeats the sleeping cache.\n */\n private async hasStreamingBrokerForDevice(deviceId: number): Promise<boolean> {\n // `cameraStreams` is the device-scoped facade over the system\n // `stream-broker` cap — `getBrokerStreams` returns this device's\n // profile slots only (no cluster-wide scan + filter).\n try {\n const dev = await this.ctx.fetchDevice(deviceId)\n const slots = await dev.cameraStreams?.getBrokerStreams({}) ?? []\n return slots.some(s => s.status === 'streaming')\n } catch {\n return false\n }\n }\n\n /**\n * Diagnostic status for the `status` auto-injected cap method. Reports\n * the cache bookkeeping for this device — when the last snapshot was\n * captured, how stale the cached image is, its size, and which stream\n * was used. Returns null when the device has never been captured\n * (cache miss) since the addon started.\n */\n private async getStatus(deviceId: number): Promise<{\n lastCapturedAt: number | null\n cacheAgeMs: number | null\n lastBytes: number | null\n lastStreamId: string | null\n } | null> {\n const hit = this.cache.get(deviceId)\n if (!hit) return null\n return {\n lastCapturedAt: hit.ts,\n cacheAgeMs: Date.now() - hit.ts,\n lastBytes: hit.data.base64.length, // approx bytes = base64 length * 3/4, but length is stable enough\n lastStreamId: hit.streamId,\n }\n }\n\n // ── DeviceSettingsContribution ────────────────────────────────────────\n //\n // Snapshot carries two per-device knobs:\n // - `snapshotStreamId` — which stream to prefer when grabbing frames\n // (empty = auto / default \"main\")\n // - `snapshotDebug` — extra logging for troubleshooting snapshot paths\n //\n // Storage uses the addon's own per-device store (`ctx.settings\n // .writeDeviceStore`), not the device's config. The schema isn't\n // captured in a Zod object: the keys are optional and the UI layer\n // validates shapes.\n\n private async readDeviceSettings(deviceId: number): Promise<SnapshotDeviceSettings> {\n if (!this.ctx.settings) return {}\n const raw = await this.ctx.settings.readDeviceStore(deviceId)\n const rawAge = raw['snapshotMaxAgeS']\n const maxAgeS = typeof rawAge === 'number' && rawAge >= 0 && Number.isFinite(rawAge)\n ? rawAge\n : undefined\n return {\n snapshotStreamId: typeof raw['snapshotStreamId'] === 'string' ? raw['snapshotStreamId'] : undefined,\n snapshotDebug: raw['snapshotDebug'] === true,\n ...(maxAgeS !== undefined ? { snapshotMaxAgeS: maxAgeS } : {}),\n }\n }\n\n private async buildDeviceSettingsContribution(deviceId: number): Promise<ConfigUISchemaWithValues | null> {\n // Camera-only — snapshot settings are meaningless on Lights /\n // Switches / Sensors / Buttons, and surfacing the section there\n // produces ghost top-tabs (the device-detail UI builds the tab\n // list off the contribution sections). Source-side gate keeps the\n // policy with the addon that owns the cap; the admin-ui no longer\n // needs the symmetric `device-section-policy` allow-list.\n const meta = await this.lookupDeviceMeta(deviceId)\n if (meta && meta.type !== DeviceType.Camera) return null\n const current = await this.readDeviceSettings(deviceId)\n const streamOptions = await this.getStreamOptions(deviceId)\n const isBattery = await this.isDeviceBattery(deviceId)\n const defaultMaxAgeS = isBattery ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S\n return {\n sections: [{\n id: 'snapshot-preferences',\n title: 'Snapshot',\n tab: 'snapshot',\n order: 60,\n fields: [\n {\n type: 'select' as const,\n key: 'snapshotStreamId',\n label: 'Preferred stream',\n description: 'Stream used when grabbing a snapshot',\n options: streamOptions,\n required: true,\n value: current.snapshotStreamId || 'auto',\n },\n {\n type: 'number' as const,\n key: 'snapshotMaxAgeS',\n label: 'Max cache age (s)',\n description:\n `Serve cached snapshot up to this age before re-capturing. Default ${defaultMaxAgeS}s for ` +\n `${isBattery ? 'battery cams (avoids gratuitous wake-ups)' : 'non-battery cams (live-feel)'}. ` +\n 'The UI refresh button always forces a fresh capture regardless of this value.',\n min: 0,\n max: 24 * 3600,\n step: 1,\n value: current.snapshotMaxAgeS ?? defaultMaxAgeS,\n },\n {\n type: 'boolean' as const,\n key: 'snapshotDebug',\n label: 'Debug logging',\n description: 'Log stream selection and timing details for this device.',\n value: current.snapshotDebug ?? false,\n },\n ],\n }],\n }\n }\n\n /**\n * Single-trip device lookup against device-manager. Returns the\n * fields the wrapper actually consults — name (logging) + battery\n * flag (cache window + broker-fallback gate). Sourced from the\n * device-manager registry rather than the battery cap so the answer\n * survives a momentarily-unreachable provider (the very condition\n * we're trying to be resilient to).\n *\n * Logged at debug + null return on failure: every call site already\n * has a sensible fallback path (cache hit, conservative default, …),\n * so we don't want a transient device-manager hiccup to throw.\n */\n private async lookupDeviceMeta(\n deviceId: number,\n ): Promise<{ name?: string; isBattery: boolean; type?: DeviceType } | null> {\n const api = this.ctx.api\n if (!api) return null\n try {\n const found = await api.deviceManager.getDevice.query({ deviceId })\n if (!found) return null\n const features = (found.features as readonly string[] | undefined) ?? []\n const rawType = (found as { type?: string }).type\n return {\n ...(found.name ? { name: found.name } : {}),\n isBattery: features.includes(DeviceFeature.BatteryOperated),\n ...(rawType ? { type: rawType as DeviceType } : {}),\n }\n } catch (err) {\n this.ctx.logger.debug('deviceManager.getDevice failed during snapshot', {\n tags: { deviceId },\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n return null\n }\n }\n\n /** Settings-UI helper — battery flag drives the default max-age in the field description. */\n private async isDeviceBattery(deviceId: number): Promise<boolean> {\n return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false\n }\n\n private async getStreamOptions(deviceId: number): Promise<Array<{ value: string; label: string }>> {\n const prefix = `${deviceId}/`\n try {\n const dev = await this.ctx.fetchDevice(deviceId)\n const entries = await dev.cameraStreams?.getRtspEntries({}) ?? []\n const streamIds = entries\n .filter(e => e.enabled)\n .map(e => e.brokerId.slice(prefix.length))\n return [\n { value: 'auto', label: 'Auto' },\n ...streamIds.map(id => ({ value: id, label: streamQualityLabel(id) })),\n ]\n } catch (err) {\n this.ctx.logger.error('getStreamOptions failed', { tags: { deviceId }, meta: { error: errMsg(err) } })\n return [{ value: 'auto', label: 'Auto' }]\n }\n }\n\n private async saveDeviceSettingsPatch(\n deviceId: number,\n patch: Record<string, unknown>,\n ): Promise<{ success: true }> {\n if (!this.ctx.settings) {\n throw new Error('[snapshot] settings store unavailable — cannot persist per-device settings')\n }\n const current = await this.ctx.settings.readDeviceStore(deviceId)\n const next: Record<string, unknown> = { ...current }\n if ('snapshotStreamId' in patch) {\n const v = patch['snapshotStreamId']\n next['snapshotStreamId'] = typeof v === 'string' && v.trim().length > 0 ? v.trim() : ''\n }\n if ('snapshotDebug' in patch) {\n next['snapshotDebug'] = patch['snapshotDebug'] === true\n }\n if ('snapshotMaxAgeS' in patch) {\n const v = patch['snapshotMaxAgeS']\n // Persist a finite non-negative number; anything else (null,\n // undefined, NaN, negative) falls back to the per-device\n // default at read time.\n if (typeof v === 'number' && Number.isFinite(v) && v >= 0) {\n next['snapshotMaxAgeS'] = v\n } else {\n delete next['snapshotMaxAgeS']\n }\n }\n await this.ctx.settings.writeDeviceStore(deviceId, next)\n // Drop the cache so the next `getSnapshot` applies the new stream/debug flags.\n this.cache.delete(deviceId)\n return { success: true as const }\n }\n}\n\nfunction isAbsentNativeError(msg: string): boolean {\n return msg.includes('no provider for') || msg.includes('no native provider for capability')\n}\n\n/**\n * Quality ordering for broker picker: `high` > `mid` > `low` > other.\n * The streamId is the suffix after `${deviceId}/` in the brokerId.\n * Unknown labels fall through to 0 so they land at the bottom of the\n * preference list without crashing.\n */\nfunction qualityRank(brokerId: string, prefix: string): number {\n const streamId = brokerId.startsWith(prefix) ? brokerId.slice(prefix.length) : brokerId\n const normalized = streamId.toLowerCase()\n if (normalized.includes('high') || normalized === 'main' || normalized === 'hd') return 3\n if (normalized.includes('mid') || normalized === 'medium') return 2\n if (normalized.includes('low') || normalized === 'sub' || normalized === 'sd') return 1\n return 0\n}\n\n/**\n * Detect ffmpeg failure signatures that indicate the broker wasn't\n * streaming yet when we tried to read its RTSP restream — a race\n * window that closes as soon as the broker lands its first keyframe.\n * Used by `grabFrameFromBroker` to retry once instead of giving up.\n */\nfunction isBrokerColdError(msg: string): boolean {\n return msg.includes('Invalid data found when processing input')\n || msg.includes('Error opening input')\n || msg.includes('Connection refused')\n || msg.includes('No route to host')\n}\n\nfunction runFfmpegFrameGrab(url: string, timeoutMs: number): Promise<Buffer> {\n return new Promise<Buffer>((resolve, reject) => {\n // ── IDR-first snapshot ────────────────────────────────────────────────\n //\n // `-skip_frame nokey` decodes only keyframes — the first frame handed\n // to the MJPEG encoder is guaranteed to be a complete IDR with all\n // references present, so the JPEG can't come out as a half-decoded\n // delta (the \"green stripe\" a non-keyframe decode produces).\n //\n // `-fflags +discardcorrupt` drops corrupt packets that slipped in\n // before sync — otherwise they propagate as green/gray smears into\n // the decoded output.\n //\n // No `-analyzeduration` / `-probesize` override: the old values\n // (5_000_000 µs = 5 s) forced ffmpeg to buffer 5 s of RTP before\n // decoding even when a keyframe was already in hand, which stacked\n // with a cold stream-broker's upstream handshake and pushed the\n // snapshot past the caller's ffmpeg timeout. Defaults are fine —\n // `-skip_frame nokey` + `-frames:v 1` already bound the work to a\n // single keyframe.\n const child = execFile(\n 'ffmpeg',\n [\n '-loglevel', 'error',\n '-rtsp_transport', 'tcp',\n '-fflags', '+discardcorrupt',\n '-skip_frame', 'nokey',\n '-i', url,\n '-vf', 'select=eq(pict_type\\\\,I)',\n '-vsync', 'vfr',\n '-frames:v', '1',\n '-q:v', '3',\n '-f', 'image2pipe',\n '-vcodec', 'mjpeg',\n 'pipe:1',\n ],\n { encoding: 'buffer', maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs },\n (err, stdout, stderr) => {\n if (err) {\n // `execFile`'s default Error carries \"Command failed: …\" plus a\n // `code`/`signal` that Node fills in. ffmpeg with `-loglevel\n // error` is silent on RTSP-level failures; when the parent\n // kills the child on timeout, `killed === true` and stderr is\n // empty. Surface both sides so the log tells the real story.\n const errWithMeta = err as (Error & { code?: string | number; signal?: string | null; killed?: boolean })\n const stderrText = Buffer.isBuffer(stderr) ? stderr.toString('utf8').trim() : String(stderr ?? '').trim()\n const parts: string[] = [err instanceof Error ? err.message : String(err)]\n if (errWithMeta.killed) parts.push('killed=true')\n if (errWithMeta.code !== undefined) parts.push(`code=${String(errWithMeta.code)}`)\n if (errWithMeta.signal) parts.push(`signal=${errWithMeta.signal}`)\n if (stderrText) parts.push(`stderr: ${stderrText.slice(0, 500)}`)\n reject(new Error(parts.join(' — ')))\n return\n }\n resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout))\n },\n )\n child.on('error', (e: Error) => reject(e))\n })\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"index.js","names":[],"sources":["../../../src/builtins/snapshot/snapshot.addon.ts"],"sourcesContent":["import { execFile } from 'node:child_process'\nimport { DeviceFeature, DeviceType, snapshotCapability } from '@camstack/types'\nimport type { ConfigUISchemaWithValues, InferProvider, ProfileSlot, ProviderRegistration } from '@camstack/types'\nimport { BaseAddon, errMsg, streamQualityLabel } from '@camstack/types'\n\ntype ISnapshotProvider = InferProvider<typeof snapshotCapability>\ntype SnapshotImage = { base64: string; contentType: string }\n\n/**\n * Per-device snapshot settings stored via `ctx.settings.writeDeviceStore`.\n * Kept as a plain interface (not Zod) because the value set is tiny and\n * `snapshot` isn't worth the extra schema plumbing — runtime coercion\n * happens inline in `getDeviceStoreTyped` below.\n */\ninterface SnapshotDeviceSettings {\n /** Quality profile to prefer when grabbing a snapshot (e.g. `high`, `mid`). Empty = auto. */\n readonly snapshotStreamId?: string\n /** If true, log extra detail (timing, selected stream, error paths). */\n readonly snapshotDebug?: boolean\n /**\n * Maximum acceptable cache age in seconds. Below it the wrapper\n * serves the cached image; above it (or on `force`) it falls\n * through to the native (which on battery cams wakes the\n * firmware). When unset the per-device default applies:\n * `BATTERY_DEFAULT_MAX_AGE_S` for battery cams and\n * `NON_BATTERY_DEFAULT_MAX_AGE_S` for everything else.\n */\n readonly snapshotMaxAgeS?: number\n}\n\n/** Default cache window for non-battery cams (seconds). 10s feels live. */\nconst NON_BATTERY_DEFAULT_MAX_AGE_S = 10\n/** Default cache window for battery cams (seconds). 1h ≈ \"don't wake the cam unless asked\". */\nconst BATTERY_DEFAULT_MAX_AGE_S = 3600\n\ninterface CacheEntry {\n readonly data: SnapshotImage\n readonly ts: number\n /** Stream id resolved at capture time — 'high' | 'mid' | 'low' | custom | null. */\n readonly streamId: string | null\n}\n\ninterface SnapshotAddonConfig {\n /**\n * Last-resort cache age (ms): if every live capture path fails\n * (native + stream-broker fallback) AND a stale entry is older\n * than this, the wrapper still returns the stale image rather\n * than `null`. Keeps the UI from going blank during transient\n * camera unreachability. Per-device freshness is governed by\n * `snapshotMaxAgeS` instead.\n */\n readonly staleTtlMs: number\n}\n\n/**\n * SnapshotAddon — wrapper over the `snapshot` capability.\n *\n * Activated per-device (toggleable by user; default active). When active,\n * caches fresh snapshots in memory. On cache miss, delegates to the native\n * provider for this device via `ctx.getNativeProvider(snapshotCapability, id)`.\n *\n * No silent fallback — if the native fails, propagate the error up. Stale\n * cache is returned ONLY when the native throws AND a stale entry exists,\n * and the failure is logged so the fallback is never silent.\n *\n * Frame-grab fallback: when the native provider is absent (or returns null /\n * throws), we ask the `stream-broker` capability for the device's RTSP\n * restream URL and pipe one JPEG out of ffmpeg. stream-broker intentionally\n * does NOT expose a `grabFrame` method — the orchestration (native-first,\n * ffmpeg fallback, caching) is this addon's concern; stream-broker only\n * publishes stream endpoints.\n */\nexport class SnapshotAddon extends BaseAddon<SnapshotAddonConfig> {\n private readonly cache = new Map<number, CacheEntry>()\n\n constructor() {\n super({\n staleTtlMs: 60_000,\n })\n }\n\n protected async onInitialize(): Promise<ProviderRegistration[]> {\n this.ctx.logger.info('Snapshot wrapper initialized')\n const provider: ISnapshotProvider = {\n getSnapshot: (input) => this.getSnapshot(input),\n invalidateCache: (input) => this.invalidateCache(input),\n // DeviceSettingsContribution surface — numeric deviceId end-to-end.\n getDeviceSettingsContribution: (input) => this.buildDeviceSettingsContribution(input.deviceId),\n getDeviceLiveContribution: async () => null,\n applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),\n // Status surface — derives a diagnostic snapshot from the cache\n // bookkeeping (lastCapturedAt, cacheAgeMs, lastBytes, lastStreamId).\n // Returns null when the device has never been captured.\n getStatus: async (input) => this.getStatus(input.deviceId),\n }\n return [{\n capability: snapshotCapability,\n provider,\n }]\n }\n\n protected async onShutdown(): Promise<void> {\n this.cache.clear()\n }\n\n protected globalSettingsSchema() {\n return this.schema({\n sections: [{\n id: 'snapshot-cache',\n title: 'Snapshot Cache',\n description: 'Stale fallback when live capture fails. Per-device freshness is configured under Device → Snapshot → Max cache age.',\n columns: 1,\n fields: [\n this.field({\n type: 'number', key: 'staleTtlMs', label: 'Stale fallback TTL (ms)',\n description: 'If live capture fails, cached snapshot younger than this is still returned.',\n min: 0, max: 3_600_000, step: 1_000, default: 60_000,\n }),\n ],\n }],\n })\n }\n\n // ── Capability methods ────────────────────────────────────────────────\n\n private async getSnapshot(input: { deviceId: number; streamId?: string; force?: boolean }): Promise<SnapshotImage | null> {\n const { deviceId, force } = input\n // Pull device name + battery feature with a single device-scoped\n // query. `getDevice` is cheaper than `listAll().find(...)` (one row\n // returned, no client-side filter) and surfaces the same fields.\n const meta = await this.lookupDeviceMeta(deviceId)\n const deviceName = meta?.name\n const isBatteryDevice = meta?.isBattery ?? false\n const log = this.ctx.logger.withTags({ deviceId, ...(deviceName ? { deviceName } : {}) })\n const now = Date.now()\n const hit = this.cache.get(deviceId)\n\n // Honour the per-device stored preference unless the caller specified\n // an explicit streamId. `snapshotDebug` just flips log verbosity —\n // read once per call so the flag can be toggled without restart.\n const prefs: SnapshotDeviceSettings = await this.readDeviceSettings(deviceId).catch(() => ({}))\n const rawPref = prefs.snapshotStreamId\n const effectiveStreamId = input.streamId ?? (rawPref && rawPref !== 'auto' ? rawPref : undefined)\n if (prefs.snapshotDebug) {\n log.info('debug on', { tags: { deviceId }, meta: { stream: effectiveStreamId ?? 'auto' } })\n }\n\n // 1. Effective max-age check. A single per-device knob replaces\n // the legacy fresh/battery/sleeping cascade — defaults are 10s\n // for non-battery cams (matches the live-feel UI poll cadence)\n // and 1h for battery cams (avoids gratuitous wake-ups). The\n // operator can override via the per-device `snapshotMaxAgeS`\n // setting; a `force=true` call (UI refresh button) bypasses\n // the gate entirely. Behaviour matrix:\n //\n // hit + ageMs < maxAgeMs + !force → serve cache\n // hit + sleeping + ageMs < maxAgeMs + !force → serve cache (no wake)\n // hit + sleeping + ageMs >= maxAgeMs + !force → cache is stale,\n // let the native\n // decide whether\n // to wake (the\n // native cap layer\n // handles the\n // \"are we awake?\"\n // question)\n // no hit → fall through (will wake)\n // force === true → fall through (will wake)\n const defaultMaxAgeS = isBatteryDevice ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S\n const effectiveMaxAgeMs = (typeof prefs.snapshotMaxAgeS === 'number' && prefs.snapshotMaxAgeS >= 0\n ? prefs.snapshotMaxAgeS\n : defaultMaxAgeS) * 1_000\n if (!force && hit && now - hit.ts < effectiveMaxAgeMs) {\n if (prefs.snapshotDebug) {\n log.debug('snapshot: cache hit', {\n tags: { deviceId },\n meta: { ageMs: now - hit.ts, maxAgeMs: effectiveMaxAgeMs, isBattery: isBatteryDevice },\n })\n }\n return hit.data\n }\n\n // 2. Native provider (per-device, registered by the device-class addon).\n // Missing native is expected for cameras without a vendor snapshot\n // endpoint (e.g. a plain RTSP camera with no `snapshotUrl` configured).\n // We catch and fall through to the stream-broker ffmpeg path.\n let nativeError: unknown = null\n let nativeAbsent = false\n try {\n const native = this.ctx.getNativeProvider(snapshotCapability, deviceId)\n if (native) {\n const result = await native.getSnapshot(input)\n if (result) {\n this.cache.set(deviceId, { data: result, ts: now, streamId: effectiveStreamId ?? null })\n return result\n }\n } else {\n nativeAbsent = true\n }\n } catch (err) {\n const msg = errMsg(err)\n if (isAbsentNativeError(msg)) {\n nativeAbsent = true\n log.debug('native snapshot absent', { tags: { deviceId }, meta: { error: msg } })\n } else {\n nativeError = err\n log.warn('native snapshot failed', { tags: { deviceId }, meta: { error: msg } })\n }\n }\n\n // 3. Frame-grab fallback via stream-broker's RTSP restream. Passes\n // the effective streamId (explicit caller or stored preference) so\n // the fallback hits the same stream the user asked for instead of\n // always grabbing `main`.\n //\n // Battery-cam guard: skip the broker fallback only when the native\n // was ABSENT for a battery device — that's the case where invoking\n // the broker would be the first wake-up signal. If the native was\n // present (whether it succeeded with null, threw, or returned a\n // value), the camera is already being talked to (and likely awake);\n // the additional broker dial costs nothing extra. When a streaming\n // broker is already active, the fallback is unconditionally free\n // (someone is watching), so allow it regardless.\n const skipBrokerForBattery = isBatteryDevice\n && nativeAbsent\n && !(await this.hasStreamingBrokerForDevice(deviceId))\n if (!skipBrokerForBattery) {\n try {\n const fallback = await this.grabFrameFromBroker(deviceId, effectiveStreamId)\n if (fallback) {\n this.cache.set(deviceId, { data: fallback, ts: now, streamId: effectiveStreamId ?? null })\n return fallback\n }\n } catch (err) {\n log.warn('stream-broker snapshot fallback failed', { tags: { deviceId }, meta: { error: errMsg(err) } })\n }\n } else {\n log.debug('snapshot: skipping broker fallback — battery device with absent native and no streaming broker', {\n tags: { deviceId },\n })\n }\n\n // 4. Stale cache — last-resort \"don't go blank\" path.\n //\n // If ALL live paths failed and we have any cached entry, return\n // it and log the age. The UI keeps showing the last known frame\n // rather than a broken-image icon. The freshness gate at step 1\n // (per-device `snapshotMaxAgeS`) is the cap that decides whether\n // the cache is \"fresh enough\" in normal flow; this branch only\n // runs when every live source refused.\n if (hit) {\n const ageMs = now - hit.ts\n if (ageMs > this.config.staleTtlMs) {\n log.warn('snapshot: all live paths failed — serving stale cache', { tags: { deviceId }, meta: { ageMs } })\n }\n return hit.data\n }\n\n // Nothing produced a frame and nothing in cache. If the native provider\n // threw a real error (not just \"no provider registered for this\n // device\"), surface it so callers see the cause. If the native was\n // simply absent (camera has no snapshotUrl + no wrapper active),\n // return null so the UI renders \"no image yet\" instead of a 500.\n if (nativeError) throw nativeError\n if (nativeAbsent) return null\n return null\n }\n\n /**\n * Tell apart \"native provider isn't registered for this device\" from\n * \"native provider ran and threw a real error\". The former is the steady\n * state for cameras without a vendor snapshot endpoint and should not\n * propagate as a 500; the latter should.\n */\n\n /**\n * Pull one JPEG from the device's stream-broker RTSP restream using\n * a short-lived ffmpeg invocation.\n *\n * Stream selection strategy (picks the broker that won't stall):\n * 1. Explicit `preferredStreamId` (user set in per-device settings)\n * — always honoured, even if currently idle. Operator choice\n * wins.\n * 2. Auto: highest-quality broker currently in `streaming` state.\n * This is the whole point — prefer the stream that has active\n * subscribers (usually `low` for detection, but `mid`/`high`\n * if WebRTC is watching) so ffmpeg hits a warm pipe and\n * doesn't race the broker's resume.\n * 3. Fallback: highest-quality enabled entry regardless of status\n * (will wake a suspended broker — retry-guarded against the\n * cold-start error).\n *\n * The stream-broker auto-suspends idle streams on the \"no demand\"\n * signal; snapshots used to default to `high` which was often the\n * first to go idle, racing every snapshot with a broker resume.\n * Now we ask the orchestrator of streams which one is warm and grab\n * from there.\n */\n private async grabFrameFromBroker(\n deviceId: number,\n preferredStreamId?: string,\n ): Promise<SnapshotImage | null> {\n const dev = await this.ctx.fetchDevice(deviceId)\n const prefix = `${deviceId}/`\n\n // Device-scoped fetch: broker filters by deviceId server-side, no\n // cluster-wide list + client-side filter needed. Profile slot\n // statuses come from the same per-device facade for the warmth\n // ranking below.\n const [deviceEntries, profileSlots] = await Promise.all([\n dev.cameraStreams?.getRtspEntries({}) ?? [],\n dev.cameraStreams?.getBrokerStreams({}) ?? [],\n ])\n const usable = deviceEntries.filter(e => e.enabled && !!e.url)\n if (usable.length === 0) return null\n\n // 1. Explicit preference — try it first without status gating.\n if (preferredStreamId && preferredStreamId !== 'auto') {\n const explicit = usable.find(e => e.brokerId === `${prefix}${preferredStreamId}`)\n if (explicit) {\n const grabbed = await this.runGrabWithResumeRetry(explicit.url, deviceId)\n if (grabbed) return grabbed\n // Explicit pick failed even after retry — fall through to auto\n // rather than give up. Keeps the snapshot useful when the\n // operator's preferred stream is momentarily unreachable.\n this.ctx.logger.debug('grabFrame: explicit stream failed — falling back to auto', { meta: { preferredStreamId } })\n }\n }\n\n // Rank entries by quality (high → mid → low → other).\n const ranked = [...usable].sort(\n (a, b) => qualityRank(b.brokerId, prefix) - qualityRank(a.brokerId, prefix),\n )\n\n // Per-device slot status lookup keyed by brokerId, sourced from\n // the same `cameraStreams` facade — `status: 'streaming'` means the\n // broker is warm and an ffmpeg grab is free of dial overhead.\n const statusByBrokerId = new Map<string, ProfileSlot['status']>()\n for (const slot of profileSlots) statusByBrokerId.set(slot.brokerId, slot.status)\n const statuses = ranked.map(e => ({ entry: e, status: statusByBrokerId.get(e.brokerId) ?? 'idle' as const }))\n\n // 2. Highest-quality broker currently `streaming`.\n const warm = statuses.find(s => s.status === 'streaming')\n if (warm) {\n const grabbed = await this.runGrabWithResumeRetry(warm.entry.url, deviceId)\n if (grabbed) return grabbed\n }\n\n // 3. Final fallback: wake whichever broker has the highest quality.\n // The retry handles the resume race window.\n for (const { entry } of statuses) {\n const grabbed = await this.runGrabWithResumeRetry(entry.url, deviceId).catch(() => null)\n if (grabbed) return grabbed\n }\n return null\n }\n\n /**\n * Ffmpeg grab with one retry on the broker-cold-start error\n * signature. Covers the window between \"client connected\" and\n * \"first keyframe\" when a suspended broker resumes.\n */\n private async runGrabWithResumeRetry(url: string, deviceId: number): Promise<SnapshotImage | null> {\n let buf: Buffer\n try {\n buf = await runFfmpegFrameGrab(url, 15000)\n } catch (err) {\n const msg = errMsg(err)\n if (isBrokerColdError(msg)) {\n this.ctx.logger.debug('grabFrame: broker-resume race — retrying in 1500ms', { tags: { deviceId } })\n await new Promise((r) => setTimeout(r, 1500))\n buf = await runFfmpegFrameGrab(url, 15000)\n } else {\n throw err\n }\n }\n if (buf.length === 0) return null\n return { base64: buf.toString('base64'), contentType: 'image/jpeg' }\n }\n\n private async invalidateCache(input: { deviceId: number }): Promise<void> {\n this.cache.delete(input.deviceId)\n }\n\n /**\n * Non-throwing probe of the device's battery cap. Returns true only\n * when a battery native is registered AND its current status says\n * `sleeping: true`. Any error (no provider, native absent, getStatus\n * missing, RPC timeout) is swallowed and treated as \"awake\" — we'd\n * rather pay a wake-up than strand the caller on a cache that's\n * semantically stale. Debug-logged for observability.\n */\n /**\n * True when at least one of the device's brokers is actively\n * streaming (status === 'streaming'). Used by the battery-cam guard\n * around `grabFrameFromBroker` to allow the fallback ONLY when\n * grabbing a frame is free (a consumer is already keeping the\n * stream warm). When everything is suspended, the fallback would\n * dial the camera and wake it — defeats the sleeping cache.\n */\n private async hasStreamingBrokerForDevice(deviceId: number): Promise<boolean> {\n // `cameraStreams` is the device-scoped facade over the system\n // `stream-broker` cap — `getBrokerStreams` returns this device's\n // profile slots only (no cluster-wide scan + filter).\n try {\n const dev = await this.ctx.fetchDevice(deviceId)\n const slots = await dev.cameraStreams?.getBrokerStreams({}) ?? []\n return slots.some(s => s.status === 'streaming')\n } catch {\n return false\n }\n }\n\n /**\n * Diagnostic status for the `status` auto-injected cap method. Reports\n * the cache bookkeeping for this device — when the last snapshot was\n * captured, how stale the cached image is, its size, and which stream\n * was used. Returns null when the device has never been captured\n * (cache miss) since the addon started.\n */\n private async getStatus(deviceId: number): Promise<{\n lastCapturedAt: number | null\n cacheAgeMs: number | null\n lastBytes: number | null\n lastStreamId: string | null\n } | null> {\n const hit = this.cache.get(deviceId)\n if (!hit) return null\n return {\n lastCapturedAt: hit.ts,\n cacheAgeMs: Date.now() - hit.ts,\n lastBytes: hit.data.base64.length, // approx bytes = base64 length * 3/4, but length is stable enough\n lastStreamId: hit.streamId,\n }\n }\n\n // ── DeviceSettingsContribution ────────────────────────────────────────\n //\n // Snapshot carries two per-device knobs:\n // - `snapshotStreamId` — which stream to prefer when grabbing frames\n // (empty = auto / default \"main\")\n // - `snapshotDebug` — extra logging for troubleshooting snapshot paths\n //\n // Storage uses the addon's own per-device store (`ctx.settings\n // .writeDeviceStore`), not the device's config. The schema isn't\n // captured in a Zod object: the keys are optional and the UI layer\n // validates shapes.\n\n private async readDeviceSettings(deviceId: number): Promise<SnapshotDeviceSettings> {\n if (!this.ctx.settings) return {}\n const raw = await this.ctx.settings.readDeviceStore(deviceId)\n const rawAge = raw['snapshotMaxAgeS']\n const maxAgeS = typeof rawAge === 'number' && rawAge >= 0 && Number.isFinite(rawAge)\n ? rawAge\n : undefined\n return {\n snapshotStreamId: typeof raw['snapshotStreamId'] === 'string' ? raw['snapshotStreamId'] : undefined,\n snapshotDebug: raw['snapshotDebug'] === true,\n ...(maxAgeS !== undefined ? { snapshotMaxAgeS: maxAgeS } : {}),\n }\n }\n\n private async buildDeviceSettingsContribution(deviceId: number): Promise<ConfigUISchemaWithValues | null> {\n // Camera-only — snapshot settings are meaningless on Lights /\n // Switches / Sensors / Buttons, and surfacing the section there\n // produces ghost top-tabs (the device-detail UI builds the tab\n // list off the contribution sections). Source-side gate keeps the\n // policy with the addon that owns the cap; the admin-ui no longer\n // needs the symmetric `device-section-policy` allow-list.\n const meta = await this.lookupDeviceMeta(deviceId)\n if (meta && meta.type !== DeviceType.Camera) return null\n const current = await this.readDeviceSettings(deviceId)\n const streamOptions = await this.getStreamOptions(deviceId)\n const isBattery = await this.isDeviceBattery(deviceId)\n const defaultMaxAgeS = isBattery ? BATTERY_DEFAULT_MAX_AGE_S : NON_BATTERY_DEFAULT_MAX_AGE_S\n return {\n sections: [{\n id: 'snapshot-preferences',\n title: 'Snapshot',\n tab: 'snapshot',\n order: 60,\n fields: [\n {\n type: 'select' as const,\n key: 'snapshotStreamId',\n label: 'Preferred stream',\n description: 'Stream used when grabbing a snapshot',\n options: streamOptions,\n required: true,\n value: current.snapshotStreamId || 'auto',\n },\n {\n type: 'number' as const,\n key: 'snapshotMaxAgeS',\n label: 'Max cache age (s)',\n description:\n `Serve cached snapshot up to this age before re-capturing. Default ${defaultMaxAgeS}s for ` +\n `${isBattery ? 'battery cams (avoids gratuitous wake-ups)' : 'non-battery cams (live-feel)'}. ` +\n 'The UI refresh button always forces a fresh capture regardless of this value.',\n min: 0,\n max: 24 * 3600,\n step: 1,\n value: current.snapshotMaxAgeS ?? defaultMaxAgeS,\n },\n {\n type: 'boolean' as const,\n key: 'snapshotDebug',\n label: 'Debug logging',\n description: 'Log stream selection and timing details for this device.',\n value: current.snapshotDebug ?? false,\n },\n ],\n }],\n }\n }\n\n /**\n * Single-trip device lookup against device-manager. Returns the\n * fields the wrapper actually consults — name (logging) + battery\n * flag (cache window + broker-fallback gate). Sourced from the\n * device-manager registry rather than the battery cap so the answer\n * survives a momentarily-unreachable provider (the very condition\n * we're trying to be resilient to).\n *\n * Logged at debug + null return on failure: every call site already\n * has a sensible fallback path (cache hit, conservative default, …),\n * so we don't want a transient device-manager hiccup to throw.\n */\n private async lookupDeviceMeta(\n deviceId: number,\n ): Promise<{ name?: string; isBattery: boolean; type?: DeviceType } | null> {\n const api = this.ctx.api\n if (!api) return null\n try {\n const found = await api.deviceManager.getDevice.query({ deviceId })\n if (!found) return null\n const features = (found.features as readonly string[] | undefined) ?? []\n const rawType = (found as { type?: string }).type\n return {\n ...(found.name ? { name: found.name } : {}),\n isBattery: features.includes(DeviceFeature.BatteryOperated),\n ...(rawType ? { type: rawType as DeviceType } : {}),\n }\n } catch (err) {\n this.ctx.logger.debug('deviceManager.getDevice failed during snapshot', {\n tags: { deviceId },\n meta: { error: err instanceof Error ? err.message : String(err) },\n })\n return null\n }\n }\n\n /** Settings-UI helper — battery flag drives the default max-age in the field description. */\n private async isDeviceBattery(deviceId: number): Promise<boolean> {\n return (await this.lookupDeviceMeta(deviceId))?.isBattery ?? false\n }\n\n private async getStreamOptions(deviceId: number): Promise<Array<{ value: string; label: string }>> {\n const prefix = `${deviceId}/`\n try {\n const dev = await this.ctx.fetchDevice(deviceId)\n const entries = await dev.cameraStreams?.getRtspEntries({}) ?? []\n const streamIds = entries\n .filter(e => e.enabled)\n .map(e => e.brokerId.slice(prefix.length))\n return [\n { value: 'auto', label: 'Auto' },\n ...streamIds.map(id => ({ value: id, label: streamQualityLabel(id) })),\n ]\n } catch (err) {\n this.ctx.logger.error('getStreamOptions failed', { tags: { deviceId }, meta: { error: errMsg(err) } })\n return [{ value: 'auto', label: 'Auto' }]\n }\n }\n\n private async saveDeviceSettingsPatch(\n deviceId: number,\n patch: Record<string, unknown>,\n ): Promise<{ success: true }> {\n if (!this.ctx.settings) {\n throw new Error('[snapshot] settings store unavailable — cannot persist per-device settings')\n }\n const current = await this.ctx.settings.readDeviceStore(deviceId)\n const next: Record<string, unknown> = { ...current }\n if ('snapshotStreamId' in patch) {\n const v = patch['snapshotStreamId']\n next['snapshotStreamId'] = typeof v === 'string' && v.trim().length > 0 ? v.trim() : ''\n }\n if ('snapshotDebug' in patch) {\n next['snapshotDebug'] = patch['snapshotDebug'] === true\n }\n if ('snapshotMaxAgeS' in patch) {\n const v = patch['snapshotMaxAgeS']\n // Persist a finite non-negative number; anything else (null,\n // undefined, NaN, negative) falls back to the per-device\n // default at read time.\n if (typeof v === 'number' && Number.isFinite(v) && v >= 0) {\n next['snapshotMaxAgeS'] = v\n } else {\n delete next['snapshotMaxAgeS']\n }\n }\n await this.ctx.settings.writeDeviceStore(deviceId, next)\n // Drop the cache so the next `getSnapshot` applies the new stream/debug flags.\n this.cache.delete(deviceId)\n return { success: true as const }\n }\n}\n\nfunction isAbsentNativeError(msg: string): boolean {\n return msg.includes('no provider for') || msg.includes('no native provider for capability')\n}\n\n/**\n * Quality ordering for broker picker: `high` > `mid` > `low` > other.\n * The streamId is the suffix after `${deviceId}/` in the brokerId.\n * Unknown labels fall through to 0 so they land at the bottom of the\n * preference list without crashing.\n */\nfunction qualityRank(brokerId: string, prefix: string): number {\n const streamId = brokerId.startsWith(prefix) ? brokerId.slice(prefix.length) : brokerId\n const normalized = streamId.toLowerCase()\n if (normalized.includes('high') || normalized === 'main' || normalized === 'hd') return 3\n if (normalized.includes('mid') || normalized === 'medium') return 2\n if (normalized.includes('low') || normalized === 'sub' || normalized === 'sd') return 1\n return 0\n}\n\n/**\n * Detect ffmpeg failure signatures that indicate the broker wasn't\n * streaming yet when we tried to read its RTSP restream — a race\n * window that closes as soon as the broker lands its first keyframe.\n * Used by `grabFrameFromBroker` to retry once instead of giving up.\n */\nfunction isBrokerColdError(msg: string): boolean {\n return msg.includes('Invalid data found when processing input')\n || msg.includes('Error opening input')\n || msg.includes('Connection refused')\n || msg.includes('No route to host')\n}\n\nfunction runFfmpegFrameGrab(url: string, timeoutMs: number): Promise<Buffer> {\n return new Promise<Buffer>((resolve, reject) => {\n // ── IDR-first snapshot ────────────────────────────────────────────────\n //\n // `-skip_frame nokey` decodes only keyframes — the first frame handed\n // to the MJPEG encoder is guaranteed to be a complete IDR with all\n // references present, so the JPEG can't come out as a half-decoded\n // delta (the \"green stripe\" a non-keyframe decode produces).\n //\n // `-fflags +discardcorrupt` drops corrupt packets that slipped in\n // before sync — otherwise they propagate as green/gray smears into\n // the decoded output.\n //\n // No `-analyzeduration` / `-probesize` override: the old values\n // (5_000_000 µs = 5 s) forced ffmpeg to buffer 5 s of RTP before\n // decoding even when a keyframe was already in hand, which stacked\n // with a cold stream-broker's upstream handshake and pushed the\n // snapshot past the caller's ffmpeg timeout. Defaults are fine —\n // `-skip_frame nokey` + `-frames:v 1` already bound the work to a\n // single keyframe.\n const child = execFile(\n 'ffmpeg',\n [\n '-loglevel', 'error',\n '-rtsp_transport', 'tcp',\n '-fflags', '+discardcorrupt',\n '-skip_frame', 'nokey',\n '-i', url,\n '-vf', 'select=eq(pict_type\\\\,I)',\n '-vsync', 'vfr',\n '-frames:v', '1',\n '-q:v', '3',\n '-f', 'image2pipe',\n '-vcodec', 'mjpeg',\n 'pipe:1',\n ],\n { encoding: 'buffer', maxBuffer: 16 * 1024 * 1024, timeout: timeoutMs },\n (err, stdout, stderr) => {\n if (err) {\n // `execFile`'s default Error carries \"Command failed: …\" plus a\n // `code`/`signal` that Node fills in. ffmpeg with `-loglevel\n // error` is silent on RTSP-level failures; when the parent\n // kills the child on timeout, `killed === true` and stderr is\n // empty. Surface both sides so the log tells the real story.\n const errWithMeta = err as (Error & { code?: string | number; signal?: string | null; killed?: boolean })\n const stderrText = Buffer.isBuffer(stderr) ? stderr.toString('utf8').trim() : String(stderr ?? '').trim()\n const parts: string[] = [err instanceof Error ? err.message : String(err)]\n if (errWithMeta.killed) parts.push('killed=true')\n if (errWithMeta.code !== undefined) parts.push(`code=${String(errWithMeta.code)}`)\n if (errWithMeta.signal) parts.push(`signal=${errWithMeta.signal}`)\n if (stderrText) parts.push(`stderr: ${stderrText.slice(0, 500)}`)\n reject(new Error(parts.join(' — ')))\n return\n }\n resolve(Buffer.isBuffer(stdout) ? stdout : Buffer.from(stdout))\n },\n )\n child.on('error', (e: Error) => reject(e))\n })\n}\n"],"mappings":"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"}
|
|
@@ -39,9 +39,7 @@ var SnapshotAddon = class extends BaseAddon {
|
|
|
39
39
|
getDeviceLiveContribution: async () => null,
|
|
40
40
|
applyDeviceSettingsPatch: (input) => this.saveDeviceSettingsPatch(input.deviceId, input.patch),
|
|
41
41
|
getStatus: async (input) => this.getStatus(input.deviceId)
|
|
42
|
-
}
|
|
43
|
-
kind: "wrapper",
|
|
44
|
-
defaultActive: true
|
|
42
|
+
}
|
|
45
43
|
}];
|
|
46
44
|
}
|
|
47
45
|
async onShutdown() {
|