abb-rws-client 0.7.2 → 1.0.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 (70) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +17 -8
  3. package/dist/HalJsonParser.d.ts +55 -0
  4. package/dist/HalJsonParser.d.ts.map +1 -0
  5. package/dist/HalJsonParser.js +155 -0
  6. package/dist/HalJsonParser.js.map +1 -0
  7. package/dist/HttpSession.d.ts.map +1 -1
  8. package/dist/HttpSession.js +13 -2
  9. package/dist/HttpSession.js.map +1 -1
  10. package/dist/IRWSAdapter.d.ts +7 -1
  11. package/dist/IRWSAdapter.d.ts.map +1 -1
  12. package/dist/MdnsDiscovery.d.ts +57 -0
  13. package/dist/MdnsDiscovery.d.ts.map +1 -0
  14. package/dist/MdnsDiscovery.js +313 -0
  15. package/dist/MdnsDiscovery.js.map +1 -0
  16. package/dist/MultiRobotManager.d.ts +5 -2
  17. package/dist/MultiRobotManager.d.ts.map +1 -1
  18. package/dist/MultiRobotManager.js +8 -3
  19. package/dist/MultiRobotManager.js.map +1 -1
  20. package/dist/RWS1Adapter.d.ts +60 -2
  21. package/dist/RWS1Adapter.d.ts.map +1 -1
  22. package/dist/RWS1Adapter.js +152 -4
  23. package/dist/RWS1Adapter.js.map +1 -1
  24. package/dist/ResourceMapper.d.ts +4 -0
  25. package/dist/ResourceMapper.d.ts.map +1 -1
  26. package/dist/ResourceMapper.js +9 -1
  27. package/dist/ResourceMapper.js.map +1 -1
  28. package/dist/RobotManager.d.ts +72 -12
  29. package/dist/RobotManager.d.ts.map +1 -1
  30. package/dist/RobotManager.js +255 -50
  31. package/dist/RobotManager.js.map +1 -1
  32. package/dist/RwsClient2.d.ts +150 -10
  33. package/dist/RwsClient2.d.ts.map +1 -1
  34. package/dist/RwsClient2.js +599 -236
  35. package/dist/RwsClient2.js.map +1 -1
  36. package/dist/WsSubscriber.d.ts +31 -5
  37. package/dist/WsSubscriber.d.ts.map +1 -1
  38. package/dist/WsSubscriber.js +104 -50
  39. package/dist/WsSubscriber.js.map +1 -1
  40. package/dist/detect.d.ts +12 -3
  41. package/dist/detect.d.ts.map +1 -1
  42. package/dist/detect.js +69 -25
  43. package/dist/detect.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/types.js +3 -3
  49. package/dist/types.js.map +1 -1
  50. package/examples/05-remote-control-rmmp.mjs +10 -12
  51. package/examples/06-pull-module-source.mjs +13 -20
  52. package/package.json +62 -60
  53. package/src/HalJsonParser.ts +137 -0
  54. package/src/HttpSession.ts +460 -0
  55. package/src/IRWSAdapter.ts +422 -0
  56. package/src/Logger.ts +54 -0
  57. package/src/MdnsDiscovery.ts +336 -0
  58. package/src/MultiRobotManager.ts +159 -0
  59. package/src/RWS1Adapter.ts +1018 -0
  60. package/src/RWS2Adapter.ts +19 -0
  61. package/src/ResourceMapper.ts +517 -0
  62. package/src/ResponseParser.ts +710 -0
  63. package/src/RobotManager.ts +1705 -0
  64. package/src/RwsClient.ts +1150 -0
  65. package/src/RwsClient2.ts +2214 -0
  66. package/src/WsSubscriber.ts +350 -0
  67. package/src/XhtmlParser.ts +53 -0
  68. package/src/detect.ts +261 -0
  69. package/src/index.ts +83 -0
  70. package/src/types.ts +336 -0
