obsbot-mcp 0.7.0 → 0.9.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 +111 -20
- package/dist/build-info.json +5 -0
- package/dist/device/manager.d.ts +8 -0
- package/dist/device/manager.js +37 -4
- package/dist/device/manager.js.map +1 -1
- package/dist/geometry/aim.d.ts +24 -13
- package/dist/geometry/aim.js +26 -13
- package/dist/geometry/aim.js.map +1 -1
- package/dist/ipc/build-id.d.ts +25 -0
- package/dist/ipc/build-id.js +60 -0
- package/dist/ipc/build-id.js.map +1 -0
- package/dist/ipc/client.d.ts +36 -0
- package/dist/ipc/client.js +106 -4
- package/dist/ipc/client.js.map +1 -1
- package/dist/ipc/coordinator.d.ts +91 -12
- package/dist/ipc/coordinator.js +293 -57
- package/dist/ipc/coordinator.js.map +1 -1
- package/dist/ipc/gate.d.ts +14 -0
- package/dist/ipc/gate.js +45 -0
- package/dist/ipc/gate.js.map +1 -0
- package/dist/ipc/owner.d.ts +27 -1
- package/dist/ipc/owner.js +67 -4
- package/dist/ipc/owner.js.map +1 -1
- package/dist/ipc/protocol.d.ts +30 -0
- package/dist/ipc/protocol.js +40 -0
- package/dist/ipc/protocol.js.map +1 -1
- package/dist/ipc/rendezvous.d.ts +37 -11
- package/dist/ipc/rendezvous.js +143 -30
- package/dist/ipc/rendezvous.js.map +1 -1
- package/dist/mcp/server.js +40 -12
- package/dist/mcp/server.js.map +1 -1
- package/dist/tail2/api.d.ts +381 -0
- package/dist/tail2/api.js +547 -0
- package/dist/tail2/api.js.map +1 -0
- package/dist/tail2/mdns.d.ts +48 -0
- package/dist/tail2/mdns.js +216 -0
- package/dist/tail2/mdns.js.map +1 -0
- package/dist/tail2/move.d.ts +41 -0
- package/dist/tail2/move.js +79 -0
- package/dist/tail2/move.js.map +1 -0
- package/dist/tail2/registry.d.ts +100 -0
- package/dist/tail2/registry.js +200 -0
- package/dist/tail2/registry.js.map +1 -0
- package/dist/tail2/snapshot.d.ts +54 -0
- package/dist/tail2/snapshot.js +157 -0
- package/dist/tail2/snapshot.js.map +1 -0
- package/dist/tail2/tools.d.ts +7 -0
- package/dist/tail2/tools.js +772 -0
- package/dist/tail2/tools.js.map +1 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +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 +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":"AAAA,OAAO,KAAK,MAAM,YAAY,CAAC;AAC/B,OAAO,EAAE,iBAAiB,EAAE,MAAM,SAAS,CAAC;AA8B5C,MAAM,KAAK,GAAG,aAAa,CAAC;AAC5B,MAAM,SAAS,GAAG,IAAI,CAAC;AACvB,MAAM,MAAM,GAAG,gCAAgC,CAAC;AAEhD,MAAM,YAAY,GAAG,CAAC,CAAS,EAAW,EAAE,CAAC,CAAC,KAAK,EAAE,IAAI,CAAC,KAAK,SAAS,IAAI,CAAC,KAAK,iBAAiB,CAAC;AAEpG,8EAA8E;AAC9E,4EAA4E;AAC5E,6EAA6E;AAC7E,0EAA0E;AAC1E,iEAAiE;AACjE,8EAA8E;AAE9E;;qEAEqE;AACrE,SAAS,QAAQ,CAAC,GAAW,EAAE,GAAW;IACxC,MAAM,MAAM,GAAa,EAAE,CAAC;IAC5B,IAAI,MAAM,GAAG,KAAK,CAAC;IACnB,IAAI,GAAG,GAAG,GAAG,CAAC;IACd,IAAI,GAAG,GAAG,GAAG,CAAC;IACd,IAAI,KAAK,GAAG,CAAC,CAAC;IACd,OAAO,IAAI,EAAE,CAAC;QACZ,IAAI,GAAG,IAAI,GAAG,CAAC,MAAM;YAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;QAC5D,MAAM,GAAG,GAAG,GAAG,CAAC,GAAG,CAAE,CAAC;QACtB,IAAI,GAAG,KAAK,CAAC,EAAE,CAAC;YACd,IAAI,CAAC,MAAM;gBAAE,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC;YAC3B,MAAM;QACR,CAAC;QACD,IAAI,CAAC,GAAG,GAAG,IAAI,CAAC,KAAK,IAAI,EAAE,CAAC;YAC1B,IAAI,GAAG,GAAG,CAAC,IAAI,GAAG,CAAC,MAAM;gBAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;YAChE,MAAM,GAAG,GAAG,CAAC,CAAC,GAAG,GAAG,IAAI,CAAC,IAAI,CAAC,CAAC,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,CAAE,CAAC;YAChD,IAAI,CAAC,MAAM;gBAAE,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC;YAC3B,IAAI,EAAE,KAAK,GAAG,EAAE;gBAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC,CAAC,eAAe;YACvE,GAAG,GAAG,GAAG,CAAC;YACV,MAAM,GAAG,IAAI,CAAC;YACd,SAAS;QACX,CAAC;QACD,IAAI,GAAG,GAAG,CAAC,GAAG,GAAG,GAAG,GAAG,CAAC,MAAM;YAAE,OAAO,EAAE,IAAI,EAAE,EAAE,EAAE,GAAG,EAAE,GAAG,CAAC,MAAM,EAAE,CAAC;QACrE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,QAAQ,CAAC,GAAG,GAAG,CAAC,EAAE,GAAG,GAAG,CAAC,GAAG,GAAG,CAAC,CAAC,QAAQ,CAAC,QAAQ,CAAC,CAAC,CAAC;QACrE,GAAG,IAAI,CAAC,GAAG,GAAG,CAAC;IACjB,CAAC;IACD,OAAO,EAAE,IAAI,EAAE,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,EAAE,GAAG,EAAE,CAAC;AACzC,CAAC;AAED,mFAAmF;AACnF,SAAS,YAAY,CAAC,GAAW;IAC/B,MAAM,OAAO,GAAa,EAAE,CAAC;IAC7B,IAAI,GAAG,CAAC,MAAM,GAAG,EAAE;QAAE,OAAO,OAAO,CAAC;IACpC,MAAM,EAAE,GAAG,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAC/B,MAAM,EAAE,GAAG,GAAG,CAAC,YAAY,CAAC,CAAC,CAAC,CAAC;IAC/B,IAAI,GAAG,GAAG,EAAE,CAAC;IACb,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,MAAM,EAAE,GAAG,EAAE,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACnC,GAAG,GAAG,GAAG,GAAG,CAAC,CAAC,CAAC,iBAAiB;QAChC,IAAI,GAAG,GAAG,GAAG,CAAC,MAAM;YAAE,OAAO,OAAO,CAAC;IACvC,CAAC;IACD,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,EAAE,EAAE,CAAC,EAAE,EAAE,CAAC;QAC5B,MAAM,EAAE,GAAG,EAAE,GAAG,QAAQ,CAAC,GAAG,EAAE,GAAG,CAAC,CAAC;QACnC,GAAG,GAAG,GAAG,CAAC;QACV,IAAI,GAAG,GAAG,EAAE,GAAG,GAAG,CAAC,MAAM;YAAE,OAAO,OAAO,CAAC;QAC1C,MAAM,IAAI,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,CAAC,CAAC;QACnC,MAAM,QAAQ,GAAG,GAAG,CAAC,YAAY,CAAC,GAAG,GAAG,CAAC,CAAC,CAAC;QAC3C,MAAM,KAAK,GAAG,GAAG,CAAC,QAAQ,CAAC,GAAG,GAAG,EAAE,EAAE,GAAG,GAAG,EAAE,GAAG,QAAQ,CAAC,CAAC;QAC1D,GAAG,IAAI,EAAE,GAAG,QAAQ,CAAC;QACrB,IAAI,IAAI,KAAK,EAAE,EAAE,CAAC;YAChB,2DAA2D;YAC3D,IAAI,CAAC,GAAG,CAAC,CAAC;YACV,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC;gBACxB,MAAM,CAAC,GAAG,KAAK,CAAC,CAAC,CAAE,CAAC;gBACpB,IAAI,CAAC,GAAG,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC,MAAM;oBAAE,MAAM;gBACpC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,CAAC,GAAG,CAAC,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,CAAC;gBAChE,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACb,CAAC;QACH,CAAC;IACH,CAAC;IACD,OAAO,OAAO,CAAC;AACjB,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,UAAU,sBAAsB,CAAC,GAAW,EAAE,KAAc;IAChE,MAAM,MAAM,GAA4B,EAAE,CAAC;IAC3C,KAAK,MAAM,CAAC,IAAI,YAAY,CAAC,GAAG,CAAC,EAAE,CAAC;QAClC,IAAI,CAAC,CAAC,CAAC,UAAU,CAAC,GAAG,CAAC;YAAE,SAAS;QACjC,IAAI,CAAC;YACH,MAAM,CAAC,GAAG,IAAI,CAAC,KAAK,CAAC,CAAC,CAAY,CAAC;YACnC,IAAI,CAAC,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,CAAC,EAAE,CAAC;gBACpD,MAAM,CAAC,MAAM,CAAC,MAAM,EAAE,CAA4B,CAAC,CAAC;YACtD,CAAC;QACH,CAAC;QAAC,MAAM,CAAC;YACP,kEAAkE;QACpE,CAAC;IACH,CAAC;IACD,MAAM,GAAG,GAAG,OAAO,MAAM,CAAC,OAAO,KAAK,QAAQ,CAAC,CAAC,CAAC,MAAM,CAAC,OAAO,CAAC,WAAW,EAAE,CAAC,CAAC,CAAC,EAAE,CAAC;IACnF,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC;QAAE,OAAO,IAAI,CAAC;IAEnC,MAAM,KAAK,GAAa,EAAE,CAAC;IAC3B,KAAK,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,QAAQ,EAAE,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC;QACtD,IAAI,OAAO,CAAC,KAAK,QAAQ,IAAI,YAAY,CAAC,CAAC,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;YAAE,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;IACpF,CAAC;IACD,IAAI,KAAK,IAAI,YAAY,CAAC,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,QAAQ,CAAC,KAAK,CAAC;QAAE,KAAK,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAC9E,IAAI,KAAK,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,IAAI,CAAC;IAEpC,IAAI,IAAI,GAAG,GAAG,CAAC;IACf,IAAI,OAAO,MAAM,CAAC,WAAW,KAAK,QAAQ,IAAI,cAAc,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,CAAC,EAAE,CAAC;QACtF,MAAM,OAAO,GAAG,MAAM,CAAC,IAAI,CAAC,MAAM,CAAC,WAAW,EAAE,KAAK,CAAC,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC;QACxE,IAAI,OAAO,KAAK,EAAE;YAAE,IAAI,GAAG,OAAO,CAAC;IACrC,CAAC;IACD,OAAO,EAAE,GAAG,EAAE,IAAI,EAAE,KAAK,EAAE,CAAC;AAC9B,CAAC;AAED;gEACgE;AAChE,MAAM,UAAU,2BAA2B;IACzC,MAAM,KAAK,GAAG,IAAI,GAAG,EAA6B,CAAC;IACnD,OAAO;QACL,IAAI,CAAC,GAAW,EAAE,KAAc;YAC9B,MAAM,CAAC,GAAG,sBAAsB,CAAC,GAAG,EAAE,KAAK,CAAC,CAAC;YAC7C,IAAI,CAAC,CAAC;gBAAE,OAAO;YACf,MAAM,QAAQ,GAAG,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC;YAClC,IAAI,QAAQ,EAAE,CAAC;gBACb,KAAK,MAAM,CAAC,IAAI,CAAC,CAAC,KAAK;oBAAE,IAAI,CAAC,QAAQ,CAAC,KAAK,CAAC,QAAQ,CAAC,CAAC,CAAC;wBAAE,QAAQ,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC;YACnF,CAAC;iBAAM,CAAC;gBACN,KAAK,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,EAAE,CAAC,CAAC,CAAC;YACtB,CAAC;QACH,CAAC;QACD,OAAO;YACL,OAAO,CAAC,GAAG,KAAK,CAAC,MAAM,EAAE,CAAC,CAAC;QAC7B,CAAC;KACF,CAAC;AACJ,CAAC;AAED;;;;;GAKG;AACH,MAAM,UAAU,2BAA2B,CAAC,EAAE,GAAG,IAAI;IACnD,OAAO,IAAI,OAAO,CAAC,CAAC,OAAO,EAAE,EAAE;QAC7B,MAAM,SAAS,GAAG,2BAA2B,EAAE,CAAC;QAChD,IAAI,MAAM,GAAwB,IAAI,CAAC;QACvC,IAAI,QAAQ,GAAG,KAAK,CAAC;QACrB,MAAM,MAAM,GAAG,GAAS,EAAE;YACxB,IAAI,QAAQ;gBAAE,OAAO;YACrB,QAAQ,GAAG,IAAI,CAAC;YAChB,YAAY,CAAC,KAAK,CAAC,CAAC;YACpB,IAAI,MAAM,EAAE,CAAC;gBACX,MAAM,CAAC,kBAAkB,EAAE,CAAC;gBAC5B,IAAI,CAAC;oBACH,MAAM,CAAC,cAAc,CAAC,KAAK,CAAC,CAAC;gBAC/B,CAAC;gBAAC,MAAM,CAAC;oBACP,gBAAgB;gBAClB,CAAC;gBACD,MAAM,CAAC,KAAK,EAAE,CAAC;YACjB,CAAC;YACD,OAAO,CAAC,SAAS,CAAC,OAAO,EAAE,CAAC,CAAC;QAC/B,CAAC,CAAC;QACF,MAAM,KAAK,GAAG,UAAU,CAAC,MAAM,EAAE,EAAE,CAAC,CAAC;QACrC,IAAI,CAAC;YACH,MAAM,GAAG,KAAK,CAAC,YAAY,CAAC,EAAE,IAAI,EAAE,MAAM,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;YAC/D,MAAM,CAAC,EAAE,CAAC,SAAS,EAAE,CAAC,GAAW,EAAE,KAAuB,EAAE,EAAE;gBAC5D,SAAS,CAAC,IAAI,CAAC,GAAG,EAAE,KAAK,CAAC,OAAO,CAAC,CAAC;YACrC,CAAC,CAAC,CAAC;YACH,MAAM,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,CAAC,CAAC;YAC3B,MAAM,CAAC,IAAI,CAAC,SAAS,EAAE,GAAG,EAAE;gBAC1B,MAAM,CAAC,GAAG,MAAO,CAAC;gBAClB,IAAI,CAAC;oBACH,CAAC,CAAC,aAAa,CAAC,KAAK,CAAC,CAAC;gBACzB,CAAC;gBAAC,MAAM,CAAC;oBACP,qEAAqE;gBACvE,CAAC;gBACD,KAAK,MAAM,KAAK,IAAI,MAAM,CAAC,MAAM,CAAC,iBAAiB,EAAE,CAAC,EAAE,CAAC;oBACvD,KAAK,MAAM,CAAC,IAAI,KAAK,IAAI,EAAE,EAAE,CAAC;wBAC5B,IAAI,CAAC,CAAC,MAAM,KAAK,MAAM,IAAI,CAAC,CAAC,QAAQ;4BAAE,SAAS;wBAChD,IAAI,CAAC;4BACH,CAAC,CAAC,aAAa,CAAC,KAAK,EAAE,CAAC,CAAC,OAAO,CAAC,CAAC;wBACpC,CAAC;wBAAC,MAAM,CAAC;4BACP,mDAAmD;wBACrD,CAAC;oBACH,CAAC;gBACH,CAAC;YACH,CAAC,CAAC,CAAC;QACL,CAAC;QAAC,MAAM,CAAC;YACP,MAAM,EAAE,CAAC;QACX,CAAC;IACH,CAAC,CAAC,CAAC;AACL,CAAC"}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Pose } from "./api.js";
|
|
2
|
+
/**
|
|
3
|
+
* Absolute gimbal positioning for the Tail 2, built out of the two primitives
|
|
4
|
+
* the HTTP API actually offers: continuous speed moves (POST ptz/gimbalcontrol)
|
|
5
|
+
* and a slow pose readback (save-scratch-preset → GET). There is no
|
|
6
|
+
* move-to-angle endpoint, so this is a closed loop with a very sluggish
|
|
7
|
+
* sensor — a pose read costs ~1.5 s and the preset list lags the save by up
|
|
8
|
+
* to ~1 s. The loop is therefore deliberately conservative: undershoot each
|
|
9
|
+
* jog, re-read, correct; never let a jog run longer than a few seconds.
|
|
10
|
+
*
|
|
11
|
+
* Two facts from the 2026-09-30 hardware session shape it:
|
|
12
|
+
* - SPEED: yaw command 20 for 1 s moved the recorded pose 7.86°, so roughly
|
|
13
|
+
* 0.39 °/s per command unit (ramps included in that one sample).
|
|
14
|
+
* - SIGN: a POSITIVE yaw command DECREASED the recorded yaw. The command
|
|
15
|
+
* sign per axis is a convention we have measured for yaw only; the loop
|
|
16
|
+
* assumes the same for pitch and self-corrects per axis on contradiction
|
|
17
|
+
* (first jog too far away → flip that axis and continue).
|
|
18
|
+
*/
|
|
19
|
+
export interface MoveDeps {
|
|
20
|
+
readPose(): Promise<Pose>;
|
|
21
|
+
speedCmd(yawUnits: number, pitchUnits: number): Promise<void>;
|
|
22
|
+
stop(): Promise<void>;
|
|
23
|
+
sleep(ms: number): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface MoveTarget {
|
|
26
|
+
yaw?: number;
|
|
27
|
+
pitch?: number;
|
|
28
|
+
}
|
|
29
|
+
export interface MoveResult {
|
|
30
|
+
pose: Pose;
|
|
31
|
+
/** Axis error in degrees when the loop finished, by axis. */
|
|
32
|
+
error: {
|
|
33
|
+
yaw: number;
|
|
34
|
+
pitch: number;
|
|
35
|
+
};
|
|
36
|
+
iterations: number;
|
|
37
|
+
converged: boolean;
|
|
38
|
+
/** Axes whose command sign had to be flipped after the first jog. */
|
|
39
|
+
signFlipped: string[];
|
|
40
|
+
}
|
|
41
|
+
export declare function moveAbsolute(deps: MoveDeps, target: MoveTarget): Promise<MoveResult>;
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
/** Rough °/s per command unit at the speeds this loop uses (measured once, ramps included). */
|
|
2
|
+
const EST_DEG_PER_S_PER_UNIT = 0.39;
|
|
3
|
+
/** Aim to cover this fraction of the remaining error per jog: undershoot, then correct. */
|
|
4
|
+
const COVER_FRACTION = 0.75;
|
|
5
|
+
/** Stop when an axis is within this many degrees. */
|
|
6
|
+
const TOLERANCE_DEG = 1.5;
|
|
7
|
+
const MAX_ITERATIONS = 5;
|
|
8
|
+
const MAX_JOG_MS = 3500;
|
|
9
|
+
const MIN_SPEED_UNITS = 8;
|
|
10
|
+
const MAX_SPEED_UNITS = 40;
|
|
11
|
+
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
12
|
+
export async function moveAbsolute(deps, target) {
|
|
13
|
+
// Command sign per axis: measured, a positive yaw command decreased the
|
|
14
|
+
// recorded yaw — so to move the record toward +, command −.
|
|
15
|
+
const sign = { yaw: -1, pitch: -1 };
|
|
16
|
+
const signFlipped = [];
|
|
17
|
+
let pose = await deps.readPose();
|
|
18
|
+
const errOf = (p) => ({
|
|
19
|
+
yaw: target.yaw !== undefined ? target.yaw - p.yaw : 0,
|
|
20
|
+
pitch: target.pitch !== undefined ? target.pitch - p.pitch : 0,
|
|
21
|
+
});
|
|
22
|
+
const err = errOf(pose);
|
|
23
|
+
const prevErr = { ...err };
|
|
24
|
+
let iterations = 0;
|
|
25
|
+
let lastCmd = null;
|
|
26
|
+
while (iterations < MAX_ITERATIONS) {
|
|
27
|
+
const errNow = errOf(pose);
|
|
28
|
+
if (Math.abs(errNow.yaw) <= TOLERANCE_DEG && Math.abs(errNow.pitch) <= TOLERANCE_DEG)
|
|
29
|
+
break;
|
|
30
|
+
if (iterations > 0 && lastCmd) {
|
|
31
|
+
// Self-calibration: if an axis we drove got FARTHER away, our assumed
|
|
32
|
+
// command sign for that axis is wrong. Flip it and continue.
|
|
33
|
+
for (const axis of ["yaw", "pitch"]) {
|
|
34
|
+
if (lastCmd[axis] === 0)
|
|
35
|
+
continue;
|
|
36
|
+
if (Math.abs(errNow[axis]) > Math.abs(prevErr[axis]) + 0.5) {
|
|
37
|
+
sign[axis] = -sign[axis];
|
|
38
|
+
if (!signFlipped.includes(axis))
|
|
39
|
+
signFlipped.push(axis);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Speed from the larger remaining error, undershooting on purpose.
|
|
44
|
+
const worst = Math.max(Math.abs(errNow.yaw), Math.abs(errNow.pitch));
|
|
45
|
+
const units = clamp(worst / EST_DEG_PER_S_PER_UNIT, MIN_SPEED_UNITS, MAX_SPEED_UNITS);
|
|
46
|
+
const duration = clamp((worst * COVER_FRACTION) / (units * EST_DEG_PER_S_PER_UNIT) * 1000, 250, MAX_JOG_MS);
|
|
47
|
+
const cmd = {
|
|
48
|
+
// Command direction: the axis sign convention (Δpose = sign × cmd × k)
|
|
49
|
+
// applied to the error's own sign — toward the target, whichever side
|
|
50
|
+
// of it we are on.
|
|
51
|
+
yaw: Math.abs(errNow.yaw) > TOLERANCE_DEG ? sign.yaw * Math.sign(errNow.yaw) * units : 0,
|
|
52
|
+
pitch: Math.abs(errNow.pitch) > TOLERANCE_DEG
|
|
53
|
+
? sign.pitch * Math.sign(errNow.pitch) * units
|
|
54
|
+
: 0,
|
|
55
|
+
};
|
|
56
|
+
if (cmd.yaw === 0 && cmd.pitch === 0)
|
|
57
|
+
break;
|
|
58
|
+
try {
|
|
59
|
+
await deps.speedCmd(cmd.yaw, cmd.pitch);
|
|
60
|
+
lastCmd = cmd;
|
|
61
|
+
Object.assign(prevErr, errNow);
|
|
62
|
+
await deps.sleep(duration);
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
await deps.stop();
|
|
66
|
+
}
|
|
67
|
+
iterations++;
|
|
68
|
+
pose = await deps.readPose();
|
|
69
|
+
}
|
|
70
|
+
const errFinal = errOf(pose);
|
|
71
|
+
return {
|
|
72
|
+
pose,
|
|
73
|
+
error: errFinal,
|
|
74
|
+
iterations,
|
|
75
|
+
converged: Math.abs(errFinal.yaw) <= TOLERANCE_DEG && Math.abs(errFinal.pitch) <= TOLERANCE_DEG,
|
|
76
|
+
signFlipped,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
//# sourceMappingURL=move.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"move.js","sourceRoot":"","sources":["../../src/tail2/move.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
|