obsbot-mcp 0.3.0 → 0.4.0
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/README.md +197 -38
- package/dist/codec/commands.d.ts +42 -4
- package/dist/codec/commands.js +72 -10
- package/dist/codec/commands.js.map +1 -1
- package/dist/codec/frame.d.ts +1 -0
- package/dist/codec/frame.js +1 -1
- package/dist/codec/frame.js.map +1 -1
- package/dist/codec/preset.d.ts +40 -0
- package/dist/codec/preset.js +198 -0
- package/dist/codec/preset.js.map +1 -0
- package/dist/codec/types.d.ts +4 -0
- package/dist/device/helper-factory.d.ts +25 -0
- package/dist/device/helper-factory.js +40 -0
- package/dist/device/helper-factory.js.map +1 -0
- package/dist/device/manager.d.ts +326 -2
- package/dist/device/manager.js +720 -24
- package/dist/device/manager.js.map +1 -1
- package/dist/ipc/client.d.ts +19 -0
- package/dist/ipc/client.js +81 -0
- package/dist/ipc/client.js.map +1 -0
- package/dist/ipc/coordinator.d.ts +29 -0
- package/dist/ipc/coordinator.js +94 -0
- package/dist/ipc/coordinator.js.map +1 -0
- package/dist/ipc/owner.d.ts +21 -0
- package/dist/ipc/owner.js +59 -0
- package/dist/ipc/owner.js.map +1 -0
- package/dist/ipc/protocol.d.ts +21 -0
- package/dist/ipc/protocol.js +56 -0
- package/dist/ipc/protocol.js.map +1 -0
- package/dist/ipc/rendezvous.d.ts +26 -0
- package/dist/ipc/rendezvous.js +93 -0
- package/dist/ipc/rendezvous.js.map +1 -0
- package/dist/mcp/log-sink.d.ts +12 -0
- package/dist/mcp/log-sink.js +27 -0
- package/dist/mcp/log-sink.js.map +1 -0
- package/dist/mcp/ready.d.ts +16 -4
- package/dist/mcp/ready.js +11 -8
- package/dist/mcp/ready.js.map +1 -1
- package/dist/mcp/render.js +1 -1
- package/dist/mcp/render.js.map +1 -1
- package/dist/mcp/server.js +68 -18
- package/dist/mcp/server.js.map +1 -1
- package/dist/mcp/tools.d.ts +5 -3
- package/dist/mcp/tools.js +787 -186
- package/dist/mcp/tools.js.map +1 -1
- package/dist/transport/helper-process.d.ts +36 -0
- package/dist/transport/helper-process.js +190 -10
- package/dist/transport/helper-process.js.map +1 -1
- package/dist/transport/linux.d.ts +40 -0
- package/dist/transport/linux.js +92 -2
- package/dist/transport/linux.js.map +1 -1
- package/dist/transport/macos.d.ts +13 -0
- package/dist/transport/macos.js +59 -3
- package/dist/transport/macos.js.map +1 -1
- package/dist/transport/read-serial.d.ts +26 -0
- package/dist/transport/read-serial.js +87 -0
- package/dist/transport/read-serial.js.map +1 -0
- package/dist/transport/transport.d.ts +24 -0
- package/dist/transport/windows.d.ts +4 -0
- package/dist/transport/windows.js +31 -4
- package/dist/transport/windows.js.map +1 -1
- package/native/prebuilt/darwin-arm64/obsbot-helper +0 -0
- package/native/prebuilt/darwin-x64/obsbot-helper +0 -0
- package/native/prebuilt/linux-x64/obsbot-helper +0 -0
- package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
- package/package.json +3 -2
- package/dist/device/session.d.ts +0 -22
- package/dist/device/session.js +0 -37
- package/dist/device/session.js.map +0 -1
package/dist/device/manager.js
CHANGED
|
@@ -1,45 +1,741 @@
|
|
|
1
1
|
import { WindowsTransport } from "../transport/windows.js";
|
|
2
2
|
import { LinuxTransport } from "../transport/linux.js";
|
|
3
3
|
import { MacosTransport } from "../transport/macos.js";
|
|
4
|
+
// USB vendor ID 0x3564 is registered to **Remo Inc.** — the manufacturer. OBSBOT
|
|
5
|
+
// is a Remo product brand, and Remo ships non-OBSBOT (and non-camera) devices
|
|
6
|
+
// under the same VID, so candidacy must gate on VID **and** a known model PID —
|
|
7
|
+
// never VID alone. Add a PID here (and mirror it in native/macos/helper.m's
|
|
8
|
+
// OBSBOT_MODEL_PIDS) when a new OBSBOT camera model is verified on hardware.
|
|
9
|
+
const REMO_VID = 0x3564;
|
|
10
|
+
const OBSBOT_MODEL_PIDS = new Map([
|
|
11
|
+
[REMO_VID, new Set([0xfef8 /* Tiny 2 */])],
|
|
12
|
+
]);
|
|
13
|
+
// Legacy name match — used ONLY as a fallback on platforms whose helper does not
|
|
14
|
+
// yet report USB vid/pid (Linux today: its /dev/videoN paths carry no USB
|
|
15
|
+
// identity). Windows and macOS helpers report vid/pid, so there the gate is
|
|
16
|
+
// strict and branded software sources — e.g. the "OBSBOT Virtual Camera"
|
|
17
|
+
// DirectShow filter, which matched this regex and poisoned multi-candidate
|
|
18
|
+
// binding — are correctly excluded.
|
|
19
|
+
const OBSBOT_NAME_RE = /obsbot/i;
|
|
20
|
+
/** Message text from an unknown thrown value, for diagnostics. */
|
|
21
|
+
const errText = (e) => (e instanceof Error ? e.message : String(e));
|
|
22
|
+
/**
|
|
23
|
+
* Is this enumerated device a controllable OBSBOT camera worth binding? On
|
|
24
|
+
* Windows/macOS the helper reports USB vid/pid, so gate strictly on the Remo
|
|
25
|
+
* VID + known-model PID table (a software "OBSBOT Virtual Camera" has no vid/pid
|
|
26
|
+
* and is rejected). On Linux the helper does not report vid/pid yet, so fall
|
|
27
|
+
* back to the name match there until it does.
|
|
28
|
+
*/
|
|
29
|
+
function isObsbotCamera(d) {
|
|
30
|
+
if (process.platform === "linux")
|
|
31
|
+
return OBSBOT_NAME_RE.test(d.name);
|
|
32
|
+
if (d.vid === undefined || d.pid === undefined)
|
|
33
|
+
return false;
|
|
34
|
+
return OBSBOT_MODEL_PIDS.get(d.vid)?.has(d.pid) ?? false;
|
|
35
|
+
}
|
|
36
|
+
/** Thrown by get() with no selector when more than one camera is attached. */
|
|
37
|
+
export class AmbiguousCameraError extends Error {
|
|
38
|
+
available;
|
|
39
|
+
constructor(available) {
|
|
40
|
+
super(`multiple cameras attached; specify one of: ${available.join(", ")}`);
|
|
41
|
+
this.name = "AmbiguousCameraError";
|
|
42
|
+
this.available = available;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/** Thrown by get(serial) when no attached, bindable camera has that serial. */
|
|
46
|
+
export class UnknownCameraError extends Error {
|
|
47
|
+
available;
|
|
48
|
+
constructor(serial, available) {
|
|
49
|
+
super(`unknown camera "${serial}"; available: ${available.length ? available.join(", ") : "(none)"}`);
|
|
50
|
+
this.name = "UnknownCameraError";
|
|
51
|
+
this.available = available;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Serial-keyed multi-camera registry. Identity is the serial (read via
|
|
56
|
+
* ObsbotTransport.readSerial() on a freshly-opened device); locationId is
|
|
57
|
+
* only ever a display/correlation hint, never persisted as truth for
|
|
58
|
+
* binding.
|
|
59
|
+
*
|
|
60
|
+
* Steady state is one HelperProcess per bound camera (Map<serial, {helper,
|
|
61
|
+
* transport, locationId}>), spawned lazily — nothing is spawned until a
|
|
62
|
+
* camera is actually needed (get() or listCameras()). While scanning for a
|
|
63
|
+
* camera to bind, candidates are tried on a single scratch helper reused
|
|
64
|
+
* across attempts, mirroring the native helper's own behaviour: `doOpen`
|
|
65
|
+
* unconditionally releases whatever device it previously held before
|
|
66
|
+
* opening the next one, so re-opening a different candidate on the same
|
|
67
|
+
* helper is exactly what one physical device-swap looks like to it. A
|
|
68
|
+
* scratch helper that turns out to be the winning candidate is promoted
|
|
69
|
+
* directly into the registry (no extra spawn); one that never opens a
|
|
70
|
+
* usable camera is kept around for the next scan instead of being spawned
|
|
71
|
+
* again. Consequently DeviceManager never closes a helper it did not spawn
|
|
72
|
+
* itself for a *losing* attempt — ownership of a bound camera's helper
|
|
73
|
+
* transfers to the registry, and callers that hand in an already-started,
|
|
74
|
+
* externally-owned helper (see the .mjs scripts) keep owning its shutdown.
|
|
75
|
+
*
|
|
76
|
+
* USB open is exclusive: a camera another process holds fails `open` with
|
|
77
|
+
* an exclusive-access error. Per multi-camera spec §4.3, ANY open failure
|
|
78
|
+
* during a scan is treated as "not mine, skip" — not fatal — because the
|
|
79
|
+
* helper does not currently surface a clean, stable way to distinguish
|
|
80
|
+
* exclusive-access from other open failures at this layer.
|
|
81
|
+
*
|
|
82
|
+
* invalidate() is the escape hatch out of "once bound, stay bound": it
|
|
83
|
+
* drops a registry entry (best-effort closing its helper first) so the
|
|
84
|
+
* next get()/bind() re-scans through a fresh helper instead of handing back
|
|
85
|
+
* a transport that may be talking to a device that already re-enumerated.
|
|
86
|
+
*/
|
|
4
87
|
export class DeviceManager {
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
88
|
+
makeHelper;
|
|
89
|
+
registry = new Map();
|
|
90
|
+
/** Lazily spawned, reused across scans until promoted into the registry. */
|
|
91
|
+
scanHelper;
|
|
92
|
+
/**
|
|
93
|
+
* Long-lived listener. Holds no device and never scans for one — its only job
|
|
94
|
+
* is to still be alive, and still subscribed, when the cable moves.
|
|
95
|
+
*/
|
|
96
|
+
watcher;
|
|
97
|
+
/**
|
|
98
|
+
* Reconnect tracking, folded in from the retired DeviceSession. `everBound`
|
|
99
|
+
* records every serial this manager has bound at least once — deliberately
|
|
100
|
+
* SEPARATE from the registry Map so it survives invalidate() dropping a
|
|
101
|
+
* registry entry (the registry says "bound right now", this says "bound at
|
|
102
|
+
* some point"). A promote() of a serial already in `everBound` is therefore a
|
|
103
|
+
* RE-bind — a self-heal after a mid-session disconnect — and lands the serial
|
|
104
|
+
* in `reconnectedSerials`, which the readiness gate drains via
|
|
105
|
+
* takeReconnected() to surface `reconnected: true` on the next command.
|
|
106
|
+
*/
|
|
107
|
+
everBound = new Set();
|
|
108
|
+
reconnectedSerials = new Set();
|
|
109
|
+
/**
|
|
110
|
+
* @param makeHelper Factory for a HelperProcess, invoked whenever a fresh
|
|
111
|
+
* scratch helper is needed (each scan, and each rebind after
|
|
112
|
+
* invalidate()). CONTRACT: it MUST return a freshly-started,
|
|
113
|
+
* exclusively-owned HelperProcess on every call — DeviceManager may call
|
|
114
|
+
* it repeatedly across a session and always expects a clean handle, not
|
|
115
|
+
* a shared one. A factory that instead returns the SAME already-started
|
|
116
|
+
* helper on every call (as the single-camera `.mjs` scripts under
|
|
117
|
+
* scripts/ do, to keep one native session alive for their whole run)
|
|
118
|
+
* MUST NOT bind more than one serial through this manager: a second
|
|
119
|
+
* scan would hand that same shared helper back and silently steal the
|
|
120
|
+
* first bound camera's native session out from under it. No current
|
|
121
|
+
* caller does this (all are single-camera); this is latent and not
|
|
122
|
+
* guarded in code — the proper fix belongs to multi-camera .mjs/CLI
|
|
123
|
+
* wiring, not to DeviceManager itself.
|
|
124
|
+
*/
|
|
125
|
+
/** True while an arrival re-bind ladder is running (see handleCameraArrived). */
|
|
126
|
+
rebinding = false;
|
|
127
|
+
/**
|
|
128
|
+
* Delays before each arrival re-bind attempt, in order — so four attempts
|
|
129
|
+
* spanning ~4.5s, the first immediate.
|
|
130
|
+
*
|
|
131
|
+
* Sized from hardware, 2026-07-21: for many seconds after a USB
|
|
132
|
+
* re-enumeration the Tiny 2's vendor mailbox is intermittently not-ready
|
|
133
|
+
* (the reply slot reads back with its magic byte zeroed), and readSerial's
|
|
134
|
+
* own 8 x 30ms poll is too short to ride it out. Measured 22 failures in 80
|
|
135
|
+
* attempts across the first 14s after a replug, against 0 in 120 in steady
|
|
136
|
+
* state — so a single attempt on arrival is roughly a 1-in-4 coin flip, and
|
|
137
|
+
* losing it silently leaves the camera unbound until the user's next call.
|
|
138
|
+
* Four spread attempts make a lost re-bind unlikely without retrying forever
|
|
139
|
+
* at a camera that is simply not answering.
|
|
140
|
+
*/
|
|
141
|
+
arrivalBackoffMs;
|
|
142
|
+
/**
|
|
143
|
+
* Where a failed or retried arrival re-bind is reported. STDERR by default —
|
|
144
|
+
* never stdout, which is the JSON-RPC channel.
|
|
145
|
+
*
|
|
146
|
+
* Exists because the ladder was silent: a ladder that fired and a clean first
|
|
147
|
+
* attempt looked identical from outside the process, so the retry could not
|
|
148
|
+
* be observed on hardware even once it existed. A self-heal that fails
|
|
149
|
+
* quietly is the failure mode that cost a day of debugging.
|
|
150
|
+
*/
|
|
151
|
+
log;
|
|
152
|
+
constructor(makeHelper, opts = {}) {
|
|
153
|
+
this.makeHelper = makeHelper;
|
|
154
|
+
this.arrivalBackoffMs = opts.arrivalBackoffMs ?? [0, 400, 1200, 3000];
|
|
155
|
+
this.log = opts.log ?? ((m) => console.error(m));
|
|
8
156
|
}
|
|
9
|
-
createTransport() {
|
|
157
|
+
createTransport(helper) {
|
|
10
158
|
if (process.platform === "linux") {
|
|
11
|
-
return new LinuxTransport(
|
|
159
|
+
return new LinuxTransport(helper);
|
|
12
160
|
}
|
|
13
161
|
if (process.platform === "darwin") {
|
|
14
|
-
return new MacosTransport(
|
|
162
|
+
return new MacosTransport(helper);
|
|
163
|
+
}
|
|
164
|
+
return new WindowsTransport(helper);
|
|
165
|
+
}
|
|
166
|
+
/**
|
|
167
|
+
* Close and forget the scratch helper. Closing matters as much as forgetting:
|
|
168
|
+
* the process is alive and holding whatever it opened, so dropping only the
|
|
169
|
+
* reference leaks it and leaves the replacement unable to open the camera —
|
|
170
|
+
* the same mistake pruneDeadEntries() makes if it merely deletes an entry.
|
|
171
|
+
*/
|
|
172
|
+
async discardScanHelper() {
|
|
173
|
+
const helper = this.scanHelper;
|
|
174
|
+
this.scanHelper = undefined;
|
|
175
|
+
if (!helper)
|
|
176
|
+
return;
|
|
177
|
+
try {
|
|
178
|
+
await helper.close();
|
|
179
|
+
}
|
|
180
|
+
catch {
|
|
181
|
+
// best-effort — a disconnected helper's close() may itself throw
|
|
15
182
|
}
|
|
16
|
-
return new WindowsTransport(this.helper);
|
|
17
183
|
}
|
|
184
|
+
/**
|
|
185
|
+
* Keep one helper alive purely to receive bus events.
|
|
186
|
+
*
|
|
187
|
+
* Bus events are delivered per PROCESS (helperFactory subscribes each one it
|
|
188
|
+
* spawns), and every process that could be listening gets closed: a departure
|
|
189
|
+
* closes the registry helper, and a failed bind closes the scratch scanner.
|
|
190
|
+
* Since promote() clears `scanHelper`, the bound steady state is exactly ONE
|
|
191
|
+
* live helper — the registry's — so handleCameraDeparted() alone drops the
|
|
192
|
+
* subscriber count to zero. The arrival that follows is then delivered to
|
|
193
|
+
* nobody and the camera stays unbound until a tool call fails against it.
|
|
194
|
+
*
|
|
195
|
+
* PRIMING IS NOT OPTIONAL. A helper that has never enumerated receives
|
|
196
|
+
* nothing. Measured on hardware (darwin-arm64, Tiny 2, 2026-07-21) across one
|
|
197
|
+
* same-port replug, three processes, none of which opened the device:
|
|
198
|
+
*
|
|
199
|
+
* enumerated while camera present -> departure YES, arrival YES
|
|
200
|
+
* never enumerated -> departure NO, arrival NO
|
|
201
|
+
* enumerated while camera absent -> arrival YES
|
|
202
|
+
*
|
|
203
|
+
* The silent one stayed alive the whole run and answered a later enumerate
|
|
204
|
+
* correctly, so that was genuine non-delivery. Its first enumerate took 71ms
|
|
205
|
+
* against ~1ms for the primed pair — AVFoundation's discovery subsystem
|
|
206
|
+
* starting up for the first time. Registering the observers
|
|
207
|
+
* (helper.m:registerCameraNotifications) is NOT what starts delivery;
|
|
208
|
+
* touching the device list is.
|
|
209
|
+
*
|
|
210
|
+
* Windows enforces the same rule for an unrelated reason: helper.cpp drops
|
|
211
|
+
* any event whose path is missing from `g_knownPaths`, which only enumerate
|
|
212
|
+
* fills, and that cache is per-process. There the path must have been seen
|
|
213
|
+
* WHILE THE CAMERA WAS PRESENT — an enumerate during an absence cannot cache
|
|
214
|
+
* it. macOS has no such constraint: an arm primed during an absence received
|
|
215
|
+
* the arrival in the same millisecond as one primed while present.
|
|
216
|
+
*
|
|
217
|
+
* Both halves are now MEASURED, one platform each, same three-arm experiment:
|
|
218
|
+
*
|
|
219
|
+
* primed-present never-primed primed-during-absence
|
|
220
|
+
* macOS yes NO yes
|
|
221
|
+
* Windows yes NO NO
|
|
222
|
+
*
|
|
223
|
+
* The Windows run logged its own mechanism rather than inferring it — the
|
|
224
|
+
* absent-camera enumerate recorded "Tiny 2 in its list = false", so there was
|
|
225
|
+
* no path to cache and the arrival died at helper.cpp:1022.
|
|
226
|
+
*
|
|
227
|
+
* PRIMING REPEATS ON EVERY CALL, and that is Windows insurance specifically.
|
|
228
|
+
* macOS does NOT need it — measured across two full replug cycles, a watcher
|
|
229
|
+
* primed once at startup kept delivering (arrived=2, departed=2), identical
|
|
230
|
+
* to one that re-primed after every departure. The prime is a one-time
|
|
231
|
+
* activation there, not a lease. On Windows a watcher whose predecessor DIED
|
|
232
|
+
* during an absence gets replaced while the camera is gone, never caches the
|
|
233
|
+
* path, and never self-corrects, because the guard below would otherwise
|
|
234
|
+
* early-return on a watcher that is alive and useless. Hardware-confirmed on
|
|
235
|
+
* Windows: that scenario ends `available`, never `bound`, with no arrival ever
|
|
236
|
+
* seen — where the same code on macOS recovers.
|
|
237
|
+
*
|
|
238
|
+
* So the repeat only helps at a moment the camera is PRESENT. Re-priming
|
|
239
|
+
* during an absence is a no-op on Windows; only the next successful bind can
|
|
240
|
+
* cure a deaf watcher there.
|
|
241
|
+
*
|
|
242
|
+
* Costs one idle process once a camera has been bound. It opens nothing, so
|
|
243
|
+
* it holds no exclusive USB handle and the camera stays available to Zoom,
|
|
244
|
+
* OBS and OBSBOT Center — the constraint that shaped handleCameraArrived.
|
|
245
|
+
*/
|
|
246
|
+
async ensureWatcher() {
|
|
247
|
+
if (this.everBound.size === 0)
|
|
248
|
+
return; // never ours — stay hands-off
|
|
249
|
+
try {
|
|
250
|
+
if (!this.watcher || this.watcher.isDead)
|
|
251
|
+
this.watcher = await this.makeHelper();
|
|
252
|
+
await this.watcher.enumerate(); // the touch that starts delivery — see above
|
|
253
|
+
}
|
|
254
|
+
catch (e) {
|
|
255
|
+
// NEVER fatal. This runs AFTER promote(), so throwing would escape bind()
|
|
256
|
+
// with the camera already in the registry: the caller sees an exception
|
|
257
|
+
// while listCameras() reports it bound. The watcher only buys a proactive
|
|
258
|
+
// re-bind — without it recovery falls back to the failure-driven path,
|
|
259
|
+
// which is one extra call, exactly where things stood before it existed.
|
|
260
|
+
//
|
|
261
|
+
// Self-healing by construction: a spawn failure leaves `watcher`
|
|
262
|
+
// undefined and a prime failure leaves it unprimed, and either way the
|
|
263
|
+
// next ensureWatcher() call retries. Logged because a self-heal that
|
|
264
|
+
// fails quietly is the failure mode that cost a day of debugging.
|
|
265
|
+
this.log(`obsbot-mcp: bus watcher unavailable (${errText(e)}); ` +
|
|
266
|
+
`replug recovery falls back to the next tool call`);
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
async getScanHelper() {
|
|
270
|
+
// A cached scan helper whose process has died must be discarded, not
|
|
271
|
+
// handed back. invalidate() only drops REGISTRY entries, so a helper that
|
|
272
|
+
// died mid-scan (before promote() moved it into the registry) would
|
|
273
|
+
// otherwise stay cached here and every later scan would talk to a corpse —
|
|
274
|
+
// the one path where failing fast on the transport isn't enough to recover.
|
|
275
|
+
if (this.scanHelper?.isDead)
|
|
276
|
+
this.scanHelper = undefined;
|
|
277
|
+
if (!this.scanHelper) {
|
|
278
|
+
this.scanHelper = await this.makeHelper();
|
|
279
|
+
}
|
|
280
|
+
return this.scanHelper;
|
|
281
|
+
}
|
|
282
|
+
/**
|
|
283
|
+
* Compat: raw enumerate() pass-through (unfiltered, no serials). Used by
|
|
284
|
+
* obsbot_capture_snapshot's virtual/ndi source lookup, not by obsbot_devices
|
|
285
|
+
* (which reports the identified fleet via listCameras() instead).
|
|
286
|
+
*/
|
|
18
287
|
async list() {
|
|
19
|
-
|
|
288
|
+
const helper = await this.getScanHelper();
|
|
289
|
+
return helper.enumerate();
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Scan attached candidates on the scratch helper for a camera to bind.
|
|
293
|
+
*
|
|
294
|
+
* - `wantSerial` given: stop at the first candidate whose serial matches;
|
|
295
|
+
* throws UnknownCameraError (listing every identifiable serial seen)
|
|
296
|
+
* if the scan exhausts without a match.
|
|
297
|
+
* - `wantSerial` omitted: every candidate must be probed so the full
|
|
298
|
+
* fleet is known — a partial scan could under-report ambiguity or
|
|
299
|
+
* silently pick the wrong "only" camera. Binds if exactly one distinct
|
|
300
|
+
* serial turns up; throws AmbiguousCameraError (all distinct serials)
|
|
301
|
+
* if more than one does; throws if none do.
|
|
302
|
+
*/
|
|
303
|
+
async bind(wantSerial) {
|
|
304
|
+
const helper = await this.getScanHelper();
|
|
305
|
+
const devices = await helper.enumerate();
|
|
306
|
+
const candidates = devices.filter(isObsbotCamera);
|
|
307
|
+
const found = new Map();
|
|
308
|
+
let matched;
|
|
309
|
+
// Why each candidate was passed over. Every `continue` below is a silent
|
|
310
|
+
// rejection, and when they ALL reject the caller used to get a bare "no
|
|
311
|
+
// OBSBOT camera found" — the same message it gets with nothing plugged in.
|
|
312
|
+
// Carrying the reasons into the error is the difference between "no camera"
|
|
313
|
+
// and "the camera is right there but wouldn't answer".
|
|
314
|
+
const rejected = [];
|
|
315
|
+
for (const d of candidates) {
|
|
316
|
+
let xuNode;
|
|
317
|
+
try {
|
|
318
|
+
xuNode = await helper.open(d.path);
|
|
319
|
+
}
|
|
320
|
+
catch (e) {
|
|
321
|
+
// ANY open failure (exclusive access or otherwise) — skip, not
|
|
322
|
+
// fatal. The next candidate's open() releases this attempt.
|
|
323
|
+
rejected.push(`${d.path}: open failed: ${errText(e)}`);
|
|
324
|
+
continue;
|
|
325
|
+
}
|
|
326
|
+
if (xuNode < 0) {
|
|
327
|
+
// Opened, but this node has no XU unit (e.g. the metadata/ISP node
|
|
328
|
+
// the Tiny 2 also exposes) — not a usable candidate.
|
|
329
|
+
rejected.push(`${d.path}: opened but has no XU unit`);
|
|
330
|
+
continue;
|
|
331
|
+
}
|
|
332
|
+
const transport = this.createTransport(helper);
|
|
333
|
+
let serial;
|
|
334
|
+
try {
|
|
335
|
+
serial = await transport.readSerial();
|
|
336
|
+
}
|
|
337
|
+
catch (e) {
|
|
338
|
+
rejected.push(`${d.path}: ${errText(e)}`);
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
if (!found.has(serial))
|
|
342
|
+
found.set(serial, { locationId: d.locationId, name: d.name });
|
|
343
|
+
matched = { transport, serial, locationId: d.locationId, path: d.path, name: d.name };
|
|
344
|
+
if (wantSerial && serial === wantSerial)
|
|
345
|
+
break;
|
|
346
|
+
}
|
|
347
|
+
if (wantSerial) {
|
|
348
|
+
if (matched && matched.serial === wantSerial) {
|
|
349
|
+
this.promote(matched);
|
|
350
|
+
await this.ensureWatcher();
|
|
351
|
+
return { transport: matched.transport, serial: matched.serial };
|
|
352
|
+
}
|
|
353
|
+
throw new UnknownCameraError(wantSerial, [...found.keys()]);
|
|
354
|
+
}
|
|
355
|
+
if (found.size === 0) {
|
|
356
|
+
// A helper that SAW a camera and still could not bind it is suspect —
|
|
357
|
+
// drop it rather than cache it for the next attempt.
|
|
358
|
+
//
|
|
359
|
+
// Hardware, 2026-07-21 replug: the macOS helper derives `path` from
|
|
360
|
+
// AVFoundation, whose device list lags the USB bus and, in a long-lived
|
|
361
|
+
// process, did not refresh at all. A helper spawned while the camera was
|
|
362
|
+
// unplugged therefore kept enumerating it with vid/pid but an EMPTY path
|
|
363
|
+
// — unopenable — for over two minutes, while a freshly spawned process
|
|
364
|
+
// saw the device immediately. Retrying on the cached helper could never
|
|
365
|
+
// converge; only a new process could.
|
|
366
|
+
//
|
|
367
|
+
// Deliberately UNCONDITIONAL, including when the bus looks empty.
|
|
368
|
+
//
|
|
369
|
+
// It is tempting to narrow this to "only when a candidate was rejected",
|
|
370
|
+
// since a helper reporting an empty bus looks healthy and the churn is
|
|
371
|
+
// real — the verified run forked 16 processes in 15s. That narrowing was
|
|
372
|
+
// written, unit-tested, and then REVERTED: in the hardware run that
|
|
373
|
+
// actually recovered, every retry during the recovery window reported an
|
|
374
|
+
// empty bus with NO rejected candidate, and it recovered precisely
|
|
375
|
+
// because each attempt forked a fresh process with a fresh IOKit/
|
|
376
|
+
// AVFoundation view. Keeping the helper in that case would restore the
|
|
377
|
+
// bug. A stale process can under-report the bus as empty, so "empty" is
|
|
378
|
+
// not evidence the helper is healthy.
|
|
379
|
+
//
|
|
380
|
+
// Cost is bounded: one spawn (~60ms) per FAILED bind, nothing on the
|
|
381
|
+
// happy path, and it stops as soon as a camera binds.
|
|
382
|
+
await this.discardScanHelper();
|
|
383
|
+
// That discard just closed what may have been the only process left. If
|
|
384
|
+
// the watcher had already died and nothing was bound, this failing call
|
|
385
|
+
// is the ONLY signal that the camera went away — the unplug itself was
|
|
386
|
+
// heard by nobody. Restore the listener (never the scanner, which stays
|
|
387
|
+
// unconditionally fresh) before throwing, or the replug that follows is
|
|
388
|
+
// silent too and costs a second failed call.
|
|
389
|
+
await this.ensureWatcher();
|
|
390
|
+
// With nothing attached there is nothing to explain, so that message
|
|
391
|
+
// stays exactly as it was.
|
|
392
|
+
throw new Error(rejected.length === 0
|
|
393
|
+
? "no OBSBOT camera found"
|
|
394
|
+
: `no OBSBOT camera found — ${rejected.length} candidate(s) rejected: ${rejected.join("; ")}`);
|
|
395
|
+
}
|
|
396
|
+
if (found.size > 1) {
|
|
397
|
+
throw new AmbiguousCameraError([...found.keys()]);
|
|
398
|
+
}
|
|
399
|
+
// Exactly one distinct serial: `matched` is guaranteed bound to it
|
|
400
|
+
// (every successful candidate shared that one serial).
|
|
401
|
+
this.promote(matched);
|
|
402
|
+
await this.ensureWatcher();
|
|
403
|
+
return { transport: matched.transport, serial: matched.serial };
|
|
404
|
+
}
|
|
405
|
+
/** Move the current scratch helper into the registry under `m.serial`. */
|
|
406
|
+
promote(m) {
|
|
407
|
+
// Reconnect bookkeeping BEFORE recording the bind: if we've bound this
|
|
408
|
+
// serial before, this promote is a re-bind (self-heal after a disconnect),
|
|
409
|
+
// so flag it reconnected. The very first bind of a serial is never a
|
|
410
|
+
// reconnect. everBound must NOT be gated on the registry (invalidate drops
|
|
411
|
+
// registry entries) — that's the whole point of tracking it separately.
|
|
412
|
+
if (this.everBound.has(m.serial))
|
|
413
|
+
this.reconnectedSerials.add(m.serial);
|
|
414
|
+
this.everBound.add(m.serial);
|
|
415
|
+
this.registry.set(m.serial, {
|
|
416
|
+
helper: this.scanHelper,
|
|
417
|
+
transport: m.transport,
|
|
418
|
+
locationId: m.locationId,
|
|
419
|
+
path: m.path,
|
|
420
|
+
name: m.name,
|
|
421
|
+
});
|
|
422
|
+
// This helper now belongs to the registry entry; the next scan needs
|
|
423
|
+
// its own (lazily spawned on next use).
|
|
424
|
+
this.scanHelper = undefined;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* Whether the camera identified by `serial` (or, with no arg, the single
|
|
428
|
+
* bound camera) was RE-bound after a prior bind — i.e. self-healed across a
|
|
429
|
+
* mid-session disconnect — clearing that flag on read. Single-camera
|
|
430
|
+
* semantics (no serial): return true if ANY serial is pending-reconnected,
|
|
431
|
+
* draining them all. Preserves the exact contract the retired
|
|
432
|
+
* DeviceSession.takeReconnected() had.
|
|
433
|
+
*/
|
|
434
|
+
takeReconnected(serial) {
|
|
435
|
+
if (serial !== undefined) {
|
|
436
|
+
const r = this.reconnectedSerials.has(serial);
|
|
437
|
+
this.reconnectedSerials.delete(serial);
|
|
438
|
+
return r;
|
|
439
|
+
}
|
|
440
|
+
if (this.reconnectedSerials.size === 0)
|
|
441
|
+
return false;
|
|
442
|
+
this.reconnectedSerials.clear();
|
|
443
|
+
return true;
|
|
20
444
|
}
|
|
445
|
+
/**
|
|
446
|
+
* Drop bound camera(s) from the registry so the next get()/bind() spawns a
|
|
447
|
+
* fresh scratch helper and re-scans from scratch — the correct move after
|
|
448
|
+
* a device has re-enumerated (e.g. unplug/replug), since a stale cached
|
|
449
|
+
* transport talks to a helper that may no longer own a live handle.
|
|
450
|
+
* Closing each dropped helper is best-effort: a helper whose native
|
|
451
|
+
* session already died (the common case right after a disconnect) can
|
|
452
|
+
* throw on close(), and that must not stop the entry from being dropped.
|
|
453
|
+
*
|
|
454
|
+
* serial given -> drop just that camera (no-op if it isn't bound)
|
|
455
|
+
* no serial -> drop every bound camera
|
|
456
|
+
*/
|
|
457
|
+
async invalidate(serial) {
|
|
458
|
+
const drop = async (s, entry) => {
|
|
459
|
+
try {
|
|
460
|
+
await entry.helper.close();
|
|
461
|
+
}
|
|
462
|
+
catch {
|
|
463
|
+
// best-effort — a dead/disconnected helper's close() may itself throw
|
|
464
|
+
}
|
|
465
|
+
this.registry.delete(s);
|
|
466
|
+
};
|
|
467
|
+
if (serial) {
|
|
468
|
+
const entry = this.registry.get(serial);
|
|
469
|
+
if (entry)
|
|
470
|
+
await drop(serial, entry);
|
|
471
|
+
return;
|
|
472
|
+
}
|
|
473
|
+
await Promise.all([...this.registry].map(([s, entry]) => drop(s, entry)));
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Drop registry entries whose helper process has died, so the next resolve
|
|
477
|
+
* re-binds instead of handing back a transport that can only ever fail.
|
|
478
|
+
*
|
|
479
|
+
* "Once bound, stay bound" is the right default, but a bound entry whose
|
|
480
|
+
* helper is GONE isn't a binding — it's a corpse. Without this, only the
|
|
481
|
+
* tools that route through ensureReady() (which calls invalidate()) could
|
|
482
|
+
* recover; every other tool resolved straight through get() and returned the
|
|
483
|
+
* same dead transport forever. Recovery then required the caller to happen to
|
|
484
|
+
* invoke a different tool — and the caller here is an LLM, which will instead
|
|
485
|
+
* retry the failing tool or report broken hardware that is actually fine.
|
|
486
|
+
*
|
|
487
|
+
* Deliberately a `dead` check and not a liveness probe: it costs a boolean,
|
|
488
|
+
* adds no round trip to the hot path, and leaves a LIVE binding untouched (a
|
|
489
|
+
* guard that re-scanned every call would spawn a helper per tool call).
|
|
490
|
+
*
|
|
491
|
+
* A wedged-but-alive helper is intentionally NOT caught here — that is the
|
|
492
|
+
* per-request timeout's job, and condemning a session for one slow op would
|
|
493
|
+
* throw away a working binding.
|
|
494
|
+
*/
|
|
495
|
+
async pruneDeadEntries() {
|
|
496
|
+
for (const [serial, entry] of this.registry) {
|
|
497
|
+
// `deviceLost` covers the unplug case, where the helper PROCESS is alive
|
|
498
|
+
// and healthy but the camera behind it is gone. Hardware-tested
|
|
499
|
+
// 2026-07-21: without this, a replug left every call failing forever —
|
|
500
|
+
// the handle was stranded and only killing the helper recovered it,
|
|
501
|
+
// because process death was the only condition anything watched for.
|
|
502
|
+
if (!entry.helper.isDead && !entry.helper.deviceLost)
|
|
503
|
+
continue;
|
|
504
|
+
// CLOSE, don't just forget. A device-lost helper is still RUNNING and
|
|
505
|
+
// still holding the USB device, so dropping the reference alone leaks the
|
|
506
|
+
// process and the replacement helper can never open the camera. The first
|
|
507
|
+
// cut of this fix did exactly that and turned one stranded handle into a
|
|
508
|
+
// helper leak — every retry spawned another one and recovery never
|
|
509
|
+
// happened. (A dead helper's close() is a harmless no-op, same as in
|
|
510
|
+
// invalidate(), which has always closed-then-deleted for this reason.)
|
|
511
|
+
try {
|
|
512
|
+
await entry.helper.close();
|
|
513
|
+
}
|
|
514
|
+
catch {
|
|
515
|
+
// best-effort — a disconnected helper's close() may itself throw
|
|
516
|
+
}
|
|
517
|
+
this.registry.delete(serial);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
/**
|
|
521
|
+
* Resolve to a bound transport.
|
|
522
|
+
* no serial + one camera attached -> bind & return it
|
|
523
|
+
* no serial + several attached -> AmbiguousCameraError
|
|
524
|
+
* serial given + match -> bind (lazily) & return
|
|
525
|
+
* serial given + no match -> UnknownCameraError
|
|
526
|
+
* Already-bound cameras are returned directly from the registry without
|
|
527
|
+
* rescanning — "bind lazily" means once bound, stay bound.
|
|
528
|
+
*/
|
|
529
|
+
async get(serial) {
|
|
530
|
+
await this.pruneDeadEntries();
|
|
531
|
+
if (serial) {
|
|
532
|
+
const existing = this.registry.get(serial);
|
|
533
|
+
if (existing)
|
|
534
|
+
return existing.transport;
|
|
535
|
+
const { transport } = await this.bind(serial);
|
|
536
|
+
return transport;
|
|
537
|
+
}
|
|
538
|
+
if (this.registry.size === 1) {
|
|
539
|
+
return [...this.registry.values()][0].transport;
|
|
540
|
+
}
|
|
541
|
+
if (this.registry.size > 1) {
|
|
542
|
+
throw new AmbiguousCameraError([...this.registry.keys()]);
|
|
543
|
+
}
|
|
544
|
+
const { transport } = await this.bind();
|
|
545
|
+
return transport;
|
|
546
|
+
}
|
|
547
|
+
/**
|
|
548
|
+
* The OS reported a camera detached. Drop any binding on that path now,
|
|
549
|
+
* instead of waiting for a tool call to fail against a dead handle.
|
|
550
|
+
*
|
|
551
|
+
* Without this the registry stays authoritative until something errors, so
|
|
552
|
+
* `obsbot_devices` reports a phantom `bound` entry — serial and all — for a
|
|
553
|
+
* camera physically sitting on the desk. The prune is otherwise reactive:
|
|
554
|
+
* `deviceLost` is only set when an op fails, and listCameras() deliberately
|
|
555
|
+
* does not re-open bound entries to check.
|
|
556
|
+
*
|
|
557
|
+
* Closes the helper rather than merely forgetting it, for the same reason
|
|
558
|
+
* pruneDeadEntries() does: a helper left running still holds the device, so
|
|
559
|
+
* the replacement could never open it.
|
|
560
|
+
*/
|
|
561
|
+
async handleCameraDeparted(e) {
|
|
562
|
+
for (const [serial, entry] of this.registry) {
|
|
563
|
+
if (entry.path !== e.path)
|
|
564
|
+
continue;
|
|
565
|
+
try {
|
|
566
|
+
await entry.helper.close();
|
|
567
|
+
}
|
|
568
|
+
catch {
|
|
569
|
+
// best-effort — a disconnected helper's close() may itself throw
|
|
570
|
+
}
|
|
571
|
+
this.registry.delete(serial);
|
|
572
|
+
}
|
|
573
|
+
// The close above may have removed the last live subscriber, and the arrival
|
|
574
|
+
// this departure pairs with has not happened yet. Replacing the watcher here
|
|
575
|
+
// matters when it died unnoticed while the camera was bound: the registry
|
|
576
|
+
// helper was still alive to hear THIS event, but nothing would be alive to
|
|
577
|
+
// hear the next one.
|
|
578
|
+
//
|
|
579
|
+
// The replacement is necessarily spawned with the camera absent, which
|
|
580
|
+
// primes it on macOS but not on Windows — see ensureWatcher(). On Windows
|
|
581
|
+
// this degrades to the failure-driven path and the next successful bind
|
|
582
|
+
// re-primes it, so the call is worth making on both.
|
|
583
|
+
await this.ensureWatcher();
|
|
584
|
+
}
|
|
585
|
+
/**
|
|
586
|
+
* The OS reported a camera attached. Re-establish a binding this process
|
|
587
|
+
* ALREADY held — a genuine self-heal after a replug — and nothing more.
|
|
588
|
+
*
|
|
589
|
+
* Deliberately does not bind a camera we never had. The Tiny 2 is a device
|
|
590
|
+
* Zoom, OBS and OBSBOT Center also want, and a server that grabbed it the
|
|
591
|
+
* moment it appeared would make it busy for them even if nobody ever used
|
|
592
|
+
* this server. `everBound` is the record of what was ours; it survives
|
|
593
|
+
* invalidate() precisely so a re-bind can be recognised as a reconnect.
|
|
594
|
+
*
|
|
595
|
+
* Never throws: the caller is a stdout line handler, and an unhandled
|
|
596
|
+
* rejection there would take down the reader and wedge every in-flight
|
|
597
|
+
* request. A failed re-bind just leaves the next tool call to bind normally.
|
|
598
|
+
*/
|
|
599
|
+
async handleCameraArrived(_e) {
|
|
600
|
+
if (this.everBound.size === 0)
|
|
601
|
+
return; // never ours — stay hands-off
|
|
602
|
+
// Something is already bound, so there is nothing to restore.
|
|
603
|
+
if (this.registry.size > 0)
|
|
604
|
+
return;
|
|
605
|
+
// One ladder at a time. Two would double the helper spawns and race two
|
|
606
|
+
// binds into promote(), which assumes the scratch helper is still there.
|
|
607
|
+
if (this.rebinding)
|
|
608
|
+
return;
|
|
609
|
+
this.rebinding = true;
|
|
610
|
+
const total = this.arrivalBackoffMs.length;
|
|
611
|
+
try {
|
|
612
|
+
for (let i = 0; i < total; i++) {
|
|
613
|
+
const delay = this.arrivalBackoffMs[i];
|
|
614
|
+
if (delay > 0)
|
|
615
|
+
await new Promise((r) => setTimeout(r, delay));
|
|
616
|
+
// A tool call may have bound it while we waited — it got there first.
|
|
617
|
+
if (this.registry.size > 0)
|
|
618
|
+
return;
|
|
619
|
+
try {
|
|
620
|
+
await this.bind();
|
|
621
|
+
// Silent on the happy path; every replug would otherwise print. A
|
|
622
|
+
// retried success is worth a line precisely because it is the only
|
|
623
|
+
// evidence the ladder ever does anything.
|
|
624
|
+
if (i > 0)
|
|
625
|
+
this.log(`obsbot-mcp: arrival re-bind succeeded on attempt ${i + 1}`);
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
catch (e) {
|
|
629
|
+
// Keep trying: see the backoff's own comment for why one attempt is
|
|
630
|
+
// a coin flip. Arrival is still only a hint — if the ladder runs out
|
|
631
|
+
// the next tool call binds normally.
|
|
632
|
+
this.log(`obsbot-mcp: arrival re-bind attempt ${i + 1}/${total} failed: ${errText(e)}`);
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
this.log(`obsbot-mcp: arrival re-bind gave up after ${total} attempts; ` +
|
|
636
|
+
`the next tool call will bind normally`);
|
|
637
|
+
}
|
|
638
|
+
finally {
|
|
639
|
+
this.rebinding = false;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Close everything this manager is holding: registry helpers, the scratch
|
|
644
|
+
* scanner, and the watcher.
|
|
645
|
+
*
|
|
646
|
+
* Helpers already self-terminate when the parent goes away — they exit on
|
|
647
|
+
* stdin EOF — so this is not what stops them leaking on a crash. It exists
|
|
648
|
+
* because the watcher is the first helper deliberately built to outlive every
|
|
649
|
+
* operation: nothing else bounds its lifetime, so an orderly stop needs
|
|
650
|
+
* something explicit to call. Best-effort throughout; a shutdown path that can
|
|
651
|
+
* itself throw is worse than one that leaves a process for the OS to reap.
|
|
652
|
+
*/
|
|
653
|
+
async shutdown() {
|
|
654
|
+
const close = async (h) => {
|
|
655
|
+
if (!h)
|
|
656
|
+
return;
|
|
657
|
+
try {
|
|
658
|
+
await h.close();
|
|
659
|
+
}
|
|
660
|
+
catch {
|
|
661
|
+
// best-effort — a disconnected helper's close() may itself throw
|
|
662
|
+
}
|
|
663
|
+
};
|
|
664
|
+
const watcher = this.watcher;
|
|
665
|
+
this.watcher = undefined;
|
|
666
|
+
await Promise.all([
|
|
667
|
+
...[...this.registry.values()].map((e) => close(e.helper)),
|
|
668
|
+
this.discardScanHelper(),
|
|
669
|
+
close(watcher),
|
|
670
|
+
]);
|
|
671
|
+
this.registry.clear();
|
|
672
|
+
}
|
|
673
|
+
/** Compat shim for the single-camera API B1 retires. */
|
|
21
674
|
async openFirstObsbot() {
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
675
|
+
return this.get();
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* Per attached camera: serial (where obtainable), locationId, name, and
|
|
679
|
+
* status. A camera this process cannot open is reported `busy` WITHOUT a
|
|
680
|
+
* serial rather than omitted — it is enumerable but not identifiable.
|
|
681
|
+
* Already-bound cameras are reported from the registry without
|
|
682
|
+
* re-opening (avoids a pointless self-conflict against our own handle).
|
|
683
|
+
*/
|
|
684
|
+
async listCameras() {
|
|
685
|
+
// Registry entries are reported as `bound` without re-opening them, so a
|
|
686
|
+
// stale one is indistinguishable from a healthy camera. Observed on
|
|
687
|
+
// hardware 2026-07-21: obsbot_devices kept reporting status:"bound" with a
|
|
688
|
+
// serial for a camera that was physically UNPLUGGED — a caller reads that
|
|
689
|
+
// as "present and ready", which is exactly backwards.
|
|
690
|
+
await this.pruneDeadEntries();
|
|
691
|
+
const results = [];
|
|
692
|
+
const seenSerials = new Set();
|
|
693
|
+
const boundLocationIds = new Set();
|
|
694
|
+
const boundPaths = new Set();
|
|
695
|
+
for (const [serial, entry] of this.registry) {
|
|
696
|
+
results.push({ serial, locationId: entry.locationId, name: entry.name, status: "bound" });
|
|
697
|
+
seenSerials.add(serial);
|
|
698
|
+
if (entry.locationId !== undefined)
|
|
699
|
+
boundLocationIds.add(entry.locationId);
|
|
700
|
+
boundPaths.add(entry.path);
|
|
701
|
+
}
|
|
702
|
+
const helper = await this.getScanHelper();
|
|
703
|
+
const devices = await helper.enumerate();
|
|
704
|
+
const candidates = devices.filter(isObsbotCamera);
|
|
705
|
+
for (const d of candidates) {
|
|
706
|
+
// locationId is macOS-only (undefined on Linux/Windows); `path` is
|
|
707
|
+
// populated on every platform, so it's the dedup key that actually
|
|
708
|
+
// works cross-platform. Without it, a bound camera off-macOS gets
|
|
709
|
+
// re-opened here, collides with the registry helper's own held-open
|
|
710
|
+
// handle, and is double-reported a second time as a serial-less
|
|
711
|
+
// "busy" entry alongside its correct "bound" one.
|
|
712
|
+
if (boundPaths.has(d.path)) {
|
|
713
|
+
continue; // already reported as bound above
|
|
714
|
+
}
|
|
715
|
+
if (d.locationId !== undefined && boundLocationIds.has(d.locationId)) {
|
|
716
|
+
continue; // already reported as bound above
|
|
717
|
+
}
|
|
32
718
|
try {
|
|
33
|
-
const xuNode = await
|
|
34
|
-
if (xuNode
|
|
35
|
-
|
|
36
|
-
|
|
719
|
+
const xuNode = await helper.open(d.path);
|
|
720
|
+
if (xuNode < 0)
|
|
721
|
+
continue;
|
|
722
|
+
const transport = this.createTransport(helper);
|
|
723
|
+
const serial = await transport.readSerial();
|
|
724
|
+
if (seenSerials.has(serial))
|
|
725
|
+
continue; // duplicate node of an already-listed camera
|
|
726
|
+
seenSerials.add(serial);
|
|
727
|
+
results.push({ serial, locationId: d.locationId, name: d.name, status: "available" });
|
|
37
728
|
}
|
|
38
729
|
catch (e) {
|
|
39
|
-
|
|
730
|
+
results.push({
|
|
731
|
+
locationId: d.locationId,
|
|
732
|
+
name: d.name,
|
|
733
|
+
status: "busy",
|
|
734
|
+
reason: errText(e),
|
|
735
|
+
});
|
|
40
736
|
}
|
|
41
737
|
}
|
|
42
|
-
|
|
738
|
+
return results;
|
|
43
739
|
}
|
|
44
740
|
}
|
|
45
741
|
//# sourceMappingURL=manager.js.map
|