obsbot-mcp 0.6.3 → 0.8.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.
Files changed (71) hide show
  1. package/README.md +97 -20
  2. package/dist/build-info.json +5 -0
  3. package/dist/device/manager.d.ts +8 -0
  4. package/dist/device/manager.js +37 -4
  5. package/dist/device/manager.js.map +1 -1
  6. package/dist/geometry/aim.d.ts +24 -13
  7. package/dist/geometry/aim.js +26 -13
  8. package/dist/geometry/aim.js.map +1 -1
  9. package/dist/ipc/build-id.d.ts +25 -0
  10. package/dist/ipc/build-id.js +60 -0
  11. package/dist/ipc/build-id.js.map +1 -0
  12. package/dist/ipc/client.d.ts +36 -0
  13. package/dist/ipc/client.js +106 -4
  14. package/dist/ipc/client.js.map +1 -1
  15. package/dist/ipc/coordinator.d.ts +91 -12
  16. package/dist/ipc/coordinator.js +293 -57
  17. package/dist/ipc/coordinator.js.map +1 -1
  18. package/dist/ipc/gate.d.ts +14 -0
  19. package/dist/ipc/gate.js +45 -0
  20. package/dist/ipc/gate.js.map +1 -0
  21. package/dist/ipc/owner.d.ts +27 -1
  22. package/dist/ipc/owner.js +67 -4
  23. package/dist/ipc/owner.js.map +1 -1
  24. package/dist/ipc/protocol.d.ts +30 -0
  25. package/dist/ipc/protocol.js +40 -0
  26. package/dist/ipc/protocol.js.map +1 -1
  27. package/dist/ipc/rendezvous.d.ts +37 -11
  28. package/dist/ipc/rendezvous.js +143 -30
  29. package/dist/ipc/rendezvous.js.map +1 -1
  30. package/dist/mcp/server.js +40 -12
  31. package/dist/mcp/server.js.map +1 -1
  32. package/dist/mcp/tools.d.ts +6 -0
  33. package/dist/mcp/tools.js +123 -17
  34. package/dist/mcp/tools.js.map +1 -1
  35. package/dist/tail2/api.d.ts +218 -0
  36. package/dist/tail2/api.js +267 -0
  37. package/dist/tail2/api.js.map +1 -0
  38. package/dist/tail2/mdns.d.ts +48 -0
  39. package/dist/tail2/mdns.js +216 -0
  40. package/dist/tail2/mdns.js.map +1 -0
  41. package/dist/tail2/registry.d.ts +100 -0
  42. package/dist/tail2/registry.js +200 -0
  43. package/dist/tail2/registry.js.map +1 -0
  44. package/dist/tail2/snapshot.d.ts +54 -0
  45. package/dist/tail2/snapshot.js +157 -0
  46. package/dist/tail2/snapshot.js.map +1 -0
  47. package/dist/tail2/tools.d.ts +7 -0
  48. package/dist/tail2/tools.js +370 -0
  49. package/dist/tail2/tools.js.map +1 -0
  50. package/dist/transport/helper-process.d.ts +11 -0
  51. package/dist/transport/helper-process.js +10 -0
  52. package/dist/transport/helper-process.js.map +1 -1
  53. package/dist/transport/linux.d.ts +59 -9
  54. package/dist/transport/linux.js +163 -21
  55. package/dist/transport/linux.js.map +1 -1
  56. package/dist/transport/macos.d.ts +8 -0
  57. package/dist/transport/macos.js +11 -0
  58. package/dist/transport/macos.js.map +1 -1
  59. package/dist/transport/transport.d.ts +22 -0
  60. package/dist/transport/transport.js +20 -0
  61. package/dist/transport/transport.js.map +1 -1
  62. package/dist/transport/windows.d.ts +6 -0
  63. package/dist/transport/windows.js +9 -0
  64. package/dist/transport/windows.js.map +1 -1
  65. package/dist/version.d.ts +1 -1
  66. package/dist/version.js +1 -1
  67. package/native/prebuilt/darwin-arm64/obsbot-helper +0 -0
  68. package/native/prebuilt/darwin-x64/obsbot-helper +0 -0
  69. package/native/prebuilt/linux-x64/obsbot-helper +0 -0
  70. package/native/prebuilt/win32-x64/obsbot-helper.exe +0 -0
  71. package/package.json +4 -1