@@ -0,0 +1,336 @@
1
+ /**
2
+ * mDNS/Bonjour discovery of ABB controllers — zero dependencies (node:dgram +
3
+ * hand-rolled DNS wire parsing).
4
+ *
5
+ * Live-verified 2026-07-08/09 against five RobotStudio VCs (1× RW6.16 IRC5,
6
+ * 4× RW7.21 OmniCore) on this network:
7
+ * - Every controller/VC advertises the `_http._tcp.local` service with
8
+ * instance name `RobotWebServices_<systemname>`. ABB's manual documents
9
+ * browsing the `rws` SUBTYPE (`dns-sd -B _http._tcp,rws`), so we query
10
+ * `rws._sub._http._tcp.local` first and plain `_http._tcp.local` as a
11
+ * fallback (some stacks don't answer subtype PTR queries).
12
+ * - RW7 VCs attach TXT records: `RwVer=7.21.0 SysGuid=<guid> RwsPort=5466
13
+ * RobApiP=2558 WanIp= VC=`. RW6 VCs advertise name + SRV(port) only —
14
+ * NO TXT metadata. That asymmetry is the protocol heuristic below.
15
+ * - The SRV target is the machine hostname (e.g. `DESKTOP-X.local`); the
16
+ * address comes from the A record in the additionals section, falling
17
+ * back to the UDP responder's source address.
18
+ *
19
+ * Strategy: one-shot LEGACY unicast query (RFC 6762 §6.7) — bind an ephemeral
20
+ * UDP port (never 5353 itself; mDNSResponder owns it), send standard DNS PTR
21
+ * queries to 224.0.0.251:5353, and collect the unicast replies until the
22
+ * timeout. Windows multicast egress is per-interface, so when the host has
23
+ * several IPv4 interfaces the query is re-sent through each one.
24
+ */
25
+ import * as dgram from 'dgram';
26
+ import * as os from 'os';
27
+
28
+ /** A controller found via mDNS. RW6 units carry no TXT → only name/host/port. */
29
+ export interface MdnsController {
30
+ /** Full mDNS instance label, e.g. `RobotWebServices_MyRobot`. */
31
+ instanceName: string;
32
+ /** Controller system name — the instance label minus the `RobotWebServices_` prefix. */
33
+ systemName: string;
34
+ /** IPv4 address (A record of the SRV target, else the responder's source address). */
35
+ host: string;
36
+ /** RWS port — TXT `RwsPort` when present, else the SRV port. */
37
+ port: number;
38
+ /** RobotWare version from TXT `RwVer` (RW7 only). */
39
+ rwVersion?: string;
40
+ /** System GUID from TXT `SysGuid` (RW7 only). */
41
+ sysGuid?: string;
42
+ /** PC SDK (RobApi) port from TXT `RobApiP` (RW7 only). */
43
+ robApiPort?: number;
44
+ /**
45
+ * Heuristic, not a probe: RW7/OmniCore advertisements carry TXT metadata
46
+ * (`RwsPort`/`RwVer`) → 'rws2'; advertisements with no TXT data → 'rws1'
47
+ * (matches RW6 behavior); TXT present but without the ABB keys → 'unknown'.
48
+ * Confirm with `RobotManager.probeSpecificPort` before trusting it.
49
+ */
50
+ probableProtocol: 'rws1' | 'rws2' | 'unknown';
51
+ }
52
+
53
+ /** Minimal slice of `dgram.Socket` used here — injectable for offline tests. */
54
+ export interface MdnsSocket {
55
+ on(event: 'message', cb: (msg: Buffer, rinfo: { address: string; port: number }) => void): void;
56
+ on(event: 'error', cb: (err: Error) => void): void;
57
+ on(event: string, cb: (...args: never[]) => void): void;
58
+ bind(port: number, address: string, cb: () => void): void;
59
+ send(msg: Uint8Array, port: number, address: string, cb?: (err: Error | null) => void): void;
60
+ setMulticastInterface(addr: string): void;
61
+ close(): void;
62
+ }
63
+
64
+ export interface MdnsDiscoveryOptions {
65
+ /** How long to collect replies before resolving. Default 2000 ms. */
66
+ timeoutMs?: number;
67
+ /** Test seam — defaults to `dgram.createSocket('udp4')`. */
68
+ socketFactory?: () => MdnsSocket;
69
+ /**
70
+ * IPv4 interface addresses to use as multicast egress (in addition to the
71
+ * OS default route). Defaults to every local IPv4 address — on Windows the
72
+ * default-route interface is often NOT the one the VCs/robots live on.
73
+ */
74
+ interfaceAddrs?: string[];
75
+ }
76
+
77
+ const MDNS_ADDR = '224.0.0.251';
78
+ const MDNS_PORT = 5353;
79
+ const QUERY_NAMES = ['rws._sub._http._tcp.local', '_http._tcp.local'] as const;
80
+ const INSTANCE_PREFIX = 'RobotWebServices_';
81
+
82
+ // DNS record types we care about
83
+ const TYPE_A = 1;
84
+ const TYPE_PTR = 12;
85
+ const TYPE_TXT = 16;
86
+ const TYPE_SRV = 33;
87
+
88
+ // ─── Wire encoding ────────────────────────────────────────────────────────────
89
+
90
+ /** Build a standard one-question DNS PTR query (legacy unicast — plain QCLASS IN). */
91
+ function buildPtrQuery(name: string, id: number): Buffer {
92
+ const parts: Buffer[] = [];
93
+ const header = Buffer.alloc(12);
94
+ header.writeUInt16BE(id, 0);
95
+ header.writeUInt16BE(1, 4); // QDCOUNT
96
+ parts.push(header);
97
+ for (const label of name.split('.')) {
98
+ if (label.length === 0) { continue; }
99
+ const lb = Buffer.from(label, 'utf8');
100
+ parts.push(Buffer.from([lb.length]), lb);
101
+ }
102
+ const tail = Buffer.alloc(5);
103
+ tail.writeUInt16BE(TYPE_PTR, 1); // byte 0 is the root-label terminator
104
+ tail.writeUInt16BE(1, 3); // QCLASS IN
105
+ parts.push(tail);
106
+ return Buffer.concat(parts);
107
+ }
108
+
109
+ // ─── Wire parsing ─────────────────────────────────────────────────────────────
110
+
111
+ /**
112
+ * Decode a (possibly compressed) domain name. Returns the labels and the
113
+ * offset just past the name in the original (non-pointer) stream. Throws on
114
+ * truncation and pointer loops — callers treat any throw as "malformed
115
+ * packet, ignore".
116
+ */
117
+ function readName(buf: Buffer, offset: number): { labels: string[]; next: number } {
118
+ const labels: string[] = [];
119
+ let pos = offset;
120
+ let next = -1; // set on the first compression jump
121
+ let jumps = 0;
122
+ for (;;) {
123
+ if (pos >= buf.length) { throw new Error('name runs past end of packet'); }
124
+ const len = buf[pos];
125
+ if (len === 0) { pos += 1; break; }
126
+ if ((len & 0xc0) === 0xc0) {
127
+ if (pos + 1 >= buf.length) { throw new Error('truncated compression pointer'); }
128
+ if (++jumps > 64) { throw new Error('compression pointer loop'); }
129
+ if (next < 0) { next = pos + 2; }
130
+ pos = ((len & 0x3f) << 8) | buf[pos + 1];
131
+ } else if ((len & 0xc0) !== 0) {
132
+ throw new Error('reserved label type');
133
+ } else {
134
+ if (pos + 1 + len > buf.length) { throw new Error('label runs past end of packet'); }
135
+ labels.push(buf.toString('utf8', pos + 1, pos + 1 + len));
136
+ pos += 1 + len;
137
+ }
138
+ }
139
+ return { labels, next: next < 0 ? pos : next };
140
+ }
141
+
142
+ /** Parse a TXT rdata (length-prefixed `key=value` strings) into a lowercase-keyed map. */
143
+ function parseTxt(buf: Buffer, start: number, len: number): Record<string, string> {
144
+ const out: Record<string, string> = {};
145
+ const end = start + len;
146
+ let pos = start;
147
+ while (pos < end) {
148
+ const l = buf[pos];
149
+ pos += 1;
150
+ if (l === 0) { continue; }
151
+ const s = buf.toString('utf8', pos, Math.min(pos + l, end));
152
+ pos += l;
153
+ const eq = s.indexOf('=');
154
+ const key = (eq >= 0 ? s.slice(0, eq) : s).toLowerCase();
155
+ if (key) { out[key] = eq >= 0 ? s.slice(eq + 1) : ''; }
156
+ }
157
+ return out;
158
+ }
159
+
160
+ /** Accumulated records across all reply packets of one discovery run. */
161
+ interface RecordStore {
162
+ /** key = lowercased full service name → instance label + reply source address. */
163
+ instances: Map<string, { instanceLabel: string; srcAddr: string }>;
164
+ srv: Map<string, { target: string; port: number }>;
165
+ txt: Map<string, Record<string, string>>;
166
+ /** key = lowercased hostname → IPv4 address. */
167
+ a: Map<string, string>;
168
+ }
169
+
170
+ /**
171
+ * Parse one reply packet into the store. Answers, authority, and additionals
172
+ * are treated uniformly — responders put the A record for the SRV target in
173
+ * additionals. Throws on malformed data (caller ignores the packet).
174
+ */
175
+ function ingestPacket(buf: Buffer, srcAddr: string, store: RecordStore): void {
176
+ if (buf.length < 12) { throw new Error('packet shorter than DNS header'); }
177
+ if ((buf.readUInt16BE(2) & 0x8000) === 0) { return; } // a query, not a response
178
+ const qdCount = buf.readUInt16BE(4);
179
+ const rrCount = buf.readUInt16BE(6) + buf.readUInt16BE(8) + buf.readUInt16BE(10);
180
+
181
+ let pos = 12;
182
+ for (let i = 0; i < qdCount; i++) {
183
+ pos = readName(buf, pos).next + 4; // skip QTYPE + QCLASS
184
+ }
185
+
186
+ const register = (labels: string[]): string => {
187
+ const key = labels.join('.').toLowerCase();
188
+ store.instances.set(key, { instanceLabel: labels[0], srcAddr });
189
+ return key;
190
+ };
191
+
192
+ for (let i = 0; i < rrCount; i++) {
193
+ const { labels, next } = readName(buf, pos);
194
+ pos = next;
195
+ if (pos + 10 > buf.length) { throw new Error('record header runs past end of packet'); }
196
+ const type = buf.readUInt16BE(pos);
197
+ const rdLen = buf.readUInt16BE(pos + 8);
198
+ pos += 10;
199
+ if (pos + rdLen > buf.length) { throw new Error('rdata runs past end of packet'); }
200
+
201
+ if (type === TYPE_PTR) {
202
+ const target = readName(buf, pos).labels;
203
+ if (target[0]?.startsWith(INSTANCE_PREFIX)) { register(target); }
204
+ } else if (type === TYPE_SRV && labels[0]?.startsWith(INSTANCE_PREFIX)) {
205
+ if (rdLen < 8) { throw new Error('SRV rdata too short'); }
206
+ const port = buf.readUInt16BE(pos + 4);
207
+ const target = readName(buf, pos + 6).labels.join('.');
208
+ store.srv.set(register(labels), { target, port });
209
+ } else if (type === TYPE_TXT && labels[0]?.startsWith(INSTANCE_PREFIX)) {
210
+ store.txt.set(register(labels), parseTxt(buf, pos, rdLen));
211
+ } else if (type === TYPE_A && rdLen >= 4) {
212
+ store.a.set(labels.join('.').toLowerCase(), `${buf[pos]}.${buf[pos + 1]}.${buf[pos + 2]}.${buf[pos + 3]}`);
213
+ }
214
+ pos += rdLen;
215
+ }
216
+ }
217
+
218
+ // ─── Aggregation ──────────────────────────────────────────────────────────────
219
+
220
+ function positiveInt(value: string | undefined): number | undefined {
221
+ if (!value) { return undefined; }
222
+ const n = parseInt(value, 10);
223
+ return Number.isFinite(n) && n > 0 ? n : undefined;
224
+ }
225
+
226
+ function buildControllers(store: RecordStore): MdnsController[] {
227
+ const byInstance = new Map<string, MdnsController>();
228
+ for (const [key, inst] of store.instances) {
229
+ const srv = store.srv.get(key);
230
+ const txt = store.txt.get(key);
231
+ const hasTxtData = txt !== undefined && Object.keys(txt).length > 0;
232
+ const port = positiveInt(txt?.['rwsport']) ?? srv?.port;
233
+ if (!port) { continue; } // no SRV and no RwsPort — nothing to connect to
234
+ const host = (srv && store.a.get(srv.target.toLowerCase())) ?? inst.srcAddr;
235
+
236
+ const controller: MdnsController = {
237
+ instanceName: inst.instanceLabel,
238
+ systemName: inst.instanceLabel.slice(INSTANCE_PREFIX.length),
239
+ host,
240
+ port,
241
+ probableProtocol: hasTxtData
242
+ ? (txt['rwsport'] || txt['rwver'] ? 'rws2' : 'unknown')
243
+ : 'rws1',
244
+ };
245
+ const rwVersion = txt?.['rwver'];
246
+ if (rwVersion) { controller.rwVersion = rwVersion; }
247
+ const sysGuid = txt?.['sysguid'];
248
+ if (sysGuid) { controller.sysGuid = sysGuid; }
249
+ const robApiPort = positiveInt(txt?.['robapip']);
250
+ if (robApiPort !== undefined) { controller.robApiPort = robApiPort; }
251
+
252
+ if (!byInstance.has(controller.instanceName)) {
253
+ byInstance.set(controller.instanceName, controller);
254
+ }
255
+ }
256
+ return [...byInstance.values()];
257
+ }
258
+
259
+ /** Non-internal-first list of local IPv4 addresses, for multicast egress retries. */
260
+ function ipv4InterfaceAddrs(): string[] {
261
+ const addrs: string[] = [];
262
+ for (const infos of Object.values(os.networkInterfaces())) {
263
+ for (const info of infos ?? []) {
264
+ // Node <18.0 reports family as the string 'IPv4', ≥18.0 sometimes as the number 4
265
+ if ((info.family === 'IPv4' || (info.family as unknown) === 4) && !info.internal) {
266
+ addrs.push(info.address);
267
+ }
268
+ }
269
+ }
270
+ return addrs;
271
+ }
272
+
273
+ /**
274
+ * One-shot mDNS browse for ABB controllers. Resolves after `timeoutMs`
275
+ * (default 2000) with every `RobotWebServices_*` instance heard, deduped by
276
+ * instance name. Never rejects — socket errors resolve with what was
277
+ * collected so far.
278
+ */
279
+ export function discoverControllersMdns(opts: MdnsDiscoveryOptions = {}): Promise<MdnsController[]> {
280
+ const timeoutMs = opts.timeoutMs ?? 2000;
281
+ const factory = opts.socketFactory ?? ((): MdnsSocket => dgram.createSocket({ type: 'udp4', reuseAddr: true }));
282
+
283
+ return new Promise<MdnsController[]>(resolve => {
284
+ const store: RecordStore = { instances: new Map(), srv: new Map(), txt: new Map(), a: new Map() };
285
+ let socket: MdnsSocket;
286
+ try {
287
+ socket = factory();
288
+ } catch {
289
+ resolve([]);
290
+ return;
291
+ }
292
+
293
+ let settled = false;
294
+ const finish = (): void => {
295
+ if (settled) { return; }
296
+ settled = true;
297
+ clearTimeout(timer);
298
+ try { socket.close(); } catch { /* already closed */ }
299
+ resolve(buildControllers(store));
300
+ };
301
+ const timer = setTimeout(finish, timeoutMs);
302
+
303
+ socket.on('error', () => finish());
304
+ socket.on('message', (msg: Buffer, rinfo: { address: string; port: number }) => {
305
+ try {
306
+ ingestPacket(msg, rinfo.address, store);
307
+ } catch {
308
+ // malformed or non-DNS packet — 5353 traffic is noisy, just skip it
309
+ }
310
+ });
311
+
312
+ try {
313
+ socket.bind(0, '0.0.0.0', () => {
314
+ const queries = QUERY_NAMES.map((name, i) => buildPtrQuery(name, i + 1));
315
+ const sendAll = (): void => {
316
+ for (const q of queries) {
317
+ try { socket.send(q, MDNS_PORT, MDNS_ADDR, () => { /* send errors are non-fatal */ }); } catch { /* ditto */ }
318
+ }
319
+ };
320
+ sendAll(); // OS default multicast egress
321
+ // Windows picks ONE egress interface per send — retry through each
322
+ // IPv4 interface so controllers on non-default-route networks answer.
323
+ for (const addr of opts.interfaceAddrs ?? ipv4InterfaceAddrs()) {
324
+ try {
325
+ socket.setMulticastInterface(addr);
326
+ sendAll();
327
+ } catch {
328
+ // interface can't multicast (VPN/virtual adapters) — skip
329
+ }
330
+ }
331
+ });
332
+ } catch {
333
+ finish();
334
+ }
335
+ });
336
+ }
@@ -0,0 +1,159 @@
1
+ import * as crypto from 'crypto';
2
+ import { RobotManager } from './RobotManager.js';
3
+ import type { RobotState, ErrorListener, RobotManagerOptions } from './RobotManager.js';
4
+ import type { FileEntry } from './types.js';
5
+
6
+ export interface RobotConfig {
7
+ id: string;
8
+ name: string;
9
+ host: string;
10
+ /** Specific port to use. If omitted, auto-detection probes common ports. */
11
+ port?: number;
12
+ /** Whether HTTPS is required. Derived from auto-detection or stored explicitly. */
13
+ useHttps?: boolean;
14
+ username: string;
15
+ password: string;
16
+ }
17
+
18
+ /**
19
+ * Manages multiple RobotManager instances.
20
+ * One robot is "active" at a time — all panels display that robot's state.
21
+ */
22
+ export class MultiRobotManager {
23
+ private managers = new Map<string, RobotManager>();
24
+ private configMap = new Map<string, RobotConfig>();
25
+ private _activeId: string | null = null;
26
+ private handlers: Array<() => void> = [];
27
+ private errorListener: ErrorListener | null = null;
28
+
29
+ /** Options forwarded to every RobotManager this instance creates (current and future). */
30
+ constructor(private readonly managerOptions?: RobotManagerOptions) {}
31
+
32
+ // ── Accessors ──────────────────────────────────────────────────────────────
33
+
34
+ /** The currently selected robot's manager, or null if no robots configured. */
35
+ get active(): RobotManager | null {
36
+ return this._activeId ? (this.managers.get(this._activeId) ?? null) : null;
37
+ }
38
+
39
+ get activeId(): string | null { return this._activeId; }
40
+
41
+ /** All robots with their configs and managers. */
42
+ get entries(): Array<{ id: string; config: RobotConfig; manager: RobotManager }> {
43
+ return [...this.managers.entries()].map(([id, manager]) => ({
44
+ id, config: this.configMap.get(id)!, manager,
45
+ }));
46
+ }
47
+
48
+ get configs(): RobotConfig[] { return [...this.configMap.values()]; }
49
+
50
+ // ── State interface (satisfies providers that only need state) ─────────────
51
+
52
+ get state(): RobotState {
53
+ return this.active?.state ?? {
54
+ connected: false, host: '', ctrlstate: null, opmode: null,
55
+ execstate: null, execCycle: null, speedRatio: null, coldetstate: null,
56
+ tasks: [], modules: [], mechunits: [], joints: null, cartesian: null,
57
+ cartesianFull: null, identity: null, systemInfo: null, eventLog: [], ioSignals: [],
58
+ };
59
+ }
60
+
61
+ onDidChange(fn: () => void): void { this.handlers.push(fn); }
62
+
63
+ /**
64
+ * Install an error listener that gets attached to every current AND future
65
+ * RobotManager. Convenience for hosts that want a single sink for connection
66
+ * failures across all robots.
67
+ */
68
+ onError(fn: ErrorListener): void {
69
+ this.errorListener = fn;
70
+ for (const mgr of this.managers.values()) { mgr.onError(fn); }
71
+ }
72
+
73
+ // Delegated methods for providers that call manager methods directly
74
+ async listDirectory(remotePath: string): Promise<FileEntry[]> {
75
+ const m = this.active;
76
+ if (!m) { throw new Error('No active robot'); }
77
+ return m.listDirectory(remotePath);
78
+ }
79
+
80
+ // ── Robot management ───────────────────────────────────────────────────────
81
+
82
+ addRobot(config: RobotConfig): void {
83
+ const mgr = new RobotManager(this.managerOptions);
84
+ mgr.onDidChange(() => this.notify());
85
+ if (this.errorListener) { mgr.onError(this.errorListener); }
86
+ this.managers.set(config.id, mgr);
87
+ this.configMap.set(config.id, config);
88
+ if (!this._activeId) { this._activeId = config.id; }
89
+ this.notify();
90
+ }
91
+
92
+ updateConfig(id: string, patch: Partial<Omit<RobotConfig, 'id'>>): void {
93
+ const existing = this.configMap.get(id);
94
+ if (existing) {
95
+ this.configMap.set(id, { ...existing, ...patch });
96
+ this.notify();
97
+ }
98
+ }
99
+
100
+ removeRobot(id: string): void {
101
+ this.managers.get(id)?.disconnect().catch(() => {});
102
+ this.managers.delete(id);
103
+ this.configMap.delete(id);
104
+ if (this._activeId === id) {
105
+ this._activeId = [...this.managers.keys()][0] ?? null;
106
+ }
107
+ this.notify();
108
+ }
109
+
110
+ setActive(id: string): void {
111
+ if (this.managers.has(id)) {
112
+ this._activeId = id;
113
+ this.notify();
114
+ }
115
+ }
116
+
117
+ // ── Per-robot connect/disconnect ───────────────────────────────────────────
118
+
119
+ async connectRobot(id: string): Promise<void> {
120
+ const mgr = this.managers.get(id);
121
+ const cfg = this.configMap.get(id);
122
+ if (!mgr || !cfg) { throw new Error(`Robot ${id} not found`); }
123
+ // Pass stored port/useHttps so same-host robots go to different ports
124
+ await mgr.connect(cfg.host, cfg.username, cfg.password, cfg.port, cfg.useHttps);
125
+
126
+ // If RobotManager auto-recovered a different port (RobotStudio reassigned it),
127
+ // persist the new port so future connects skip the scan step.
128
+ const actualPort = mgr.currentPort;
129
+ const actualUseHttps = mgr.currentUseHttps;
130
+ if (actualPort !== undefined && actualPort !== cfg.port) {
131
+ this.configMap.set(id, { ...cfg, port: actualPort, useHttps: actualUseHttps });
132
+ }
133
+
134
+ if (!this.active?.state.connected) { this._activeId = id; }
135
+ this.notify();
136
+ }
137
+
138
+ async disconnectRobot(id: string): Promise<void> {
139
+ await this.managers.get(id)?.disconnect();
140
+ }
141
+
142
+ // ── Serialization ──────────────────────────────────────────────────────────
143
+
144
+ /** Create a MultiRobotManager from persisted configs, with backward compatibility for legacy single-robot settings. */
145
+ static fromConfigs(configs: RobotConfig[], opts?: RobotManagerOptions): MultiRobotManager {
146
+ const m = new MultiRobotManager(opts);
147
+ configs.forEach(c => m.addRobot(c));
148
+ return m;
149
+ }
150
+
151
+ /** Generate a new robot config ID. */
152
+ static newId(): string {
153
+ return crypto.randomUUID ? crypto.randomUUID() : Math.random().toString(36).slice(2);
154
+ }
155
+
156
+ // ── Internal ───────────────────────────────────────────────────────────────
157
+
158
+ private notify(): void { this.handlers.forEach(h => h()); }
159
+ }