abb-rws-client 0.5.0 → 0.7.2

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