@@ -0,0 +1,216 @@
1
+ import dgram from "node:dgram";
2
+ import { networkInterfaces } from "node:os";
3
+ const GROUP = "224.0.0.251";
4
+ const MDNS_PORT = 5353;
5
+ const MAC_RE = /^[0-9a-f]{2}(:[0-9a-f]{2}){5}$/;
6
+ const isUsefulHost = (h) => h !== "" && h !== "0.0.0.0" && h !== "255.255.255.255";
7
+ // ---------------------------------------------------------------------------
8
+ // DNS packet parsing — just enough for mDNS response packets: questions are
9
+ // skipped, answer names are decoded (compression pointers included), and TXT
10
+ // records are unpacked into their length-prefixed strings. SRV/A/AAAA are
11
+ // tolerated structurally but only TXT carries the Tail 2 digest.
12
+ // ---------------------------------------------------------------------------
13
+ /** Decode a (possibly compressed) DNS name. Returns the name and the offset
14
+ * just past it in the ORIGINAL section — after a compression pointer the
15
+ * record's fixed fields continue right after the pointer itself. */
16
+ function readName(buf, off) {
17
+ const labels = [];
18
+ let jumped = false;
19
+ let end = off;
20
+ let pos = off;
21
+ let jumps = 0;
22
+ while (true) {
23
+ if (pos >= buf.length)
24
+ return { name: "", end: buf.length };
25
+ const len = buf[pos];
26
+ if (len === 0) {
27
+ if (!jumped)
28
+ end = pos + 1;
29
+ break;
30
+ }
31
+ if ((len & 0xc0) === 0xc0) {
32
+ if (pos + 1 >= buf.length)
33
+ return { name: "", end: buf.length };
34
+ const ptr = ((len & 0x3f) << 8) | buf[pos + 1];
35
+ if (!jumped)
36
+ end = pos + 2;
37
+ if (++jumps > 32)
38
+ return { name: "", end: buf.length }; // pointer loop
39
+ pos = ptr;
40
+ jumped = true;
41
+ continue;
42
+ }
43
+ if (pos + 1 + len > buf.length)
44
+ return { name: "", end: buf.length };
45
+ labels.push(buf.subarray(pos + 1, pos + 1 + len).toString("latin1"));
46
+ pos += 1 + len;
47
+ }
48
+ return { name: labels.join("."), end };
49
+ }
50
+ /** Unpack one packet's TXT strings; other record types are skipped by RDLENGTH. */
51
+ function txtStringsOf(buf) {
52
+ const strings = [];
53
+ if (buf.length < 12)
54
+ return strings;
55
+ const qd = buf.readUInt16BE(4);
56
+ const an = buf.readUInt16BE(6);
57
+ let pos = 12;
58
+ for (let i = 0; i < qd; i++) {
59
+ const { end } = readName(buf, pos);
60
+ pos = end + 4; // QTYPE + QCLASS
61
+ if (pos > buf.length)
62
+ return strings;
63
+ }
64
+ for (let i = 0; i < an; i++) {
65
+ const { end } = readName(buf, pos);
66
+ pos = end;
67
+ if (pos + 10 > buf.length)
68
+ return strings;
69
+ const type = buf.readUInt16BE(pos);
70
+ const rdLength = buf.readUInt16BE(pos + 8);
71
+ const rdata = buf.subarray(pos + 10, pos + 10 + rdLength);
72
+ pos += 10 + rdLength;
73
+ if (type === 16) {
74
+ // TXT RDATA: a sequence of <1-byte length><bytes> strings.
75
+ let p = 0;
76
+ while (p < rdata.length) {
77
+ const l = rdata[p];
78
+ if (p + 1 + l > rdata.length)
79
+ break;
80
+ strings.push(rdata.subarray(p + 1, p + 1 + l).toString("utf8"));
81
+ p += 1 + l;
82
+ }
83
+ }
84
+ }
85
+ return strings;
86
+ }
87
+ /**
88
+ * Extract a Tail 2 announcement from one mDNS packet, or null for anything
89
+ * else on the wire. Detection is by the digest's own shape — TXT strings that
90
+ * JSON-parse into an object carrying a `ble_mac` (the registry's identity
91
+ * MAC) plus a reachable IP — so other devices' mDNS traffic parses to nothing
92
+ * and registers nothing. `srcIp` is where the packet came from; it becomes a
93
+ * host of last resort and a live-reachability hint.
94
+ */
95
+ export function parseTail2Announcement(buf, srcIp) {
96
+ const merged = {};
97
+ for (const s of txtStringsOf(buf)) {
98
+ if (!s.startsWith("{"))
99
+ continue;
100
+ try {
101
+ const v = JSON.parse(s);
102
+ if (v && typeof v === "object" && !Array.isArray(v)) {
103
+ Object.assign(merged, v);
104
+ }
105
+ }
106
+ catch {
107
+ // Someone else's TXT string that merely looks like JSON — ignore.
108
+ }
109
+ }
110
+ const mac = typeof merged.ble_mac === "string" ? merged.ble_mac.toLowerCase() : "";
111
+ if (!MAC_RE.test(mac))
112
+ return null;
113
+ const hosts = [];
114
+ for (const h of [merged.wired_ip, merged.wireless_ip]) {
115
+ if (typeof h === "string" && isUsefulHost(h) && !hosts.includes(h))
116
+ hosts.push(h);
117
+ }
118
+ if (srcIp && isUsefulHost(srcIp) && !hosts.includes(srcIp))
119
+ hosts.push(srcIp);
120
+ if (hosts.length === 0)
121
+ return null;
122
+ let name = mac;
123
+ if (typeof merged.device_name === "string" && /^[0-9a-f]+$/i.test(merged.device_name)) {
124
+ const decoded = Buffer.from(merged.device_name, "hex").toString("utf8");
125
+ if (decoded !== "")
126
+ name = decoded;
127
+ }
128
+ return { mac, name, hosts };
129
+ }
130
+ /** Accumulates announcements across packets, keyed by MAC. Testable without
131
+ * sockets: push() buffers, results() the deduplicated list. */
132
+ export function createAnnouncementCollector() {
133
+ const byMac = new Map();
134
+ return {
135
+ push(buf, srcIp) {
136
+ const a = parseTail2Announcement(buf, srcIp);
137
+ if (!a)
138
+ return;
139
+ const existing = byMac.get(a.mac);
140
+ if (existing) {
141
+ for (const h of a.hosts)
142
+ if (!existing.hosts.includes(h))
143
+ existing.hosts.push(h);
144
+ }
145
+ else {
146
+ byMac.set(a.mac, a);
147
+ }
148
+ },
149
+ results() {
150
+ return [...byMac.values()];
151
+ },
152
+ };
153
+ }
154
+ /**
155
+ * Listen for Tail 2 announcements for `ms` milliseconds (the camera repeats
156
+ * every few seconds, so 5s is ample) and return what was heard. Never throws:
157
+ * a bind failure or a network without multicast yields an empty list, and the
158
+ * caller falls back to the HTTP sweep.
159
+ */
160
+ export function listenForTail2Announcements(ms = 5000) {
161
+ return new Promise((resolve) => {
162
+ const collector = createAnnouncementCollector();
163
+ let socket = null;
164
+ let finished = false;
165
+ const finish = () => {
166
+ if (finished)
167
+ return;
168
+ finished = true;
169
+ clearTimeout(timer);
170
+ if (socket) {
171
+ socket.removeAllListeners();
172
+ try {
173
+ socket.dropMembership(GROUP);
174
+ }
175
+ catch {
176
+ // Already gone.
177
+ }
178
+ socket.close();
179
+ }
180
+ resolve(collector.results());
181
+ };
182
+ const timer = setTimeout(finish, ms);
183
+ try {
184
+ socket = dgram.createSocket({ type: "udp4", reuseAddr: true });
185
+ socket.on("message", (buf, rinfo) => {
186
+ collector.push(buf, rinfo.address);
187
+ });
188
+ socket.on("error", finish);
189
+ socket.bind(MDNS_PORT, () => {
190
+ const s = socket;
191
+ try {
192
+ s.addMembership(GROUP);
193
+ }
194
+ catch {
195
+ // Default-interface join failed; explicit per-interface joins below.
196
+ }
197
+ for (const addrs of Object.values(networkInterfaces())) {
198
+ for (const a of addrs ?? []) {
199
+ if (a.family !== "IPv4" || a.internal)
200
+ continue;
201
+ try {
202
+ s.addMembership(GROUP, a.address);
203
+ }
204
+ catch {
205
+ // One unusable interface must not cost the others.
206
+ }
207
+ }
208
+ }
209
+ });
210
+ }
211
+ catch {
212
+ finish();
213
+ }
214
+ });
215
+ }
216
+ //# sourceMappingURL=mdns.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"mdns.js","sourceRoot":"","sources":["../../src/tail2/mdns.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,100 @@
1
+ import { Tail2Api } from "./api.js";
2
+ import { type Tail2Announcement } from "./mdns.js";
3
+ /**
4
+ * MAC-keyed registry of attached OBSBOT Tail 2 cameras.
5
+ *
6
+ * The Tiny 2's DeviceManager discovers cameras over USB and identifies them
7
+ * by serial; the Tail 2 reports no USB serial number in either of its USB-C
8
+ * modes (MTP or UVC — TAIL2-PROTOCOL.md §1, §11), so a Tail
9
+ * 2's identity is its MAC, exactly as OBSBOT Center treats it. Entries are
10
+ * created by probing a host with the discovery handshake (GET device_info,
11
+ * then WS hello — this module only needs the first half) and can be added
12
+ * four ways:
13
+ *
14
+ * - the OBSBOT_TAIL2_HOSTS environment variable (comma-separated hosts),
15
+ * for a stable, known network;
16
+ * - the obsbot_tail2_scan tool, which listens for the camera's mDNS
17
+ * announcements (~5s) and falls back to sweeping the local /24(s);
18
+ * - the camera's own periodic mDNS announcement, which names its MAC, its
19
+ * name and both its IPs — no probe needed, the camera just said so;
20
+ * - resolve()/get() with an explicit host, which registers on first use.
21
+ *
22
+ * "Registered" does NOT mean "verified reachable right now" — entries persist
23
+ * across a camera going offline, and the next operation against its API fails
24
+ * with the actionable unreachable error rather than a mystery. Re-registration
25
+ * by MAC is idempotent and merges new candidate hosts into the entry.
26
+ */
27
+ export interface Tail2Entry {
28
+ mac: string;
29
+ name: string;
30
+ /** Every host this camera has answered on (wired and wireless IPs). */
31
+ hosts: string[];
32
+ api: Tail2Api;
33
+ }
34
+ /** Mirrors DeviceManager's contract: same shapes, same caller expectations. */
35
+ export declare class AmbiguousTail2Error extends Error {
36
+ readonly available: string[];
37
+ constructor(available: string[]);
38
+ }
39
+ export declare class Tail2Registry {
40
+ private readonly byMac;
41
+ private readonly probeTimeoutMs;
42
+ private readonly makeApi;
43
+ constructor(opts?: {
44
+ probeTimeoutMs?: number;
45
+ makeApi?: (host: string) => Tail2Api;
46
+ });
47
+ /**
48
+ * Seed from `OBSBOT_TAIL2_HOSTS` (comma-separated; blank entries ignored).
49
+ * The opts passthrough exists for tests; production calls it bare.
50
+ */
51
+ static fromEnv(env?: Record<string, string | undefined>, opts?: {
52
+ probeTimeoutMs?: number;
53
+ makeApi?: (host: string) => Tail2Api;
54
+ }): Tail2Registry;
55
+ /**
56
+ * Probe one host; register it if it answers as a Tail 2. Returns the entry,
57
+ * or null when nothing Tail-2-shaped answers there (wrong device, plain
58
+ * HTTP server, or silence) — a scan probes hundreds of addresses and
59
+ * "not here" is the normal outcome, not an error.
60
+ */
61
+ addHost(host: string): Promise<Tail2Entry | null>;
62
+ /** Resolve the `camera` selector: MAC, device_name, or host — case-insensitive. */
63
+ resolve(camera?: string): Promise<Tail2Entry>;
64
+ list(): Tail2Entry[];
65
+ /**
66
+ * Register straight from the camera's own mDNS announcement — no probe.
67
+ * The digest carries the MAC, the name and both IPs, and the packet arrived
68
+ * seconds ago, so it is better evidence than a probe that has not run. The
69
+ * API binds to the wired IP first (the wireless IP is often the camera's
70
+ * own AP subnet, 192.168.55.x, unroutable from here).
71
+ */
72
+ addAnnouncement(a: Tail2Announcement): Tail2Entry;
73
+ /**
74
+ * Discovery: listen for the camera's mDNS announcements (its own control
75
+ * channel — it multicasts a device digest every few seconds), and fall back
76
+ * to the HTTP subnet sweep only when nothing was heard, for networks where
77
+ * multicast is filtered. `OBSBOT_TAIL2_MDNS=0` skips the listen.
78
+ */
79
+ scan(opts?: {
80
+ mdnsMs?: number;
81
+ listen?: (ms: number) => Promise<Tail2Announcement[]>;
82
+ sweep?: boolean;
83
+ subnets?: string[];
84
+ concurrency?: number;
85
+ }): Promise<Tail2Entry[]>;
86
+ /**
87
+ * Sweep every IPv4 /24 this machine belongs to (excluding loopback) for
88
+ * Tail 2 cameras, registering what answers. The mDNS listen in scan()
89
+ * covers every network the camera's announcements can reach; this sweep is
90
+ * the fallback for the rest — an HTTP GET of /camera/sdk/device_info per
91
+ * candidate address, the same probe OBSBOT Center's discovery uses, so the
92
+ * traffic pattern is one the camera expects to see. Bounded concurrency
93
+ * keeps the sweep from socket-bombing the host; per-address timeouts keep
94
+ * one slow device from stretching the scan.
95
+ */
96
+ scanSubnet(opts?: {
97
+ concurrency?: number;
98
+ subnets?: string[];
99
+ }): Promise<Tail2Entry[]>;
100
+ }
@@ -0,0 +1,200 @@
1
+ import { networkInterfaces } from "node:os";
2
+ import { Tail2Api } from "./api.js";
3
+ import { listenForTail2Announcements } from "./mdns.js";
4
+ /** Mirrors DeviceManager's contract: same shapes, same caller expectations. */
5
+ export class AmbiguousTail2Error extends Error {
6
+ available;
7
+ constructor(available) {
8
+ super(`multiple Tail 2 cameras registered; specify one of: ${available.join(", ")}`);
9
+ this.name = "AmbiguousTail2Error";
10
+ this.available = available;
11
+ }
12
+ }
13
+ const NO_CAMERA_HINT = `no Tail 2 camera is registered. Add one via the obsbot_tail2_scan tool (sweeps the ` +
14
+ `local network), or set OBSBOT_TAIL2_HOSTS to its address(es), or pass its host as the ` +
15
+ `camera parameter on any Tail 2 tool`;
16
+ export class Tail2Registry {
17
+ byMac = new Map();
18
+ probeTimeoutMs;
19
+ makeApi;
20
+ constructor(opts = {}) {
21
+ this.probeTimeoutMs = opts.probeTimeoutMs ?? 2500;
22
+ this.makeApi =
23
+ opts.makeApi ?? ((host) => new Tail2Api({ baseUrl: host, timeoutMs: this.probeTimeoutMs }));
24
+ }
25
+ /**
26
+ * Seed from `OBSBOT_TAIL2_HOSTS` (comma-separated; blank entries ignored).
27
+ * The opts passthrough exists for tests; production calls it bare.
28
+ */
29
+ static fromEnv(env = process.env, opts = {}) {
30
+ const reg = new Tail2Registry(opts);
31
+ const hosts = (env.OBSBOT_TAIL2_HOSTS ?? "")
32
+ .split(",")
33
+ .map((h) => h.trim())
34
+ .filter((h) => h !== "");
35
+ for (const h of hosts)
36
+ void reg.addHost(h);
37
+ // addHost is async; seeding is best-effort and races first use harmlessly
38
+ // (resolve() re-probes nothing, but a tool call coming in before the probe
39
+ // finishes simply doesn't see the entry yet and reports "not registered").
40
+ return reg;
41
+ }
42
+ /**
43
+ * Probe one host; register it if it answers as a Tail 2. Returns the entry,
44
+ * or null when nothing Tail-2-shaped answers there (wrong device, plain
45
+ * HTTP server, or silence) — a scan probes hundreds of addresses and
46
+ * "not here" is the normal outcome, not an error.
47
+ */
48
+ async addHost(host) {
49
+ const api = this.makeApi(host);
50
+ let info;
51
+ try {
52
+ info = await api.info();
53
+ }
54
+ catch {
55
+ return null;
56
+ }
57
+ if (typeof info.mac !== "string" || info.mac === "" || !info.device_name)
58
+ return null;
59
+ const mac = info.mac.toLowerCase();
60
+ const existing = this.byMac.get(mac);
61
+ if (existing) {
62
+ // Same camera reachable at another address (wired + wireless): keep both.
63
+ if (!existing.hosts.includes(host))
64
+ existing.hosts.push(host);
65
+ return existing;
66
+ }
67
+ const entry = { mac, name: info.device_name, hosts: [host], api };
68
+ this.byMac.set(mac, entry);
69
+ return entry;
70
+ }
71
+ /** Resolve the `camera` selector: MAC, device_name, or host — case-insensitive. */
72
+ async resolve(camera) {
73
+ if (camera === undefined || camera === "") {
74
+ const all = [...this.byMac.values()];
75
+ if (all.length === 1)
76
+ return all[0];
77
+ if (all.length > 1)
78
+ throw new AmbiguousTail2Error(all.map((e) => e.mac));
79
+ throw new Error(NO_CAMERA_HINT);
80
+ }
81
+ const want = camera.toLowerCase();
82
+ for (const e of this.byMac.values()) {
83
+ if (e.mac === want || e.name.toLowerCase() === want)
84
+ return e;
85
+ if (e.hosts.some((h) => h.toLowerCase() === want))
86
+ return e;
87
+ }
88
+ // Not registered — but if the selector IS a reachable host, register it.
89
+ // This makes "camera: 192.168.0.132" work on a cold server with no env
90
+ // config and no scan, which is the fastest path from install to control.
91
+ const byHost = await this.addHost(camera);
92
+ if (byHost)
93
+ return byHost;
94
+ const available = [...this.byMac.values()].map((e) => e.mac);
95
+ throw new Error(`unknown Tail 2 camera "${camera}"; available: ` +
96
+ (available.length ? available.join(", ") : "(none registered — run obsbot_tail2_scan)") +
97
+ `. If "${camera}" is a host, nothing Tail 2 answered there.`);
98
+ }
99
+ list() {
100
+ return [...this.byMac.values()];
101
+ }
102
+ /**
103
+ * Register straight from the camera's own mDNS announcement — no probe.
104
+ * The digest carries the MAC, the name and both IPs, and the packet arrived
105
+ * seconds ago, so it is better evidence than a probe that has not run. The
106
+ * API binds to the wired IP first (the wireless IP is often the camera's
107
+ * own AP subnet, 192.168.55.x, unroutable from here).
108
+ */
109
+ addAnnouncement(a) {
110
+ const mac = a.mac.toLowerCase();
111
+ const existing = this.byMac.get(mac);
112
+ if (existing) {
113
+ for (const h of a.hosts)
114
+ if (!existing.hosts.includes(h))
115
+ existing.hosts.push(h);
116
+ return existing;
117
+ }
118
+ const entry = {
119
+ mac,
120
+ name: a.name,
121
+ hosts: [...a.hosts],
122
+ api: this.makeApi(a.hosts[0] ?? mac),
123
+ };
124
+ this.byMac.set(mac, entry);
125
+ return entry;
126
+ }
127
+ /**
128
+ * Discovery: listen for the camera's mDNS announcements (its own control
129
+ * channel — it multicasts a device digest every few seconds), and fall back
130
+ * to the HTTP subnet sweep only when nothing was heard, for networks where
131
+ * multicast is filtered. `OBSBOT_TAIL2_MDNS=0` skips the listen.
132
+ */
133
+ async scan(opts = {}) {
134
+ let heard = [];
135
+ // An injected listener (tests) always runs; the env kill-switch governs
136
+ // only the default one.
137
+ const listen = opts.listen ?? (process.env.OBSBOT_TAIL2_MDNS === "0" ? null : listenForTail2Announcements);
138
+ if (listen) {
139
+ try {
140
+ heard = await listen(opts.mdnsMs ?? 5000);
141
+ }
142
+ catch {
143
+ heard = []; // listening is best-effort; the sweep still covers it
144
+ }
145
+ }
146
+ const found = new Map();
147
+ for (const a of heard) {
148
+ const e = this.addAnnouncement(a);
149
+ found.set(e.mac, e);
150
+ }
151
+ if (heard.length > 0 || opts.sweep === false)
152
+ return [...found.values()];
153
+ const swept = await this.scanSubnet({ subnets: opts.subnets, concurrency: opts.concurrency });
154
+ for (const e of swept)
155
+ found.set(e.mac, e);
156
+ return [...found.values()];
157
+ }
158
+ /**
159
+ * Sweep every IPv4 /24 this machine belongs to (excluding loopback) for
160
+ * Tail 2 cameras, registering what answers. The mDNS listen in scan()
161
+ * covers every network the camera's announcements can reach; this sweep is
162
+ * the fallback for the rest — an HTTP GET of /camera/sdk/device_info per
163
+ * candidate address, the same probe OBSBOT Center's discovery uses, so the
164
+ * traffic pattern is one the camera expects to see. Bounded concurrency
165
+ * keeps the sweep from socket-bombing the host; per-address timeouts keep
166
+ * one slow device from stretching the scan.
167
+ */
168
+ async scanSubnet(opts = {}) {
169
+ const concurrency = opts.concurrency ?? 32;
170
+ // Overridable for tests (and for pinning a scan to one network when this
171
+ // machine straddles several); default is every non-internal IPv4 /24.
172
+ const subnets = new Set(opts.subnets ?? []);
173
+ if (subnets.size === 0) {
174
+ for (const addrs of Object.values(networkInterfaces())) {
175
+ for (const a of addrs ?? []) {
176
+ if (a.family !== "IPv4" || a.internal)
177
+ continue;
178
+ subnets.add(a.address.split(".").slice(0, 3).join("."));
179
+ }
180
+ }
181
+ }
182
+ const candidates = [];
183
+ for (const s of subnets) {
184
+ for (let i = 1; i <= 254; i++)
185
+ candidates.push(`${s}.${i}`);
186
+ }
187
+ // addHost returns the entry for every host that ANSWERED as a Tail 2 —
188
+ // including already-registered cameras — so the result is "reachable on
189
+ // this sweep", not "new since last time".
190
+ const found = new Map();
191
+ for (let i = 0; i < candidates.length; i += concurrency) {
192
+ const results = await Promise.all(candidates.slice(i, i + concurrency).map((h) => this.addHost(h)));
193
+ for (const e of results)
194
+ if (e)
195
+ found.set(e.mac, e);
196
+ }
197
+ return [...found.values()];
198
+ }
199
+ }
200
+ //# sourceMappingURL=registry.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"registry.js","sourceRoot":"","sources":["../../src/tail2/registry.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,54 @@
1
+ import { spawn } from "node:child_process";
2
+ /**
3
+ * Pull one still frame from a Tail 2's video output with ffmpeg.
4
+ *
5
+ * Primary source: the camera's SRT listener (the one streaming output this
6
+ * firmware actually serves; TAIL2-PROTOCOL.md §9) — requires Center's SRT
7
+ * toggle, exclusive with NDI. Because that toggle is a human-in-the-GUI step
8
+ * and this server is driven by AI agents that should stay autonomous, the
9
+ * fallback layer grabs from the NDI Tools "NDI Webcam" DirectShow device
10
+ * instead (Windows only): if the bridge is running and pointed at the
11
+ * camera's NDI feed, ffmpeg can read it directly and no Center trip is
12
+ * needed. When neither source is live, `snapshotOptions()` reports what it
13
+ * found and what each path costs, so the calling agent can pick.
14
+ *
15
+ * Note Center only allows changing streaming settings while the output is
16
+ * OFF, so probes landing in a configuration window see a dead port — not a
17
+ * startup delay (an earlier "minutes to bind" claim here was wrong and is
18
+ * retracted).
19
+ */
20
+ export interface SrtSnapshotOpts {
21
+ maxDim?: number;
22
+ quality?: number;
23
+ port?: number;
24
+ streamId?: string;
25
+ /** Socket I/O budget for ffmpeg, ms. Generous: SRT handshake + first keyframe. */
26
+ timeoutMs?: number;
27
+ }
28
+ export interface SrtSnapshot {
29
+ mime: string;
30
+ width: number;
31
+ height: number;
32
+ base64: string;
33
+ }
34
+ export declare class SrtNotEnabledError extends Error {
35
+ constructor();
36
+ }
37
+ export declare function srtSnapshot(host: string, opts?: SrtSnapshotOpts, spawnImpl?: typeof spawn): Promise<SrtSnapshot>;
38
+ /**
39
+ * Grab a frame from the NDI Tools "NDI Webcam" DirectShow device (Windows
40
+ * only). NOT used by the snapshot tool automatically: on the reference
41
+ * machine (2026-09-26) the bridge's user-configured output slots delivered
42
+ * uniformly black frames to ffmpeg (YAVG=YMAX=16) while other slots hung,
43
+ * and probing the devices disrupts the app. Kept for manual/verified use —
44
+ * if a slot is confirmed live (e.g. previews correctly in OBS), this works.
45
+ * Never force-kill the Webcam app: that corrupts its output-slot config.
46
+ */
47
+ export declare function ndiWebcamSnapshot(opts?: {
48
+ maxDim?: number;
49
+ quality?: number;
50
+ timeoutMs?: number;
51
+ }, spawnImpl?: typeof spawn): Promise<SrtSnapshot | null>;
52
+ /** Standard NDI Tools install location, for the options report. */
53
+ export declare const NDI_WEBCAM_EXE = "C:\\Program Files\\NDI\\NDI 6 Tools\\Webcam\\Webcam.exe";
54
+ export declare const ndiToolsInstalled: () => boolean;