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,1705 @@
1
+ import { RwsClient } from './RwsClient.js';
2
+ import type {
3
+ RapidTask, JointTarget, RobTarget, CartesianFull, ElogMessage,
4
+ FileEntry, SystemInfo, ControllerIdentity, CollisionDetectionState,
5
+ RapidSymbolProperties, RapidSymbolInfo, RapidSymbolSearchParams,
6
+ UiInstruction, RestartMode, Signal, IoNetwork, IoDevice,
7
+ SubscriptionEvent,
8
+ } from './types.js';
9
+ import { RwsError } from './types.js';
10
+ import * as https from 'https';
11
+ import * as http from 'http';
12
+ import * as net from 'net';
13
+ import * as fs from 'fs';
14
+ import * as os from 'os';
15
+ import * as path from 'path';
16
+ import type { IRWSAdapter } from './IRWSAdapter.js';
17
+ import { RWS1Adapter } from './RWS1Adapter.js';
18
+ import { RWS2Adapter } from './RWS2Adapter.js';
19
+ import { Logger } from './Logger.js';
20
+ import { discoverControllersMdns } from './MdnsDiscovery.js';
21
+ import type { MdnsController } from './MdnsDiscovery.js';
22
+
23
+ /**
24
+ * Listener signature for `onError`. The host (VS Code extension, CLI, etc.) can
25
+ * decide how to surface the error — e.g. `vscode.window.showErrorMessage` with
26
+ * the supplied action labels, or a CLI prompt, or just logging.
27
+ *
28
+ * The promise resolves to the action the user chose, or `undefined` if dismissed.
29
+ * RobotManager respects 'Reconnect' specifically (re-runs connect with the prior config).
30
+ */
31
+ export type ErrorListener = (msg: string, actions: string[]) => Promise<string | undefined>;
32
+
33
+ const SESSION_FILE = path.join(os.homedir(), '.abb-rws-session');
34
+
35
+ export interface RobotManagerOptions {
36
+ /**
37
+ * Fast-poll cadence in ms, used when WebSocket subscriptions are unavailable.
38
+ * Default 1000, clamped to ≥200 (below that the controller's ~20 req/s limit
39
+ * starts rejecting the poll burst). When subscriptions are active, polling
40
+ * drops to 5× this value (positions only).
41
+ */
42
+ refreshIntervalMs?: number;
43
+ /**
44
+ * Verify TLS certificates on HTTPS controllers. Default false — virtual and
45
+ * real controllers alike ship self-signed certs, so verification stays off
46
+ * unless the deployment has a CA-signed cert on the controller. Applies to
47
+ * port probing and to the RWS 2.0 client this manager constructs.
48
+ */
49
+ strictTls?: boolean;
50
+ }
51
+
52
+ export interface RobotState {
53
+ connected: boolean;
54
+ host: string;
55
+ ctrlstate: string | null;
56
+ opmode: string | null;
57
+ execstate: string | null;
58
+ execCycle: string | null; // 'once' | 'forever' | 'asis' | 'oncedone'
59
+ speedRatio: number | null;
60
+ coldetstate: CollisionDetectionState | null;
61
+ tasks: RapidTask[];
62
+ modules: string[];
63
+ mechunits: string[]; // list of mechanical unit names (e.g. ['ROB_1'])
64
+ joints: JointTarget | null;
65
+ cartesian: RobTarget | null;
66
+ cartesianFull: CartesianFull | null;
67
+ identity: ControllerIdentity | null;
68
+ systemInfo: SystemInfo | null;
69
+ eventLog: ElogMessage[];
70
+ ioSignals: Signal[];
71
+ }
72
+
73
+ export type ChangeHandler = () => void;
74
+
75
+ export interface ProbeResult {
76
+ port: number;
77
+ useHttps: boolean;
78
+ authType: 'digest' | 'basic';
79
+ }
80
+
81
+ /** In-flight connect attempt — args plus the connectEpoch the attempt runs under. */
82
+ interface ConnectAttempt {
83
+ host: string;
84
+ username: string;
85
+ password: string;
86
+ port?: number;
87
+ useHttps?: boolean;
88
+ /**
89
+ * connectEpoch value this attempt is running under. disconnect() bumps the
90
+ * epoch, marking the attempt cancelled — connect() must stop coalescing onto
91
+ * it, or callers get a resolved promise while the manager ends disconnected.
92
+ */
93
+ epoch: number;
94
+ /**
95
+ * Set by a user-initiated disconnect(). The epoch alone can't carry this:
96
+ * doConnect legitimately re-reads the epoch after its own internal teardowns
97
+ * (supersede, already-connected), which would launder a user cancellation
98
+ * back into validity.
99
+ */
100
+ cancelled?: boolean;
101
+ }
102
+
103
+ export interface DiscoveredController extends ProbeResult {
104
+ host: string;
105
+ }
106
+
107
+ export class RobotManager {
108
+ private adapter: IRWSAdapter | null = null;
109
+ private adapterConfig: { host: string; username: string; password: string; port: number } | null = null;
110
+ private errorListener: ErrorListener | null = null;
111
+ private _state: RobotState = {
112
+ connected: false, host: '', ctrlstate: null, opmode: null,
113
+ execstate: null, execCycle: null, speedRatio: null, coldetstate: null,
114
+ tasks: [], modules: [], mechunits: [], joints: null,
115
+ cartesian: null, cartesianFull: null, identity: null, systemInfo: null,
116
+ eventLog: [], ioSignals: [],
117
+ };
118
+
119
+ private handlers: ChangeHandler[] = [];
120
+ private timer: NodeJS.Timeout | null = null;
121
+ /** Unsubscribe function returned by adapter.subscribe(); null when not using WebSockets. */
122
+ private unsubscribeFn: (() => Promise<void>) | null = null;
123
+ /** True when WebSocket subscriptions are active (drives reduced polling interval). */
124
+ private subscriptionActive = false;
125
+ /** In-flight connect promise — used to dedupe rapid-clicks so we never run two connects in parallel. */
126
+ private connectingPromise: Promise<void> | null = null;
127
+ /** Args of the in-flight connect, so a repeat call can tell "same target" from "new target". */
128
+ private connectingArgs: ConnectAttempt | null = null;
129
+ /** Monotonic counter so old polling timers can detect they've been superseded and self-cancel. */
130
+ private pollGeneration = 0;
131
+ /** Bumped by disconnect() so an in-flight doConnect() can detect it was cancelled and unwind. */
132
+ private connectEpoch = 0;
133
+
134
+ private readonly refreshIntervalMs: number;
135
+ private readonly strictTls: boolean;
136
+
137
+ constructor(opts: RobotManagerOptions = {}) {
138
+ this.refreshIntervalMs = Math.max(200, opts.refreshIntervalMs ?? 1000);
139
+ this.strictTls = opts.strictTls === true;
140
+ }
141
+
142
+ get state(): RobotState { return this._state; }
143
+ /** The port currently in use (or last attempted). Useful for persisting auto-recovered port changes. */
144
+ get currentPort(): number | undefined { return this.adapterConfig?.port; }
145
+ /** The HTTPS flag matching `currentPort`. */
146
+ get currentUseHttps(): boolean | undefined {
147
+ if (!this.adapter || !this.adapterConfig) { return undefined; }
148
+ // RWS2Adapter is HTTPS, RWS1Adapter is HTTP. instanceof, not constructor.name —
149
+ // minified bundles rename classes, which made this persist the wrong protocol.
150
+ return this.adapter instanceof RWS2Adapter;
151
+ }
152
+ onDidChange(fn: ChangeHandler) { this.handlers.push(fn); }
153
+ private notify() { this.handlers.forEach(h => h()); }
154
+
155
+ /**
156
+ * Install an error listener. Called when the manager auto-disconnects after 3 failed
157
+ * polls. Hosts can route to UI dialogs (vscode.window.showErrorMessage), prompts, or
158
+ * alerting systems. The listener returns the chosen action; only 'Reconnect' is acted
159
+ * on internally — others are passed through for the host to handle.
160
+ */
161
+ onError(fn: ErrorListener) { this.errorListener = fn; }
162
+
163
+ // ─── Auto-detection ─────────────────────────────────────────────────────────
164
+
165
+ private static probePort(
166
+ host: string, port: number, useHttps: boolean, timeoutMs = 3000, strictTls = false
167
+ ): Promise<ProbeResult | null> {
168
+ return new Promise(resolve => {
169
+ const insecure = useHttps && !strictTls;
170
+ const agent = insecure ? new https.Agent({ rejectUnauthorized: false }) : undefined;
171
+ const options: http.RequestOptions & { agent?: https.Agent; rejectUnauthorized?: boolean } = {
172
+ method: 'GET', hostname: host, port,
173
+ path: '/rw/system',
174
+ headers: { Accept: 'application/xhtml+xml;v=2.0' },
175
+ // rejectUnauthorized must also be per-request: hosts that swap the agent
176
+ // (VS Code extension host, non-localhost targets) drop agent-level TLS
177
+ // settings — real controllers have self-signed certs (issue #2).
178
+ // Under strictTls neither is set, so certs verify normally.
179
+ ...(insecure ? { agent, rejectUnauthorized: false } : {}),
180
+ };
181
+ const tid = setTimeout(() => { req.destroy(); resolve(null); }, timeoutMs);
182
+ const req = ((useHttps ? https : http) as unknown as typeof https).request(
183
+ options as https.RequestOptions,
184
+ res => {
185
+ clearTimeout(tid);
186
+ const wwwAuth = (res.headers['www-authenticate'] ?? '') as string;
187
+ res.resume();
188
+ if (res.statusCode === 401) {
189
+ if (wwwAuth.startsWith('Digest')) { resolve({ port, useHttps, authType: 'digest' }); }
190
+ else if (wwwAuth.startsWith('Basic')) { resolve({ port, useHttps, authType: 'basic' }); }
191
+ else { resolve(null); }
192
+ } else { resolve(null); }
193
+ }
194
+ );
195
+ req.on('error', () => { clearTimeout(tid); resolve(null); });
196
+ req.end();
197
+ });
198
+ }
199
+
200
+ /** Common ports to check on any host. */
201
+ private static readonly PROBE_PORTS = [
202
+ { port: 80, useHttps: false },
203
+ { port: 443, useHttps: true },
204
+ { port: 28447, useHttps: false },
205
+ { port: 9403, useHttps: true },
206
+ ] as const;
207
+
208
+ /** Returns ALL responding controllers on a single host. */
209
+ static async detectAllControllers(host: string, strictTls = false): Promise<ProbeResult[]> {
210
+ const results = await Promise.all(
211
+ RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 3000, strictTls))
212
+ );
213
+ return results.filter((r): r is ProbeResult => r !== null);
214
+ }
215
+
216
+ /**
217
+ * Scan a set of standard ABB hosts for any responding RWS controller.
218
+ * Uses a shorter timeout (1.5 s) for snappy discovery UX.
219
+ * Returns every found controller with its host attached.
220
+ *
221
+ * If nothing is found on 127.0.0.1 via standard ports, falls back to a wide
222
+ * TCP scan of the local-VC port range — this catches RobotStudio VCs whose
223
+ * ports are randomly assigned each startup.
224
+ */
225
+ static async discoverControllers(extraHosts: string[] = [], strictTls = false): Promise<DiscoveredController[]> {
226
+ const hosts = [
227
+ '127.0.0.1', // Local virtual controllers (RobotStudio)
228
+ '192.168.125.1', // ABB standard service port — both IRC5 and OmniCore real robots
229
+ ...extraHosts,
230
+ ];
231
+
232
+ const probes = hosts.flatMap(host =>
233
+ RobotManager.PROBE_PORTS.map(c =>
234
+ RobotManager.probePort(host, c.port, c.useHttps, 1500, strictTls)
235
+ .then((r): DiscoveredController | null =>
236
+ r ? { host, port: r.port, useHttps: r.useHttps, authType: r.authType } : null
237
+ )
238
+ )
239
+ );
240
+
241
+ const results = await Promise.all(probes);
242
+ const found = results.filter((r): r is DiscoveredController => r !== null);
243
+
244
+ // Fallback: nothing on standard ports of 127.0.0.1 — try wide scan
245
+ // (RobotStudio assigns random high ports to VCs each restart).
246
+ const localHits = found.filter(c => c.host === '127.0.0.1' || c.host === 'localhost');
247
+ if (localHits.length === 0) {
248
+ Logger.info(`no controllers on standard ports of 127.0.0.1 — running wide scan…`);
249
+ const wide = await RobotManager.wideHostScan('127.0.0.1', strictTls);
250
+ Logger.info(`wide scan found ${wide.length} ABB controller(s) on 127.0.0.1`);
251
+ found.push(...wide);
252
+ }
253
+
254
+ return found;
255
+ }
256
+
257
+ /**
258
+ * Discover controllers via mDNS/Bonjour — additive alternative to the TCP
259
+ * probing of `discoverControllers`. Both real controllers and RobotStudio
260
+ * VCs advertise `RobotWebServices_<systemname>` on `_http._tcp.local`
261
+ * (VCs via the mDNSResponder service RobotStudio installs), so this finds
262
+ * VCs on their randomly-assigned ports without a port scan, and carries
263
+ * RW7 metadata (version, GUID, ports) from the TXT records.
264
+ *
265
+ * `probableProtocol` is a heuristic from the advertisement (RW7 attaches
266
+ * TXT metadata, RW6 does not) — confirm with `probeSpecificPort` before
267
+ * connecting. See MdnsDiscovery.ts for the live-verified wire details.
268
+ */
269
+ static async discoverControllersMdns(opts?: { timeoutMs?: number }): Promise<MdnsController[]> {
270
+ return discoverControllersMdns(opts);
271
+ }
272
+
273
+ /** Returns only the first responding controller (used internally during connect). */
274
+ static async detectController(host: string, strictTls = false): Promise<ProbeResult | null> {
275
+ const all = await RobotManager.detectAllControllers(host, strictTls);
276
+ return all[0] ?? null;
277
+ }
278
+
279
+ /**
280
+ * Probe an exact host:port to discover its auth type and protocol.
281
+ * Tries HTTPS first (most OmniCore VCs use it), then HTTP.
282
+ * Returns null only if neither responds — protects against guessing wrong
283
+ * (e.g. RobotStudio-assigned port 5466 is HTTPS but doesn't fit any heuristic).
284
+ */
285
+ static async probeSpecificPort(host: string, port: number, strictTls = false): Promise<ProbeResult | null> {
286
+ return (
287
+ (await RobotManager.probePort(host, port, true, 2000, strictTls)) ??
288
+ (await RobotManager.probePort(host, port, false, 2000, strictTls))
289
+ );
290
+ }
291
+
292
+ /** Fast TCP-only check to see if a port is accepting connections. */
293
+ private static tcpPing(host: string, port: number, timeoutMs = 100): Promise<boolean> {
294
+ return new Promise(resolve => {
295
+ const socket = new net.Socket();
296
+ const done = (open: boolean) => { socket.destroy(); resolve(open); };
297
+ socket.setTimeout(timeoutMs);
298
+ socket.once('connect', () => done(true));
299
+ socket.once('timeout', () => done(false));
300
+ socket.once('error', () => done(false));
301
+ socket.connect(port, host);
302
+ });
303
+ }
304
+
305
+ /**
306
+ * Wide-range port scan for RobotStudio VCs whose ports get reassigned each restart.
307
+ * Two-phase: TCP probe everything fast, then HTTP-probe only ports that responded.
308
+ *
309
+ * Uses a sliding-window worker pool to keep concurrency below the OS socket limit
310
+ * (Windows in particular drops connections silently above ~500 concurrent sockets).
311
+ *
312
+ * Heavy operation — only call when standard-port detection finds nothing.
313
+ * Prefer host=127.0.0.1; scanning a remote host this aggressively is rude.
314
+ */
315
+ static async wideHostScan(host: string, strictTls = false): Promise<DiscoveredController[]> {
316
+ // RobotStudio assigns RWS ports across a wide range. Observed values include
317
+ // 5466, 9403, 11811, 15120, 16146, 28447 — covering 4000–30000 catches them all.
318
+ const startPort = 1024;
319
+ const endPort = 30000;
320
+ const concurrency = 300; // safe on Windows; ~500 is the practical ceiling
321
+ const tcpTimeoutMs = 250; // generous to avoid false negatives
322
+
323
+ const tcpOpen: number[] = [];
324
+ let next = startPort;
325
+
326
+ // Worker pool: each worker pulls the next port until we run out
327
+ const worker = async () => {
328
+ while (next <= endPort) {
329
+ const port = next++;
330
+ if (await RobotManager.tcpPing(host, port, tcpTimeoutMs)) { tcpOpen.push(port); }
331
+ }
332
+ };
333
+ await Promise.all(Array.from({ length: concurrency }, worker));
334
+
335
+ Logger.info(`wide scan: ${tcpOpen.length} open TCP port(s) on ${host}, HTTP-probing each…`);
336
+
337
+ // HTTP-probe TCP-open ports — filter to actual ABB controllers
338
+ const probes = await Promise.all(
339
+ tcpOpen.map(port =>
340
+ RobotManager.probeSpecificPort(host, port, strictTls)
341
+ .then((r): DiscoveredController | null =>
342
+ r ? { host, port: r.port, useHttps: r.useHttps, authType: r.authType } : null
343
+ )
344
+ )
345
+ );
346
+ return probes.filter((r): r is DiscoveredController => r !== null);
347
+ }
348
+
349
+ // ─── Connection ─────────────────────────────────────────────────────────────
350
+
351
+ /**
352
+ * Connect to a controller.
353
+ * @param port If provided, skip auto-detection and use this port directly.
354
+ * Required when two controllers share the same host IP.
355
+ * @param useHttps If provided alongside port, sets the protocol explicitly.
356
+ *
357
+ * Rapid duplicate calls with the SAME target are coalesced — concurrent
358
+ * callers receive the same in-flight promise. Without this, fast double-clicks
359
+ * would spawn parallel adapters/timers and overwhelm the controller's session
360
+ * pool. A call with a DIFFERENT target instead cancels the in-flight attempt
361
+ * and connects fresh — otherwise the caller ends up connected, but not to
362
+ * what it asked for. Same for a SAME-target call after a disconnect() has
363
+ * cancelled the in-flight attempt: coalescing onto it would hand the caller
364
+ * a promise that resolves with the manager still disconnected.
365
+ */
366
+ connect(host: string, username: string, password: string, port?: number, useHttps?: boolean): Promise<void> {
367
+ if (this.connectingPromise) {
368
+ const pending = this.connectingArgs;
369
+ const sameArgs = !!pending
370
+ && pending.host === host && pending.username === username
371
+ && pending.password === password && pending.port === port
372
+ && pending.useHttps === useHttps;
373
+ const stillCurrent = !!pending && pending.epoch === this.connectEpoch && !pending.cancelled;
374
+ if (sameArgs && stillCurrent) {
375
+ Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} ignored (same connect already in flight)`);
376
+ return this.connectingPromise;
377
+ }
378
+ Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} supersedes ${sameArgs ? 'cancelled' : 'in-flight'} connect to ${pending?.host ?? '?'}`);
379
+ return this.startConnect(host, username, password, port, useHttps, this.connectingPromise);
380
+ }
381
+ return this.startConnect(host, username, password, port, useHttps);
382
+ }
383
+
384
+ private startConnect(host: string, username: string, password: string, port?: number, useHttps?: boolean, supersedes?: Promise<void>): Promise<void> {
385
+ const attempt: ConnectAttempt = { host, username, password, port, useHttps, epoch: this.connectEpoch };
386
+ this.connectingArgs = attempt;
387
+ const p = (async () => {
388
+ if (supersedes) {
389
+ await this.disconnectInternal(); // bumps connectEpoch → the in-flight doConnect unwinds
390
+ attempt.epoch = this.connectEpoch; // our own cancel of the old attempt doesn't invalidate this one
391
+ await supersedes.catch(() => {}); // let it finish unwinding before we start fresh
392
+ }
393
+ await this.doConnect(host, username, password, port, useHttps, attempt);
394
+ })().finally(() => {
395
+ // Only clear if we're still the current attempt — a superseding connect
396
+ // may have replaced these fields while we were settling.
397
+ if (this.connectingPromise === p) {
398
+ this.connectingPromise = null;
399
+ this.connectingArgs = null;
400
+ }
401
+ });
402
+ this.connectingPromise = p;
403
+ return p;
404
+ }
405
+
406
+ private async doConnect(host: string, username: string, password: string, port?: number, useHttps?: boolean, attempt?: ConnectAttempt): Promise<void> {
407
+ // If we're already connected to the same controller, this is a no-op.
408
+ // Without this, every accidental click of the Connect button burns a session
409
+ // through the disconnect → /logout → /rw/system reconnect cycle.
410
+ const cfg = this.adapterConfig;
411
+ if (this._state.connected && cfg
412
+ && cfg.host === host
413
+ && (port === undefined || cfg.port === port)
414
+ && cfg.username === username
415
+ && cfg.password === password) {
416
+ Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} skipped (already connected)`);
417
+ return;
418
+ }
419
+
420
+ if (this._state.connected) { await this.disconnectInternal(); }
421
+
422
+ // Any disconnect() after this point (user click, removeRobot) bumps the
423
+ // epoch; we re-check after every await so an aborted connect can't
424
+ // resurrect timers or subscriptions. The attempt record mirrors the value
425
+ // so connect() knows whether coalescing onto this attempt is still valid.
426
+ // User cancellations additionally set attempt.cancelled, which survives
427
+ // this re-read — otherwise a disconnect() racing our own teardown above
428
+ // would be erased here and the connection resurrected.
429
+ const epoch = this.connectEpoch;
430
+ if (attempt) { attempt.epoch = epoch; }
431
+ const aborted = (): boolean => attempt?.cancelled === true || epoch !== this.connectEpoch;
432
+ if (attempt?.cancelled) {
433
+ Logger.info(`connect → ${host} aborted (disconnected before probing)`);
434
+ return;
435
+ }
436
+
437
+ Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ' (auto-detect)'} as "${username}"`);
438
+
439
+ let probe: ProbeResult;
440
+ if (port !== undefined) {
441
+ // Port is pinned in config — verify it's actually reachable with the right protocol.
442
+ const verified = await RobotManager.probeSpecificPort(host, port, this.strictTls);
443
+ if (verified) {
444
+ probe = verified;
445
+ Logger.info(`port ${port} verified: ${verified.useHttps ? 'HTTPS' : 'HTTP'}/${verified.authType}`);
446
+ } else {
447
+ // Saved port didn't respond — RobotStudio reassigns VC ports each restart.
448
+ Logger.warn(`saved port ${port} not responding — scanning ${host} for an active controller…`);
449
+ const expectedAuth = (useHttps ?? (port === 443 || port === 9403)) ? 'basic' : 'digest';
450
+
451
+ // Phase 1: quick scan of the standard ports
452
+ let candidates = await RobotManager.detectAllControllers(host, this.strictTls);
453
+
454
+ // Phase 2: if nothing on standard ports and we're scanning localhost, do a wide scan
455
+ if (candidates.length === 0 && (host === '127.0.0.1' || host === 'localhost')) {
456
+ Logger.info(`standard ports empty — running wide scan 1024–30000 (this takes ~3 s)…`);
457
+ const wide = await RobotManager.wideHostScan(host, this.strictTls);
458
+ candidates = wide.map(c => ({ port: c.port, useHttps: c.useHttps, authType: c.authType }));
459
+ Logger.info(`wide scan found ${candidates.length} ABB controller(s) on ${host}`);
460
+ }
461
+
462
+ const match = candidates.find(c => c.authType === expectedAuth) ?? candidates[0];
463
+ if (match) {
464
+ probe = match;
465
+ Logger.info(`recovered: ${match.useHttps ? 'HTTPS' : 'HTTP'}/${match.authType} on port ${match.port} (saved was ${port})`);
466
+ } else {
467
+ // Last resort: try the saved port anyway — maybe a firewall blocks the probe but lets through auth
468
+ const https_ = useHttps ?? (port === 443 || port === 9403);
469
+ probe = { port, useHttps: https_, authType: https_ ? 'basic' : 'digest' };
470
+ Logger.warn(`no controller found anywhere on ${host} — falling back to saved port ${port}`);
471
+ }
472
+ }
473
+ } else {
474
+ // Auto-detect: probe all common ports
475
+ Logger.info(`auto-detecting controller at ${host} (ports 80, 443, 28447, 9403)`);
476
+ const found = await RobotManager.detectController(host, this.strictTls);
477
+ if (!found) {
478
+ const err = `No ABB RWS controller found at ${host}.\nChecked ports: 80, 443, 28447, 9403.\nEnsure the controller is reachable and RWS is enabled.`;
479
+ Logger.error(`auto-detect failed for ${host}`);
480
+ throw new Error(err);
481
+ }
482
+ probe = found;
483
+ Logger.info(`auto-detected: port ${probe.port} ${probe.useHttps ? 'HTTPS' : 'HTTP'}/${probe.authType}`);
484
+ }
485
+
486
+ if (aborted()) {
487
+ Logger.info(`connect → ${host} aborted (disconnected while probing)`);
488
+ return;
489
+ }
490
+
491
+ const cookieKey = `${host}:${probe.port}`;
492
+ const prevCfg = this.adapterConfig;
493
+ const sameConfig = prevCfg
494
+ && prevCfg.host === host && prevCfg.username === username
495
+ && prevCfg.password === password && prevCfg.port === probe.port;
496
+
497
+ if (!this.adapter || !sameConfig) {
498
+ if (probe.authType === 'basic') {
499
+ const scheme = probe.useHttps ? 'https' : 'http';
500
+ this.adapter = new RWS2Adapter(`${scheme}://${host}:${probe.port}`, username, password, { rejectUnauthorized: this.strictTls });
501
+ } else {
502
+ // Session cookie keyed by host:port so two controllers on same IP don't clobber each other
503
+ const cookie = this.loadSessionCookie(cookieKey) ?? undefined;
504
+ const rwsClient = new RwsClient({ host, port: probe.port, username, password, sessionCookie: cookie });
505
+ this.adapter = new RWS1Adapter(rwsClient, { host, port: probe.port, username, password });
506
+ }
507
+ this.adapterConfig = { host, username, password, port: probe.port };
508
+ }
509
+
510
+ try {
511
+ await this.adapter.connect();
512
+ } catch (e) {
513
+ Logger.error(`adapter.connect() failed for ${host}:${probe.port}`, e);
514
+ throw e;
515
+ }
516
+ if (aborted()) {
517
+ Logger.info(`connect → ${host} aborted (disconnected mid-connect) — closing session`);
518
+ await this.adapter.disconnect().catch(() => {});
519
+ return;
520
+ }
521
+ const cookie = this.adapter.getSessionCookie();
522
+ if (cookie) { this.saveSessionCookie(cookieKey, cookie); }
523
+
524
+ this._state.connected = true;
525
+ this._state.host = host;
526
+ Logger.info(`connected to ${host}:${probe.port}`);
527
+ this.notify();
528
+
529
+ // Start WebSocket subscriptions for instant state-change events.
530
+ // If subscriptions succeed, polling runs at 5× refreshIntervalMs (positions only).
531
+ // If they fail, polling runs at refreshIntervalMs (full state coverage as before).
532
+ await this.startSubscriptions();
533
+ if (aborted()) {
534
+ Logger.info(`connect → ${host} aborted (disconnected during subscription setup)`);
535
+ if (this.unsubscribeFn) { await this.unsubscribeFn().catch(() => {}); this.unsubscribeFn = null; }
536
+ this.subscriptionActive = false;
537
+ await this.adapter.disconnect().catch(() => {});
538
+ return;
539
+ }
540
+
541
+ // Every connect() invalidates older polling cycles so any timer that
542
+ // wasn't cleared (race between disconnect/reconnect) self-cancels.
543
+ const myGeneration = ++this.pollGeneration;
544
+ this.consecutiveFails = 0;
545
+
546
+ await this.fetchAll(myGeneration);
547
+ if (aborted()) { return; }
548
+ const pollMs = this.subscriptionActive ? 5 * this.refreshIntervalMs : this.refreshIntervalMs;
549
+ // Single-flight guard: if a fetchAll is still running when the timer
550
+ // fires, skip this tick. Prevents the request pile-up that caused
551
+ // 10-second timeouts on /cartesian and /tasks during heavy motion
552
+ // (controller's RWS layer queues behind the motion planner — responses
553
+ // can take >1s when joints are moving fast). Without this, a slow poll
554
+ // would stack against the next-second poll, causing both to timeout.
555
+ this.timer = setInterval(() => {
556
+ if (this.fetchInFlight) { return; }
557
+ this.fetchAll(myGeneration);
558
+ }, pollMs);
559
+ }
560
+
561
+ async disconnect(): Promise<void> {
562
+ // A user disconnect permanently cancels any in-flight connect attempt.
563
+ // The flag (not just the epoch) is what makes it stick: doConnect re-reads
564
+ // the epoch after its own internal teardowns, which would otherwise erase
565
+ // this cancellation and resurrect the connection.
566
+ if (this.connectingArgs) { this.connectingArgs.cancelled = true; }
567
+ return this.disconnectInternal();
568
+ }
569
+
570
+ /** Teardown used by doConnect/startConnect for their own reconnect cycles — must not cancel the attempt that invoked it. */
571
+ private async disconnectInternal(): Promise<void> {
572
+ // Bump generation FIRST so any in-flight fetchAll calls bail before they
573
+ // can trigger another disconnect cascade. The epoch likewise cancels any
574
+ // in-flight doConnect() so it can't re-install timers/subscriptions.
575
+ this.connectEpoch++;
576
+ this.pollGeneration++;
577
+ if (this.timer) { clearInterval(this.timer); this.timer = null; }
578
+ // Release any held motion mastership BEFORE closing the adapter
579
+ await this.releaseJogMastership();
580
+ // Unsubscribe WebSocket before closing adapter so DELETE /subscription fires correctly
581
+ if (this.unsubscribeFn) {
582
+ await this.unsubscribeFn().catch(() => {});
583
+ this.unsubscribeFn = null;
584
+ }
585
+ this.subscriptionActive = false;
586
+ if (this.adapter) { await this.adapter.disconnect().catch(() => {}); }
587
+ this._state = {
588
+ connected: false, host: '', ctrlstate: null, opmode: null,
589
+ execstate: null, execCycle: null, speedRatio: null, coldetstate: null,
590
+ tasks: [], modules: [], mechunits: [], joints: null,
591
+ cartesian: null, cartesianFull: null, identity: null, systemInfo: null,
592
+ eventLog: [], ioSignals: [],
593
+ };
594
+ this.notify();
595
+ }
596
+
597
+ async refresh(): Promise<void> { await this.fetchAll(); }
598
+
599
+ // ─── Panel control ──────────────────────────────────────────────────────────
600
+
601
+ async setMotorsOn(): Promise<void> {
602
+ if (!this.adapter) { throw new Error('Not connected'); }
603
+ await this.adapter.requestMastership('rapid');
604
+ try { await this.adapter.setControllerState('motoron'); }
605
+ finally { await this.adapter.releaseMastership('rapid').catch(() => {}); }
606
+ }
607
+
608
+ async setMotorsOff(): Promise<void> {
609
+ if (!this.adapter) { throw new Error('Not connected'); }
610
+ await this.adapter.requestMastership('rapid');
611
+ try { await this.adapter.setControllerState('motoroff'); }
612
+ finally { await this.adapter.releaseMastership('rapid').catch(() => {}); }
613
+ }
614
+
615
+ async setSpeedRatio(ratio: number): Promise<void> {
616
+ if (!this.adapter) { throw new Error('Not connected'); }
617
+ await this.adapter.setSpeedRatio(ratio);
618
+ this._state.speedRatio = ratio;
619
+ this.notify();
620
+ }
621
+
622
+ async lockOperationMode(pin: string, permanent?: boolean): Promise<void> {
623
+ if (!this.adapter) { throw new Error('Not connected'); }
624
+ await this.adapter.lockOperationMode(pin, permanent);
625
+ }
626
+
627
+ async unlockOperationMode(): Promise<void> {
628
+ if (!this.adapter) { throw new Error('Not connected'); }
629
+ await this.adapter.unlockOperationMode();
630
+ }
631
+
632
+ /**
633
+ * Switch operation mode (AUTO/MANR/MANF). VC-only — real hardware respects
634
+ * the FlexPendant key switch.
635
+ *
636
+ * State-machine constraints (ABB safety design):
637
+ * AUTO ↔ MANR: direct transition allowed.
638
+ * MANR ↔ MANF: direct transition allowed.
639
+ * AUTO ↔ MANF: NOT allowed direct — must go through MANR.
640
+ * Controller rejects with HTTP 500 "Operation failed" on the direct call.
641
+ * We auto-handle this by routing through MANR (two POSTs).
642
+ *
643
+ * Privilege:
644
+ * Going TO MANR/MANF: usually works without mastership (safer direction).
645
+ * Going TO AUTO: requires `edit` mastership + a confirmation popup on the
646
+ * FlexPendant. We wrap with mastership; the popup must be approved
647
+ * manually (no API path bypasses it — verified live).
648
+ */
649
+ async setOperationMode(mode: 'AUTO' | 'MANR' | 'MANF'): Promise<void> {
650
+ if (!this.adapter) { throw new Error('Not connected'); }
651
+ if (!this.adapter.setOperationMode) { throw new Error('setOperationMode not exposed by this adapter'); }
652
+
653
+ // Detect "must transit through MANR" pairs (AUTO ↔ MANF) and route via MANR.
654
+ const current = this._state.opmode;
655
+ const needsTransit =
656
+ (current === 'AUTO' && mode === 'MANF') ||
657
+ (current === 'MANF' && mode === 'AUTO');
658
+ if (needsTransit) {
659
+ Logger.info(`opmode: routing ${current} → MANR → ${mode} (direct transition not allowed)`);
660
+ await this.setOpmodeOnce('MANR');
661
+ // Brief pause so the controller settles the safety-chain state before the
662
+ // second hop — direct back-to-back POSTs sometimes get the second one
663
+ // rejected as "operation in progress."
664
+ await new Promise(r => setTimeout(r, 600));
665
+ await this.setOpmodeOnce(mode);
666
+ return;
667
+ }
668
+ await this.setOpmodeOnce(mode);
669
+ }
670
+
671
+ /** Single hop. Used internally + as the building block for multi-hop transitions. */
672
+ private async setOpmodeOnce(mode: 'AUTO' | 'MANR' | 'MANF'): Promise<void> {
673
+ const goingToAuto = mode === 'AUTO';
674
+ if (goingToAuto) {
675
+ try { await this.adapter!.requestMastership('rapid'); } catch { /* keep going */ }
676
+ try { await this.adapter!.setOperationMode!(mode); }
677
+ finally { await this.adapter!.releaseMastership('rapid').catch(() => {}); }
678
+ return;
679
+ }
680
+ await this.adapter!.setOperationMode!(mode);
681
+ }
682
+
683
+ // ─── Simulation panel (virtual controllers, RWS 2.0 only) ──────────────────
684
+ //
685
+ // Thin passthroughs to the RWS2 client's sim* methods — the endpoints exist
686
+ // only on RW7 virtual controllers (404 on real hardware and on RW6).
687
+
688
+ private simAdapter(): RWS2Adapter {
689
+ if (!(this.adapter instanceof RWS2Adapter)) {
690
+ throw new RwsError('Simulation panel requires an OmniCore (RWS 2.0) virtual controller', 'UNKNOWN');
691
+ }
692
+ return this.adapter;
693
+ }
694
+
695
+ async simEmergencyStop(): Promise<void> { return this.simAdapter().simEmergencyStop(); }
696
+ async simResetEmergencyStop(): Promise<void> { return this.simAdapter().simResetEmergencyStop(); }
697
+ async simGeneralStop(engage = true): Promise<void> { return this.simAdapter().simGeneralStop(engage); }
698
+ async simAutoStop(engage = true): Promise<void> { return this.simAdapter().simAutoStop(engage); }
699
+ async simEnableSwitch(on: boolean): Promise<void> { return this.simAdapter().simEnableSwitch(on); }
700
+ async teleportMechunit(mechunit: string, joints: number[], extJoints?: number[]): Promise<void> {
701
+ return this.simAdapter().teleportMechunit(mechunit, joints, extJoints);
702
+ }
703
+
704
+ // ─── RAPID execution ────────────────────────────────────────────────────────
705
+ //
706
+ // start/stop/resetpp/cycle all REQUIRE 'edit' mastership on RWS 2.0
707
+ // (RWS 1.0 calls it 'rapid', the adapter aliases internally).
708
+ // Live-confirmed: calling resetpp without mastership returns
709
+ // HTTP 403 with org_code -4501 / new_code 0xc004841d which the controller
710
+ // misleadingly tags as "RAPID error" — but the real cause is mastership.
711
+ //
712
+ // We acquire+release per-call so the manager doesn't hold mastership
713
+ // longer than necessary (other clients can use the controller meanwhile).
714
+
715
+ /**
716
+ * Wrap an op with auto-acquire-and-release of 'rapid'/'edit' mastership.
717
+ *
718
+ * NOTE: We deliberately do NOT proactively check RMMP here. Doing so caused
719
+ * false-positive errors when:
720
+ * - the controller is in AUTO mode and grants are sufficient without RMMP
721
+ * - the logged-in user lacks UAS grant to even *request* RMMP
722
+ * - the controller is a VC with no FlexPendant target for the popup
723
+ *
724
+ * Mastership-only is the right default. If the op fails because RMMP is
725
+ * actually missing, the caller's error handler can offer a Request-RMMP
726
+ * action — but it's a recovery path, not a precondition.
727
+ */
728
+ private async withMastership<T>(fn: () => Promise<T>): Promise<T> {
729
+ if (!this.adapter) { throw new Error('Not connected'); }
730
+ await this.adapter.requestMastership('rapid');
731
+ try { return await fn(); }
732
+ finally { await this.adapter.releaseMastership('rapid').catch(() => {}); }
733
+ }
734
+
735
+ // ─── Remote Mastership Privilege (RMMP) ──────────────────────────────────
736
+ /** Get current RMMP — 'none' / 'pending modify' / 'modify' / 'exclusive'. */
737
+ async getRmmpPrivilege(): Promise<string> {
738
+ if (!this.adapter?.getRmmpPrivilege) { return 'unsupported'; }
739
+ return this.adapter.getRmmpPrivilege();
740
+ }
741
+ /** Request RMMP — triggers a FlexPendant popup that the operator must approve. */
742
+ async requestRmmp(level: 'modify' | 'exclusive' = 'modify'): Promise<void> {
743
+ if (!this.adapter?.requestRmmp) { throw new Error('RMMP not supported on this controller'); }
744
+ return this.adapter.requestRmmp(level);
745
+ }
746
+
747
+ /**
748
+ * Tracks the last PP target the user explicitly chose via setPPToRoutine.
749
+ * Used by startRapid() to re-apply on next start when execstate is 'stopped'.
750
+ * Reason: when a routine completes (e.g. has `Stop;` at end, or cycle=once),
751
+ * PP advances past the routine's last instruction. The next Start with
752
+ * `execmode=continue` then does nothing visible — controller accepts the
753
+ * call (HTTP 204) but there's nothing left to execute.
754
+ * By re-applying the target on each Start-from-stopped, the user's mental
755
+ * model becomes "Start runs the routine I picked" — every time.
756
+ * Cleared by resetRapid() (PP-to-Main) so the user can then run main again.
757
+ */
758
+ private lastPPTarget: { module: string; routine: string } | null = null;
759
+
760
+ /**
761
+ * Start RAPID execution.
762
+ * If the program is currently stopped AND the user previously set PP to a
763
+ * specific routine via setPPToRoutine(), we re-apply that target before
764
+ * starting. This makes "Start" reliable across multiple clicks: the chosen
765
+ * routine runs from the top each time the program has finished and is
766
+ * idle.
767
+ * If the program is paused mid-execution (e.g. user hit Stop in the middle
768
+ * of a long routine), the PP target is NOT re-applied — Start resumes
769
+ * from where the user stopped.
770
+ * Detection: if PP currently lives in `lastPPTarget.routine`, we assume
771
+ * the user is in the "ran-then-stopped" state (PP at end-of-routine).
772
+ * If PP is in some other routine (controller moved it for an interrupt,
773
+ * trap, etc.), we leave it alone.
774
+ */
775
+ async startRapid(): Promise<void> {
776
+ if (!this.adapter) { throw new Error('Not connected'); }
777
+ await this.withMastership(async () => {
778
+ const target = this.lastPPTarget;
779
+ const stopped = this._state.execstate === 'stopped';
780
+ if (target && stopped && this.adapter!.setProgramPointer) {
781
+ // Best-effort: re-apply the target. If PP is still mid-routine
782
+ // (paused, not finished), this is still safe — it just resets PP
783
+ // to the start of the same routine.
784
+ await this.adapter!.setProgramPointer(this.activeTaskName(), {
785
+ module: target.module,
786
+ routine: target.routine,
787
+ }).catch(() => { /* swallow — start will surface its own error if any */ });
788
+ }
789
+ await this.adapter!.startRapid();
790
+ });
791
+ }
792
+
793
+ /** Active task name — the task flagged active, else the first task, else T_ROB1. */
794
+ private activeTaskName(): string {
795
+ const active = this._state.tasks.find(t => t.active);
796
+ return active?.name ?? this._state.tasks[0]?.name ?? 'T_ROB1';
797
+ }
798
+
799
+ async stopRapid(): Promise<void> {
800
+ if (!this.adapter) { throw new Error('Not connected'); }
801
+ await this.withMastership(() => this.adapter!.stopRapid());
802
+ }
803
+ async resetRapid(): Promise<void> {
804
+ if (!this.adapter) { throw new Error('Not connected'); }
805
+ // PP-to-Main clears the routine target — Start will go to main from now on.
806
+ this.lastPPTarget = null;
807
+ await this.withMastership(() => this.adapter!.resetRapid());
808
+ }
809
+
810
+ async setExecutionCycle(cycle: 'once' | 'forever' | 'asis'): Promise<void> {
811
+ if (!this.adapter) { throw new Error('Not connected'); }
812
+ await this.withMastership(() => this.adapter!.setExecutionCycle(cycle));
813
+ }
814
+
815
+ async activateRapidTask(task: string): Promise<void> {
816
+ if (!this.adapter) { throw new Error('Not connected'); }
817
+ await this.adapter.activateRapidTask(task);
818
+ }
819
+
820
+ async deactivateRapidTask(task: string): Promise<void> {
821
+ if (!this.adapter) { throw new Error('Not connected'); }
822
+ await this.adapter.deactivateRapidTask(task);
823
+ }
824
+
825
+ async activateAllRapidTasks(): Promise<void> {
826
+ if (!this.adapter) { throw new Error('Not connected'); }
827
+ await this.adapter.activateAllRapidTasks();
828
+ }
829
+
830
+ async deactivateAllRapidTasks(): Promise<void> {
831
+ if (!this.adapter) { throw new Error('Not connected'); }
832
+ await this.adapter.deactivateAllRapidTasks();
833
+ }
834
+
835
+ // ─── RAPID variables ────────────────────────────────────────────────────────
836
+
837
+ async getRapidVariable(task: string, module: string, symbol: string): Promise<string> {
838
+ if (!this.adapter) { throw new Error('Not connected'); }
839
+ return this.adapter.getRapidVariable(task, module, symbol);
840
+ }
841
+
842
+ async setRapidVariable(task: string, module: string, symbol: string, value: string): Promise<void> {
843
+ if (!this.adapter) { throw new Error('Not connected'); }
844
+ await this.adapter.setRapidVariable(task, module, symbol, value);
845
+ }
846
+
847
+ async validateRapidValue(task: string, value: string, datatype: string): Promise<boolean> {
848
+ if (!this.adapter) { throw new Error('Not connected'); }
849
+ return this.adapter.validateRapidValue(task, value, datatype);
850
+ }
851
+
852
+ async getRapidSymbolProperties(task: string, module: string, symbol: string): Promise<RapidSymbolProperties> {
853
+ if (!this.adapter) { throw new Error('Not connected'); }
854
+ return this.adapter.getRapidSymbolProperties(task, module, symbol);
855
+ }
856
+
857
+ async searchRapidSymbols(params: RapidSymbolSearchParams): Promise<RapidSymbolInfo[]> {
858
+ if (!this.adapter) { throw new Error('Not connected'); }
859
+ return this.adapter.searchRapidSymbols(params);
860
+ }
861
+
862
+ /**
863
+ * Detailed list of loaded modules — each entry has `name` + `type` (SysMod / ProgMod).
864
+ * Used by the Modules tree to render system vs program modules differently.
865
+ * Falls back to bare names from `listModules` if the adapter doesn't support details.
866
+ */
867
+ async listModulesDetailed(task: string): Promise<Array<{ name: string; type: string }>> {
868
+ if (!this.adapter) { throw new Error('Not connected'); }
869
+ if (this.adapter.listModulesDetailed) {
870
+ return this.adapter.listModulesDetailed(task);
871
+ }
872
+ const names = await this.adapter.listModules(task);
873
+ return names.map(n => ({ name: n, type: '' }));
874
+ }
875
+
876
+ /**
877
+ * Current program-pointer location for a task (module + routine + line).
878
+ * Returns null if the controller can't currently report a PP (e.g. no
879
+ * program loaded). Surfaces the data the Modules tree uses to highlight
880
+ * which routine is "active".
881
+ */
882
+ async getCurrentPP(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number } | null> {
883
+ if (!this.adapter?.getProgramPointer) { return null; }
884
+ try { return await this.adapter.getProgramPointer(task); }
885
+ catch { return null; }
886
+ }
887
+
888
+ /**
889
+ * List the routines (PROCs, FUNCs, TRAPs) defined in a loaded module.
890
+ * Uses the controller's symbol search rather than parsing the source —
891
+ * works even when the module file isn't on disk anymore (e.g. it was
892
+ * loaded then the file was deleted / never persisted).
893
+ *
894
+ * Returns: array of `{ name, symtyp }` where `symtyp` is one of:
895
+ * - 'prc' procedure (no return value, callable)
896
+ * - 'fun' function (returns a value)
897
+ * - 'trp' trap (interrupt handler)
898
+ * Routines from the controller's BASE / system modules are NOT included
899
+ * unless `includeSystem=true`.
900
+ */
901
+ async listRoutines(
902
+ task: string,
903
+ moduleName: string,
904
+ includeSystem = false,
905
+ ): Promise<Array<{ name: string; symtyp: string; symburl: string; local: boolean }>> {
906
+ if (!this.adapter) { throw new Error('Not connected'); }
907
+ const symbols = await this.adapter.searchRapidSymbols({
908
+ task,
909
+ blockurl: `RAPID/${task}/${moduleName}`,
910
+ symtyp: 'any',
911
+ // `recursive` accepts boolean in our type; the wire format is upper-case 'TRUE'
912
+ // and the adapter stringifies. Lib's default already sets 'TRUE' if omitted.
913
+ });
914
+ const routineKinds = new Set(['prc', 'fun', 'trp']);
915
+ return symbols
916
+ .filter(s => routineKinds.has(s.symtyp.toLowerCase()))
917
+ .filter(s => includeSystem || !s.local /* keep public; refine if needed */)
918
+ .map(s => ({ name: s.name, symtyp: s.symtyp, symburl: s.symburl, local: s.local }));
919
+ }
920
+
921
+ /**
922
+ * Set the program pointer to a specific routine, optionally in a specific module.
923
+ * If `module` is omitted, the controller picks based on its current scope.
924
+ * Wraps with `edit`/`rapid` mastership.
925
+ *
926
+ * After this returns successfully, the user can click Start and execution
927
+ * will begin at this routine instead of `main`.
928
+ */
929
+ async setPPToRoutine(task: string, moduleName: string, routine: string): Promise<void> {
930
+ if (!this.adapter) { throw new Error('Not connected'); }
931
+ if (!this.adapter.setProgramPointer) { throw new Error('setProgramPointer not supported on this protocol'); }
932
+ await this.withMastership(async () => {
933
+ await this.adapter!.stopRapid().catch(() => {});
934
+ await this.adapter!.setProgramPointer!(task, { module: moduleName, routine });
935
+ });
936
+ // Remember so startRapid() can re-apply on subsequent clicks once PP advances past the end.
937
+ this.lastPPTarget = { module: moduleName, routine };
938
+ }
939
+
940
+ async getActiveUiInstruction(): Promise<UiInstruction | null> {
941
+ if (!this.adapter) { throw new Error('Not connected'); }
942
+ return this.adapter.getActiveUiInstruction();
943
+ }
944
+
945
+ async setUiInstructionParam(stackurl: string, uiparam: string, value: string): Promise<void> {
946
+ if (!this.adapter) { throw new Error('Not connected'); }
947
+ await this.adapter.setUiInstructionParam(stackurl, uiparam, value);
948
+ }
949
+
950
+ // ─── File system ────────────────────────────────────────────────────────────
951
+
952
+ async listDirectory(remotePath: string): Promise<FileEntry[]> {
953
+ if (!this.adapter) { throw new Error('Not connected'); }
954
+ return this.adapter.listDirectory(remotePath);
955
+ }
956
+
957
+ async readFile(remotePath: string): Promise<string> {
958
+ if (!this.adapter) { throw new Error('Not connected'); }
959
+ return this.adapter.readFile(remotePath);
960
+ }
961
+
962
+ async deleteControllerFile(remotePath: string): Promise<void> {
963
+ if (!this.adapter) { throw new Error('Not connected'); }
964
+ await this.adapter.deleteFile(remotePath);
965
+ }
966
+
967
+ /**
968
+ * Create a directory on the controller filesystem.
969
+ * If `parentPath` itself doesn't exist, recursively creates the missing
970
+ * parents under the volume root ($HOME / HOME / BACKUP / …). This is
971
+ * mkdir -p semantics — friendlier than the bare ABB endpoint which
972
+ * returns 404 "Path does not exist" if any intermediate is missing.
973
+ */
974
+ async createDirectory(parentPath: string, dirName: string): Promise<void> {
975
+ if (!this.adapter) { throw new Error('Not connected'); }
976
+ try {
977
+ await this.adapter.createDirectory(parentPath, dirName);
978
+ return;
979
+ } catch (e) {
980
+ // 404 "Path does not exist" → ensure parents exist, then retry once.
981
+ const msg = e instanceof Error ? e.message : String(e);
982
+ if (!/HTTP 404|Path does not exist/i.test(msg)) { throw e; }
983
+ await this.ensureDirectory(parentPath);
984
+ await this.adapter.createDirectory(parentPath, dirName);
985
+ }
986
+ }
987
+
988
+ /**
989
+ * Walk down `path` from the volume root, creating each missing segment.
990
+ * Volumes ($HOME, HOME, BACKUP, DATA, …) themselves are NEVER created —
991
+ * they're controller-managed.
992
+ */
993
+ private async ensureDirectory(targetPath: string): Promise<void> {
994
+ if (!this.adapter) { throw new Error('Not connected'); }
995
+ // Strip leading slash if any; first segment is the volume name.
996
+ const cleaned = targetPath.replace(/^\/+/, '');
997
+ const segments = cleaned.split('/').filter(Boolean);
998
+ if (segments.length <= 1) { return; } // just a volume — caller can't create that
999
+ // Walk: $HOME, $HOME/a, $HOME/a/b, ...
1000
+ let cursor = segments[0]; // the volume
1001
+ for (let i = 1; i < segments.length; i++) {
1002
+ const seg = segments[i];
1003
+ try {
1004
+ await this.adapter.createDirectory(cursor, seg);
1005
+ } catch (e) {
1006
+ // 409-ish "already exists" or 200 — treat as ok. Other errors propagate.
1007
+ const msg = e instanceof Error ? e.message : String(e);
1008
+ if (!/already exists|HTTP 200|HTTP 204|409/i.test(msg)) {
1009
+ // Path didn't exist AND we couldn't create it — keep walking but
1010
+ // re-throw at the end if the final create fails. (One forgiving pass.)
1011
+ if (!/HTTP 404|Path does not exist/i.test(msg)) { throw e; }
1012
+ }
1013
+ }
1014
+ cursor = `${cursor}/${seg}`;
1015
+ }
1016
+ }
1017
+
1018
+ async copyControllerFile(sourcePath: string, destPath: string): Promise<void> {
1019
+ if (!this.adapter) { throw new Error('Not connected'); }
1020
+ await this.adapter.copyFile(sourcePath, destPath);
1021
+ }
1022
+
1023
+ // ─── Event log ──────────────────────────────────────────────────────────────
1024
+
1025
+ async refreshEventLog(): Promise<void> {
1026
+ if (!this.adapter) { return; }
1027
+ try { this._state.eventLog = await this.adapter.getEventLog(0, 'en'); this.notify(); }
1028
+ catch { /* non-fatal */ }
1029
+ }
1030
+
1031
+ async clearEventLog(): Promise<void> {
1032
+ if (!this.adapter) { throw new Error('Not connected'); }
1033
+ await this.adapter.clearEventLog(0);
1034
+ this._state.eventLog = [];
1035
+ this.notify();
1036
+ }
1037
+
1038
+ async clearAllEventLogs(): Promise<void> {
1039
+ if (!this.adapter) { throw new Error('Not connected'); }
1040
+ await this.adapter.clearAllEventLogs();
1041
+ this._state.eventLog = [];
1042
+ this.notify();
1043
+ }
1044
+
1045
+ // ─── Controller info ─────────────────────────────────────────────────────────
1046
+
1047
+ async restartController(mode: RestartMode): Promise<void> {
1048
+ if (!this.adapter) { throw new Error('Not connected'); }
1049
+ await this.adapter.restartController(mode);
1050
+ }
1051
+
1052
+ async getControllerClock(): Promise<string> {
1053
+ if (!this.adapter) { throw new Error('Not connected'); }
1054
+ return (await this.adapter.getControllerClock()).datetime;
1055
+ }
1056
+
1057
+ async setControllerClock(year: number, month: number, day: number, hour: number, min: number, sec: number): Promise<void> {
1058
+ if (!this.adapter) { throw new Error('Not connected'); }
1059
+ await this.adapter.setControllerClock(year, month, day, hour, min, sec);
1060
+ }
1061
+
1062
+ // ─── I/O ─────────────────────────────────────────────────────────────────────
1063
+
1064
+ async refreshIoSignals(): Promise<void> {
1065
+ if (!this.adapter) { throw new Error('Not connected'); }
1066
+ const PAGE = 100;
1067
+ let start = 0;
1068
+ const all: Signal[] = [];
1069
+ while (true) {
1070
+ const page = await this.adapter.listAllSignals(start, PAGE);
1071
+ all.push(...page);
1072
+ if (page.length < PAGE) { break; }
1073
+ start += PAGE;
1074
+ }
1075
+ this._state.ioSignals = all;
1076
+ this.notify();
1077
+ }
1078
+
1079
+ async writeIoSignal(name: string, value: string): Promise<void> {
1080
+ if (!this.adapter) { throw new Error('Not connected'); }
1081
+ await this.adapter.writeSignal('', '', name, value);
1082
+ const sig = this._state.ioSignals.find(s => s.name === name);
1083
+ if (sig) { sig.lvalue = value; sig.value = value; this.notify(); }
1084
+ }
1085
+
1086
+ async readIoSignal(network: string, device: string, name: string): Promise<Signal> {
1087
+ if (!this.adapter) { throw new Error('Not connected'); }
1088
+ return this.adapter.readSignal(network, device, name);
1089
+ }
1090
+
1091
+ async listNetworks(): Promise<IoNetwork[]> {
1092
+ if (!this.adapter) { throw new Error('Not connected'); }
1093
+ return this.adapter.listNetworks();
1094
+ }
1095
+
1096
+ async listDevices(network: string): Promise<IoDevice[]> {
1097
+ if (!this.adapter) { throw new Error('Not connected'); }
1098
+ return this.adapter.listDevices(network);
1099
+ }
1100
+
1101
+ // ─── Program loading ─────────────────────────────────────────────────────────
1102
+
1103
+ /**
1104
+ * Upload a single .mod / .sys file and load it into a task.
1105
+ *
1106
+ * Behavior:
1107
+ * 1. Stop RAPID briefly so the symbol table isn't being mutated mid-load.
1108
+ * 2. **If a module with the same name (= file basename without ext) is
1109
+ * already loaded, unload it first.** Live-verified gotcha: passing
1110
+ * `replace=true` to loadmod refreshes the *file* but the program's
1111
+ * symbol table still references the OLD module's procedures, so
1112
+ * `resetRapid()` afterwards reports "no main" even though the new
1113
+ * file has one. Explicit unload + load fixes this.
1114
+ * 3. Upload the file to $HOME (or HOME/ on RWS 2.0 — adapter rewrites).
1115
+ * 4. loadModule the new file.
1116
+ * 5. **If the new module declares `PROC main()`, auto-call PP-to-Main.**
1117
+ * This restores the original v0.1 ergonomics without the destructive
1118
+ * behavior the earlier loadProgram had — the user can immediately
1119
+ * click Start. Failures during the auto-resetpp are swallowed (the
1120
+ * module loaded fine; the user can manually click PP-to-Main if
1121
+ * needed). Detect by regex over the file content.
1122
+ * 6. **Does NOT unload OTHER modules.** Old versions unloaded every
1123
+ * non-system module, which destroyed the controller's pre-existing
1124
+ * program (e.g. OmniCore's `Module1`).
1125
+ */
1126
+ async loadProgram(localFilePath: string, taskName: string): Promise<void> {
1127
+ if (!this.adapter) { throw new Error('Not connected'); }
1128
+
1129
+ await this.adapter.requestMastership('rapid');
1130
+ try {
1131
+ await this.adapter.stopRapid().catch(() => {});
1132
+
1133
+ const content = fs.readFileSync(localFilePath, 'utf8');
1134
+ const fileName = path.basename(localFilePath);
1135
+ const moduleName = fileName.replace(/\.(mod|sys)$/i, '');
1136
+ const remotePath = `$HOME/${fileName}`;
1137
+
1138
+ const existing = await this.adapter.listModules(taskName).catch(() => [] as string[]);
1139
+ if (existing.includes(moduleName)) {
1140
+ await this.adapter.unloadModule(taskName, moduleName).catch(() => {});
1141
+ }
1142
+
1143
+ await this.adapter.uploadFile(remotePath, content);
1144
+ await this.adapter.loadModule(taskName, remotePath, true);
1145
+ this._state.modules = await this.adapter.listModules(taskName);
1146
+ this.notify();
1147
+
1148
+ // If the new module has a main proc, auto-resetpp so the user can Start
1149
+ // immediately. Match `PROC main(` allowing whitespace + comments above.
1150
+ // Errors here are non-fatal — the module loaded successfully even if
1151
+ // PP-to-Main fails (e.g. another module's main collides; user resolves manually).
1152
+ const hasMainProc = /\bPROC\s+main\s*\(/i.test(content);
1153
+ if (hasMainProc) {
1154
+ await this.adapter.resetRapid().catch(() => { /* user can do this manually */ });
1155
+ }
1156
+ } finally {
1157
+ await this.adapter.releaseMastership('rapid').catch(() => {});
1158
+ }
1159
+ }
1160
+
1161
+ /** @deprecated use loadProgram */
1162
+ uploadAndLoad(p: string, t: string) { return this.loadProgram(p, t); }
1163
+
1164
+ /**
1165
+ * Unload a single RAPID module from a task. Useful for resolving a
1166
+ * `main`-proc collision: if two modules both define `PROC main()`, RWS
1167
+ * refuses PP-to-Main with a semantic error — unload one and the other
1168
+ * becomes the program's entry point.
1169
+ */
1170
+ async unloadModule(taskName: string, moduleName: string): Promise<void> {
1171
+ if (!this.adapter) { throw new Error('Not connected'); }
1172
+ await this.adapter.requestMastership('rapid');
1173
+ try {
1174
+ await this.adapter.stopRapid().catch(() => {});
1175
+ await this.adapter.unloadModule(taskName, moduleName);
1176
+ this._state.modules = await this.adapter.listModules(taskName);
1177
+ this.notify();
1178
+ } finally {
1179
+ await this.adapter.releaseMastership('rapid').catch(() => {});
1180
+ }
1181
+ }
1182
+
1183
+ // ─── New verified endpoints (exposed for commands) ────────────────────────
1184
+
1185
+ async getLicenseInfo() { return this.adapter?.getLicenseInfo?.() ?? { entries: [] }; }
1186
+ async listProducts() { return this.adapter?.listProducts?.() ?? []; }
1187
+ async getRobotType(): Promise<{ type: string; variant?: string }> { return this.adapter?.getRobotType?.() ?? { type: '' }; }
1188
+ async getEnergyStats() { return this.adapter?.getEnergyStats?.() ?? {}; }
1189
+ async getReturnCode(code: number, lang = 'en') { return this.adapter?.getReturnCode?.(code, lang) ?? null; }
1190
+ async listControllerOptions() { return this.adapter?.listControllerOptions?.() ?? []; }
1191
+ async listFeatures() { return this.adapter?.listFeatures?.() ?? []; }
1192
+ async getMotionChangeCount() { return this.adapter?.getMotionChangeCount?.() ?? 0; }
1193
+ async getMotionErrorState() { return this.adapter?.getMotionErrorState?.() ?? { state: 'unknown' }; }
1194
+ async getNonMotionExecution(){ return this.adapter?.getNonMotionExecution?.() ?? false; }
1195
+ async setNonMotionExecution(enabled: boolean) { return this.adapter?.setNonMotionExecution?.(enabled); }
1196
+ async getCollisionPredictionMode() { return this.adapter?.getCollisionPredictionMode?.() ?? 'OFF'; }
1197
+ async setCollisionPredictionMode(m: string) { return this.adapter?.setCollisionPredictionMode?.(m); }
1198
+ async getEnableRequest() { return this.adapter?.getEnableRequest?.() ?? { state: 'unknown', raw: {} }; }
1199
+ async listAliasIO() { return this.adapter?.listAliasIO?.() ?? []; }
1200
+ async getTaskSelection(){ return this.adapter?.getTaskSelection?.() ?? { selected: [], available: [] }; }
1201
+ async setTaskSelection(tasks: string[]) { return this.adapter?.setTaskSelection?.(tasks); }
1202
+ async getProgramPointer(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number }> { return this.adapter?.getProgramPointer?.(task) ?? {}; }
1203
+ async getMotionPointer(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number }> { return this.adapter?.getMotionPointer?.(task) ?? {}; }
1204
+
1205
+ // CFG
1206
+ async listCfgDomains() { return this.adapter?.listCfgDomains?.() ?? []; }
1207
+ async listCfgTypes(d: string) { return this.adapter?.listCfgTypes?.(d) ?? []; }
1208
+ async listCfgInstances(d: string, t: string) { return this.adapter?.listCfgInstances?.(d, t) ?? []; }
1209
+ async getCfgInstance(d: string, t: string, i: string) { return this.adapter?.getCfgInstance?.(d, t, i) ?? {}; }
1210
+ async setCfgInstance(d: string, t: string, i: string, attrs: Record<string, string>): Promise<void> {
1211
+ if (!this.adapter?.setCfgInstance) { throw new Error('setCfgInstance not supported'); }
1212
+ await this.withMastership(() => this.adapter!.setCfgInstance!(d, t, i, attrs));
1213
+ }
1214
+ async createCfgInstance(d: string, t: string, i: string, attrs: Record<string, string>): Promise<void> {
1215
+ if (!this.adapter?.createCfgInstance) { throw new Error('createCfgInstance not supported'); }
1216
+ await this.withMastership(() => this.adapter!.createCfgInstance!(d, t, i, attrs));
1217
+ }
1218
+ async removeCfgInstance(d: string, t: string, i: string): Promise<void> {
1219
+ if (!this.adapter?.removeCfgInstance) { throw new Error('removeCfgInstance not supported'); }
1220
+ await this.withMastership(() => this.adapter!.removeCfgInstance!(d, t, i));
1221
+ }
1222
+ async loadCfgFile(filepath: string, action: 'add' | 'replace' | 'add-with-reset' = 'add'): Promise<void> {
1223
+ if (!this.adapter?.loadCfgFile) { throw new Error('loadCfgFile not supported'); }
1224
+ await this.withMastership(() => this.adapter!.loadCfgFile!(filepath, action));
1225
+ }
1226
+ async saveCfgFile(domain: string, filepath: string): Promise<void> {
1227
+ if (!this.adapter?.saveCfgFile) { throw new Error('saveCfgFile not supported'); }
1228
+ await this.withMastership(() => this.adapter!.saveCfgFile!(domain, filepath));
1229
+ }
1230
+
1231
+ // Backup / restore
1232
+ async listBackups() { return this.adapter?.listBackups?.() ?? []; }
1233
+ async createBackup(n: string) {
1234
+ if (!this.adapter?.createBackup) { throw new Error('createBackup not supported'); }
1235
+ return this.adapter.createBackup(n);
1236
+ }
1237
+ async restoreBackup(n: string) {
1238
+ if (!this.adapter?.restoreBackup) { throw new Error('restoreBackup not supported'); }
1239
+ return this.adapter.restoreBackup(n);
1240
+ }
1241
+ async getBackupStatus(): Promise<{ active: boolean; progress?: number; phase?: string }> { return this.adapter?.getBackupStatus?.() ?? { active: false }; }
1242
+
1243
+ // Module info / source / symbols / service-routine / tool-wobj activation
1244
+ // (getModuleSource, listModuleSymbols, calcCartesianFromJoints already
1245
+ // defined further below in this file.)
1246
+ async getModuleInfo(task: string, moduleName: string): Promise<Record<string, string>> {
1247
+ if (!this.adapter?.getModuleInfo) { return {}; }
1248
+ return this.adapter.getModuleInfo(task, moduleName);
1249
+ }
1250
+ async callServiceRoutine(task: string, routineName: string, args?: Record<string, string>): Promise<void> {
1251
+ if (!this.adapter?.callServiceRoutine) { throw new Error('callServiceRoutine not supported'); }
1252
+ return this.adapter.callServiceRoutine(task, routineName, args);
1253
+ }
1254
+ async setActiveTool(mechunit: string, toolName: string): Promise<void> {
1255
+ if (!this.adapter?.setActiveTool) { throw new Error('setActiveTool not supported'); }
1256
+ return this.adapter.setActiveTool(mechunit, toolName);
1257
+ }
1258
+ async setActiveWobj(mechunit: string, wobjName: string): Promise<void> {
1259
+ if (!this.adapter?.setActiveWobj) { throw new Error('setActiveWobj not supported'); }
1260
+ return this.adapter.setActiveWobj(mechunit, wobjName);
1261
+ }
1262
+
1263
+ // DIPC — Distributed Inter-Process Communication. Lets RAPID and external
1264
+ // clients exchange typed messages through named queues.
1265
+ async listDipcQueues() {
1266
+ if (!this.adapter?.listDipcQueues) { return []; }
1267
+ return this.adapter.listDipcQueues();
1268
+ }
1269
+ async createDipcQueue(name: string, options?: { maxsize?: number; maxmessages?: number }) {
1270
+ if (!this.adapter?.createDipcQueue) { throw new Error('DIPC not supported'); }
1271
+ return this.adapter.createDipcQueue(name, options);
1272
+ }
1273
+ async sendDipcMessage(queue: string, payload: string, type: 'string' | 'num' | 'dnum' | 'bool' = 'string') {
1274
+ if (!this.adapter?.sendDipcMessage) { throw new Error('DIPC not supported'); }
1275
+ return this.adapter.sendDipcMessage(queue, payload, type);
1276
+ }
1277
+ async readDipcMessage(queue: string, timeoutMs?: number) {
1278
+ if (!this.adapter?.readDipcMessage) { throw new Error('DIPC not supported'); }
1279
+ return this.adapter.readDipcMessage(queue, timeoutMs);
1280
+ }
1281
+ async removeDipcQueue(name: string) {
1282
+ if (!this.adapter?.removeDipcQueue) { throw new Error('DIPC not supported'); }
1283
+ return this.adapter.removeDipcQueue(name);
1284
+ }
1285
+
1286
+ // File volumes (HOME, BACKUP, DATA, ADDINDATA, PRODUCTS, RAMDISK, TEMP)
1287
+ async listFileVolumes(): Promise<string[]> {
1288
+ if (!this.adapter?.listFileVolumes) { return ['HOME']; } // safe default
1289
+ return this.adapter.listFileVolumes();
1290
+ }
1291
+
1292
+ // (validateRapidValue defined earlier; compressPath kept on adapter only)
1293
+ async compressPath(source: string, destination: string): Promise<void> {
1294
+ if (!this.adapter?.compressPath) { throw new Error('Compress not supported on this protocol'); }
1295
+ return this.adapter.compressPath(source, destination);
1296
+ }
1297
+
1298
+ // Tool/WObj
1299
+ async getActiveTool(m?: string) { return this.adapter?.getActiveTool?.(m) ?? { name: '' }; }
1300
+ async getActiveWobj(m?: string) { return this.adapter?.getActiveWobj?.(m) ?? { name: '' }; }
1301
+ async getActivePayload(m?: string) { return this.adapter?.getActivePayload?.(m) ?? { name: '' }; }
1302
+
1303
+ // Vision/Safety/VirtualTime
1304
+ async listVisionSystems() { return this.adapter?.listVisionSystems?.() ?? []; }
1305
+ async getSafetyStatus() { return this.adapter?.getSafetyStatus?.() ?? { state: 'unavailable' }; }
1306
+ async getVirtualTime() { return this.adapter?.getVirtualTime?.() ?? { time: 0, running: false }; }
1307
+ async setVirtualTimeRunning(r: boolean) { return this.adapter?.setVirtualTimeRunning?.(r); }
1308
+ async setVirtualTimeScale(s: number) { return this.adapter?.setVirtualTimeScale?.(s); }
1309
+
1310
+ // Mechunit details
1311
+ async getMechunitBaseFrame(m?: string) { return this.adapter?.getMechunitBaseFrame?.(m) ?? null; }
1312
+ async getMechunitAxes(m?: string) { return this.adapter?.getMechunitAxes?.(m) ?? []; }
1313
+ async getMechunitInfo(m?: string) { return this.adapter?.getMechunitInfo?.(m) ?? {}; }
1314
+
1315
+ // Task details
1316
+ async getTaskStructuralChangeCount(t: string) { return this.adapter?.getTaskStructuralChangeCount?.(t) ?? 0; }
1317
+ async getTaskMotion(t: string) { return this.adapter?.getTaskMotion?.(t) ?? {}; }
1318
+ async getTaskActivationRecord(t: string) { return this.adapter?.getTaskActivationRecord?.(t) ?? {}; }
1319
+ async getTaskProgramInfo(t: string) { return this.adapter?.getTaskProgramInfo?.(t) ?? {}; }
1320
+
1321
+ // Module
1322
+ async getModuleSource(t: string, n: string) { return this.adapter?.getModuleSource?.(t, n) ?? ''; }
1323
+ async listModuleSymbols(t: string, n: string) { return this.adapter?.listModuleSymbols?.(t, n) ?? []; }
1324
+
1325
+ // ─── Inverse + Forward Kinematics ───────────────────────────────────────────
1326
+
1327
+ async calcJointsFromCartesian(
1328
+ pos: RobTarget,
1329
+ seedJoints?: JointTarget,
1330
+ mechunit?: string,
1331
+ ): Promise<JointTarget> {
1332
+ if (!this.adapter) { throw new Error('Not connected'); }
1333
+ return this.adapter.calcJointsFromCartesian(pos, seedJoints, mechunit);
1334
+ }
1335
+
1336
+ async calcCartesianFromJoints(joints: JointTarget, mechunit = 'ROB_1', tool = 'tool0', wobj = 'wobj0'): Promise<RobTarget> {
1337
+ if (!this.adapter) { throw new Error('Not connected'); }
1338
+ if (!this.adapter.calcCartesianFromJoints) { throw new Error('Forward kinematics not supported on this protocol'); }
1339
+ return this.adapter.calcCartesianFromJoints(joints, mechunit, tool, wobj);
1340
+ }
1341
+
1342
+ // ─── Devices ─────────────────────────────────────────────────────────────────
1343
+
1344
+ async listSystemDevices(): Promise<Array<{ id: string; name: string }>> {
1345
+ if (!this.adapter?.listSystemDevices) { return []; }
1346
+ return this.adapter.listSystemDevices();
1347
+ }
1348
+ async listAllIoDevices(): Promise<Array<{ name: string; network: string; lstate: string; pstate: string; address: string }>> {
1349
+ if (!this.adapter?.listAllIoDevices) { return []; }
1350
+ return this.adapter.listAllIoDevices();
1351
+ }
1352
+
1353
+ // ─── Mastership extras ───────────────────────────────────────────────────────
1354
+
1355
+ async requestMastershipAll(): Promise<void> {
1356
+ if (!this.adapter?.requestMastershipAll) { throw new Error('requestMastershipAll not supported'); }
1357
+ return this.adapter.requestMastershipAll();
1358
+ }
1359
+ async releaseMastershipAll(): Promise<void> {
1360
+ if (!this.adapter?.releaseMastershipAll) { throw new Error('releaseMastershipAll not supported'); }
1361
+ return this.adapter.releaseMastershipAll();
1362
+ }
1363
+ async requestMastershipWithId(domain: 'rapid' | 'cfg' | 'motion'): Promise<number> {
1364
+ if (!this.adapter?.requestMastershipWithId) { throw new Error('Token-based mastership requires RWS 2.0'); }
1365
+ return this.adapter.requestMastershipWithId(domain);
1366
+ }
1367
+ async releaseMastershipWithId(domain: 'rapid' | 'cfg' | 'motion', id: number): Promise<void> {
1368
+ if (!this.adapter?.releaseMastershipWithId) { throw new Error('Token-based mastership requires RWS 2.0'); }
1369
+ return this.adapter.releaseMastershipWithId(domain, id);
1370
+ }
1371
+ async resetMastershipWatchdog(): Promise<void> {
1372
+ if (!this.adapter?.resetMastershipWatchdog) { throw new Error('Mastership watchdog requires RobotWare 7.8+'); }
1373
+ return this.adapter.resetMastershipWatchdog();
1374
+ }
1375
+
1376
+ /** Query who currently holds mastership on a domain. Returns null if adapter doesn't support it. */
1377
+ async getMastershipStatus(domain: 'rapid' | 'cfg' | 'motion' = 'rapid'): Promise<{ mastership: string; uid?: string; application?: string } | null> {
1378
+ if (!this.adapter?.getMastershipStatus) { return null; }
1379
+ try { return await this.adapter.getMastershipStatus(domain); }
1380
+ catch { return null; }
1381
+ }
1382
+
1383
+ // ─── Jogging ──────────────────────────────────────────────────────────────────
1384
+
1385
+ /** Tracks whether motion mastership is currently held — avoids redundant requests on rapid jog clicks. */
1386
+ private motionMastershipHeld = false;
1387
+ private jogReleaseTimer: NodeJS.Timeout | null = null;
1388
+
1389
+ /**
1390
+ * Jog the robot. Wraps the adapter call with safety checks and mastership.
1391
+ * Requests motion mastership on first call, releases 2s after the last call
1392
+ * (so rapid successive jogs don't churn mastership).
1393
+ */
1394
+ async jog(params: {
1395
+ mode: 'Joint' | 'Cartesian';
1396
+ axes: [number, number, number, number, number, number];
1397
+ speed: number;
1398
+ mechunit?: string;
1399
+ }): Promise<void> {
1400
+ if (!this.adapter) { throw new Error('Not connected'); }
1401
+
1402
+ // Safety: only allow jog in manual modes
1403
+ const mode = this._state.opmode;
1404
+ if (mode === 'AUTO') {
1405
+ throw new Error(`Jogging is not allowed in AUTO mode. Switch the controller to MANR or MANF first.`);
1406
+ }
1407
+ // Safety: motors must be on
1408
+ if (this._state.ctrlstate !== 'motoron') {
1409
+ throw new Error(`Motors are off (state: ${this._state.ctrlstate}). Turn motors on before jogging.`);
1410
+ }
1411
+
1412
+ // Ensure RMMP (Remote Mastership Privilege) — required for ANY modify op via RWS.
1413
+ // Without this, jog returns 403 "Operation not allowed for user".
1414
+ if (this.adapter.getRmmpPrivilege && this.adapter.requestRmmp) {
1415
+ const priv = await this.adapter.getRmmpPrivilege().catch(() => 'none');
1416
+ if (priv === 'none') {
1417
+ await this.adapter.requestRmmp('modify');
1418
+ Logger.warn('RMMP requested — open FlexPendant and approve the remote-control popup, then click jog again');
1419
+ throw new Error('Remote control not authorized yet. Open the FlexPendant and approve the popup that asks "Allow remote user to modify?", then click jog again.');
1420
+ }
1421
+ if (priv.startsWith('pending')) {
1422
+ throw new Error('Remote control approval is still pending. Open the FlexPendant and approve the popup, then click jog again.');
1423
+ }
1424
+ // 'modify' or 'exclusive' — proceed
1425
+ }
1426
+
1427
+ if (!this.motionMastershipHeld) {
1428
+ await this.adapter.requestMastership('motion');
1429
+ this.motionMastershipHeld = true;
1430
+ }
1431
+
1432
+ // Reset the auto-release timer on every jog so it only fires after 2 s of no jogging
1433
+ if (this.jogReleaseTimer) { clearTimeout(this.jogReleaseTimer); }
1434
+ this.jogReleaseTimer = setTimeout(() => { this.releaseJogMastership().catch(() => {}); }, 2000);
1435
+
1436
+ await this.adapter.jog(params);
1437
+ }
1438
+
1439
+ /** Release motion mastership immediately (called by disconnect and the auto-release timer). */
1440
+ private async releaseJogMastership(): Promise<void> {
1441
+ if (this.jogReleaseTimer) { clearTimeout(this.jogReleaseTimer); this.jogReleaseTimer = null; }
1442
+ if (this.motionMastershipHeld && this.adapter) {
1443
+ await this.adapter.releaseMastership('motion').catch(() => {});
1444
+ this.motionMastershipHeld = false;
1445
+ }
1446
+ }
1447
+
1448
+ async downloadModule(moduleName: string): Promise<string> {
1449
+ if (!this.adapter) { throw new Error('Not connected'); }
1450
+ return this.adapter.readFile(`$HOME/${moduleName}.mod`);
1451
+ }
1452
+
1453
+ // ─── Session cookie (RWS 1.0 only) ──────────────────────────────────────────
1454
+
1455
+ // ─── WebSocket subscriptions ──────────────────────────────────────────────────
1456
+
1457
+ /**
1458
+ * Subscribe to state-change events for instant UI updates.
1459
+ * Non-fatal: if subscriptions fail (e.g. controller doesn't support WS),
1460
+ * polling covers all state at full frequency.
1461
+ */
1462
+ private async startSubscriptions(): Promise<void> {
1463
+ if (!this.adapter) { return; }
1464
+ try {
1465
+ this.unsubscribeFn = await this.adapter.subscribe(
1466
+ [
1467
+ 'controllerstate',
1468
+ 'operationmode',
1469
+ 'speedratio',
1470
+ 'execution',
1471
+ 'coldetstate',
1472
+ { type: 'elog', domain: 0 },
1473
+ ],
1474
+ event => this.handleSubscriptionEvent(event),
1475
+ () => this.handleSubscriptionLost(),
1476
+ );
1477
+ this.subscriptionActive = true;
1478
+ } catch (e) {
1479
+ this.subscriptionActive = false;
1480
+ // Not an error — polling is the fallback
1481
+ console.log(
1482
+ `[RobotManager] WS subscriptions unavailable, using polling only: ${
1483
+ e instanceof Error ? e.message : String(e)
1484
+ }`
1485
+ );
1486
+ }
1487
+ }
1488
+
1489
+ /**
1490
+ * Adapter reports the event stream as terminally lost (WS reconnect attempts
1491
+ * exhausted). Drop back to the fast polling cadence so state stays fresh —
1492
+ * without this the manager keeps the 5× slow poll and the UI goes stale.
1493
+ */
1494
+ private handleSubscriptionLost(): void {
1495
+ if (!this.subscriptionActive) { return; }
1496
+ this.subscriptionActive = false;
1497
+ Logger.warn('live event stream lost — resuming fast polling');
1498
+ if (!this._state.connected || !this.timer) { return; }
1499
+ clearInterval(this.timer);
1500
+ const myGeneration = this.pollGeneration;
1501
+ this.timer = setInterval(() => {
1502
+ if (this.fetchInFlight) { return; }
1503
+ this.fetchAll(myGeneration);
1504
+ }, this.refreshIntervalMs);
1505
+ }
1506
+
1507
+ private handleSubscriptionEvent(event: SubscriptionEvent): void {
1508
+ if (!this._state.connected) { return; }
1509
+ let changed = false;
1510
+
1511
+ // event.resource can be a friendly name ('controllerstate') from RWS 1.0 via abb-rws-client,
1512
+ // or a URL path ('/rw/panel/ctrl-state;ctrlstate') from RWS 2.0.
1513
+ // RWS2Adapter.resourcePathToName() normalizes before calling here, but we also
1514
+ // match URL fragments as a safety net so both paths work regardless of adapter.
1515
+ const r = event.resource;
1516
+
1517
+ const isCtrlState = r === 'controllerstate' || /\/(ctrlstate|ctrl-state)/.test(r);
1518
+ const isOpMode = r === 'operationmode' || /\/opmode/.test(r);
1519
+ const isSpeed = r === 'speedratio' || /\/speedratio/.test(r);
1520
+ const isExec = (r === 'execution' || /\/execution/.test(r)) && !/execycle/.test(r);
1521
+ const isColdet = r === 'coldetstate' || /\/coldetstate/.test(r);
1522
+ const isElog = r === 'elog' || /\/elog\//.test(r);
1523
+
1524
+ if (isCtrlState) {
1525
+ if (this._state.ctrlstate !== event.value) { this._state.ctrlstate = event.value; changed = true; }
1526
+ } else if (isOpMode) {
1527
+ if (this._state.opmode !== event.value) { this._state.opmode = event.value; changed = true; }
1528
+ } else if (isSpeed) {
1529
+ const n = Number(event.value);
1530
+ if (!isNaN(n) && this._state.speedRatio !== n) { this._state.speedRatio = n; changed = true; }
1531
+ } else if (isExec) {
1532
+ if (this._state.execstate !== event.value) { this._state.execstate = event.value; changed = true; }
1533
+ } else if (isColdet) {
1534
+ if (this._state.coldetstate !== event.value) {
1535
+ this._state.coldetstate = event.value as CollisionDetectionState;
1536
+ changed = true;
1537
+ }
1538
+ } else if (isElog) {
1539
+ // New elog message arrived — refresh the log asynchronously
1540
+ this.adapter?.getEventLog(0, 'en').then(log => {
1541
+ this._state.eventLog = log;
1542
+ this.notify();
1543
+ }).catch(() => {});
1544
+ return; // notify() called inside the promise above
1545
+ }
1546
+
1547
+ if (changed) { this.notify(); }
1548
+ }
1549
+
1550
+ // ─── Session cookie ──────────────────────────────────────────────────────────
1551
+
1552
+ private loadSessionCookie(host: string): string | null {
1553
+ try { return (JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8'))[host] as string) ?? null; }
1554
+ catch { return null; }
1555
+ }
1556
+
1557
+ private saveSessionCookie(host: string, cookie: string): void {
1558
+ try {
1559
+ let data: Record<string, string> = {};
1560
+ try { data = JSON.parse(fs.readFileSync(SESSION_FILE, 'utf8')); } catch { /* new file */ }
1561
+ data[host] = cookie;
1562
+ // The file is shared across every RobotManager (and every host process),
1563
+ // so replace it atomically: write a temp file in the same directory,
1564
+ // then rename over the real one. A plain write lets a concurrent
1565
+ // manager read a half-written file and drop entries.
1566
+ const tmp = `${SESSION_FILE}.${process.pid}.${Date.now()}.tmp`;
1567
+ fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
1568
+ try {
1569
+ fs.renameSync(tmp, SESSION_FILE);
1570
+ } catch {
1571
+ // A concurrent writer beat us to the rename (Windows briefly locks the
1572
+ // destination) — drop our temp file and let the other writer win; the
1573
+ // cookie is re-saved on the next connect anyway.
1574
+ try { fs.unlinkSync(tmp); } catch { /* already gone */ }
1575
+ }
1576
+ } catch { /* non-fatal */ }
1577
+ }
1578
+
1579
+ // ─── Polling ─────────────────────────────────────────────────────────────────
1580
+
1581
+ private fetchCount = 0;
1582
+ /** Consecutive poll failures — only disconnects after 3, so transient blips don't drop the connection. */
1583
+ private consecutiveFails = 0;
1584
+
1585
+ private fetchInFlight = false;
1586
+
1587
+ private async fetchAll(generation?: number): Promise<void> {
1588
+ // Bail if this fetch belongs to an old generation (we've reconnected or
1589
+ // disconnected since). Re-checked after every await below — a disconnect
1590
+ // mid-poll clears _state, and a late-resolving request must not resurrect
1591
+ // the stale snapshot into it.
1592
+ const stale = (): boolean => generation !== undefined && generation !== this.pollGeneration;
1593
+ if (stale()) { return; }
1594
+ if (!this.adapter) { return; }
1595
+ if (this.fetchInFlight) { return; }
1596
+ this.fetchInFlight = true;
1597
+ try {
1598
+ const [execInfo, ctrlstate, opmode, speedRatio, tasks, joints, cartesianFull] =
1599
+ await Promise.all([
1600
+ this.adapter.getRapidExecutionInfo(),
1601
+ this.adapter.getControllerState(),
1602
+ this.adapter.getOperationMode(),
1603
+ this.adapter.getSpeedRatio(),
1604
+ this.adapter.getRapidTasks(),
1605
+ this.adapter.getJointPositions(),
1606
+ this.adapter.getCartesianFull(),
1607
+ ]);
1608
+ if (stale()) { return; }
1609
+
1610
+ // Module list needs a task name — resolve it from the tasks we just
1611
+ // fetched, not a hardcoded T_ROB1 (multi-task systems and OmniCore
1612
+ // single-arm variants name their tasks differently).
1613
+ this._state.tasks = tasks;
1614
+ const modules = await this.adapter.listModules(this.activeTaskName());
1615
+ if (stale()) { return; }
1616
+
1617
+ const cartesian = { x: cartesianFull.x, y: cartesianFull.y, z: cartesianFull.z, q1: cartesianFull.q1, q2: cartesianFull.q2, q3: cartesianFull.q3, q4: cartesianFull.q4 };
1618
+ Object.assign(this._state, {
1619
+ ctrlstate, opmode, execstate: execInfo.state, execCycle: execInfo.cycle,
1620
+ speedRatio, tasks, modules, joints, cartesian, cartesianFull,
1621
+ });
1622
+
1623
+ const coldetstate = await this.adapter.getCollisionDetectionState().catch(() => null);
1624
+ if (stale()) { return; }
1625
+ this._state.coldetstate = coldetstate;
1626
+
1627
+ this.fetchCount++;
1628
+ // Identity / systemInfo / eventLog / mechunits — only refresh occasionally,
1629
+ // BUT keep retrying every poll until they're populated (so a transient first-poll
1630
+ // failure doesn't leave the Status panel stuck on the IP fallback forever).
1631
+ const needSlowFetch =
1632
+ this.fetchCount === 1 ||
1633
+ this.fetchCount % 30 === 0 ||
1634
+ !this._state.systemInfo ||
1635
+ !this._state.identity;
1636
+
1637
+ if (needSlowFetch) {
1638
+ const [identity, systemInfo, eventLog, mechunits] = await Promise.all([
1639
+ this.adapter.getControllerIdentity().catch(e => { Logger.warn(`getControllerIdentity failed: ${e instanceof Error ? e.message : String(e)}`); return null; }),
1640
+ this.adapter.getSystemInfo().catch(e => { Logger.warn(`getSystemInfo failed: ${e instanceof Error ? e.message : String(e)}`); return null; }),
1641
+ this.adapter.getEventLog(0, 'en').catch(e => { Logger.warn(`getEventLog failed: ${e instanceof Error ? e.message : String(e)}`); return [] as ElogMessage[]; }),
1642
+ this.adapter.listMechunits().catch(e => { Logger.warn(`listMechunits failed: ${e instanceof Error ? e.message : String(e)}`); return ['ROB_1']; }),
1643
+ ]);
1644
+ if (stale()) { return; }
1645
+ if (identity) { this._state.identity = identity; }
1646
+ if (systemInfo) { this._state.systemInfo = systemInfo; }
1647
+ this._state.eventLog = eventLog;
1648
+ this._state.mechunits = mechunits;
1649
+ }
1650
+
1651
+ if (this.fetchCount === 1 || this.fetchCount % 5 === 0) {
1652
+ const PAGE = 100;
1653
+ let start = 0;
1654
+ const all: Signal[] = [];
1655
+ try {
1656
+ while (true) {
1657
+ const page = await this.adapter.listAllSignals(start, PAGE);
1658
+ if (stale()) { return; }
1659
+ all.push(...page);
1660
+ if (page.length < PAGE) { break; }
1661
+ start += PAGE;
1662
+ }
1663
+ this._state.ioSignals = all;
1664
+ } catch { /* non-fatal */ }
1665
+ }
1666
+
1667
+ if (stale()) { return; }
1668
+ this.consecutiveFails = 0;
1669
+ this.notify();
1670
+ } catch (e) {
1671
+ // Stale poll (disconnect or reconnect happened mid-flight) — don't count this failure.
1672
+ if (stale()) { return; }
1673
+
1674
+ this.consecutiveFails++;
1675
+ // First failure is usually transient (controller queued behind motion).
1676
+ // Log at info-level for the first; only warn from the second onward.
1677
+ const msg = `poll failed (${this.consecutiveFails}/3) — ${e instanceof Error ? e.message : String(e)}`;
1678
+ if (this.consecutiveFails >= 2) { Logger.warn(msg); }
1679
+ else { Logger.info(msg); }
1680
+ if (this.consecutiveFails >= 3) {
1681
+ const reason = e instanceof Error ? e.message : String(e);
1682
+ Logger.error(`disconnecting after 3 failed polls`, e);
1683
+ Logger.show();
1684
+ // Capture config BEFORE disconnect (which clears it) so the Reconnect button works.
1685
+ // Internal variant: this tears down the DEAD connection — it must not
1686
+ // cancel a fresh connect attempt the user may have started meanwhile.
1687
+ const cfg = this.adapterConfig;
1688
+ await this.disconnectInternal();
1689
+ // Hand off to the host's error listener (VS Code extension, CLI, etc.).
1690
+ // If no listener is installed, the failure is silent beyond the log lines above.
1691
+ if (this.errorListener) {
1692
+ this.errorListener(`ABB Robot disconnected: ${reason}`, ['Show Logs', 'Reconnect']).then(choice => {
1693
+ if (choice === 'Show Logs') { Logger.show(); }
1694
+ if (choice === 'Reconnect' && cfg) {
1695
+ this.connect(cfg.host, cfg.username, cfg.password, cfg.port)
1696
+ .catch(err => Logger.error('reconnect failed', err));
1697
+ }
1698
+ });
1699
+ }
1700
+ }
1701
+ } finally {
1702
+ this.fetchInFlight = false;
1703
+ }
1704
+ }
1705
+ }