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
@@ -1,4 +1,5 @@
1
1
  import { RwsClient } from './RwsClient.js';
2
+ import { RwsError } from './types.js';
2
3
  import * as https from 'https';
3
4
  import * as http from 'http';
4
5
  import * as net from 'net';
@@ -8,6 +9,7 @@ import * as path from 'path';
8
9
  import { RWS1Adapter } from './RWS1Adapter.js';
9
10
  import { RWS2Adapter } from './RWS2Adapter.js';
10
11
  import { Logger } from './Logger.js';
12
+ import { discoverControllersMdns } from './MdnsDiscovery.js';
11
13
  const SESSION_FILE = path.join(os.homedir(), '.abb-rws-session');
12
14
  export class RobotManager {
13
15
  adapter = null;
@@ -28,8 +30,18 @@ export class RobotManager {
28
30
  subscriptionActive = false;
29
31
  /** In-flight connect promise — used to dedupe rapid-clicks so we never run two connects in parallel. */
30
32
  connectingPromise = null;
33
+ /** Args of the in-flight connect, so a repeat call can tell "same target" from "new target". */
34
+ connectingArgs = null;
31
35
  /** Monotonic counter so old polling timers can detect they've been superseded and self-cancel. */
32
36
  pollGeneration = 0;
37
+ /** Bumped by disconnect() so an in-flight doConnect() can detect it was cancelled and unwind. */
38
+ connectEpoch = 0;
39
+ refreshIntervalMs;
40
+ strictTls;
41
+ constructor(opts = {}) {
42
+ this.refreshIntervalMs = Math.max(200, opts.refreshIntervalMs ?? 1000);
43
+ this.strictTls = opts.strictTls === true;
44
+ }
33
45
  get state() { return this._state; }
34
46
  /** The port currently in use (or last attempted). Useful for persisting auto-recovered port changes. */
35
47
  get currentPort() { return this.adapterConfig?.port; }
@@ -38,8 +50,9 @@ export class RobotManager {
38
50
  if (!this.adapter || !this.adapterConfig) {
39
51
  return undefined;
40
52
  }
41
- // RWS2Adapter is HTTPS, RWS1Adapter is HTTP
42
- return this.adapter.constructor.name === 'RWS2Adapter';
53
+ // RWS2Adapter is HTTPS, RWS1Adapter is HTTP. instanceof, not constructor.name —
54
+ // minified bundles rename classes, which made this persist the wrong protocol.
55
+ return this.adapter instanceof RWS2Adapter;
43
56
  }
44
57
  onDidChange(fn) { this.handlers.push(fn); }
45
58
  notify() { this.handlers.forEach(h => h()); }
@@ -51,14 +64,19 @@ export class RobotManager {
51
64
  */
52
65
  onError(fn) { this.errorListener = fn; }
53
66
  // ─── Auto-detection ─────────────────────────────────────────────────────────
54
- static probePort(host, port, useHttps, timeoutMs = 3000) {
67
+ static probePort(host, port, useHttps, timeoutMs = 3000, strictTls = false) {
55
68
  return new Promise(resolve => {
56
- const agent = useHttps ? new https.Agent({ rejectUnauthorized: false }) : undefined;
69
+ const insecure = useHttps && !strictTls;
70
+ const agent = insecure ? new https.Agent({ rejectUnauthorized: false }) : undefined;
57
71
  const options = {
58
72
  method: 'GET', hostname: host, port,
59
73
  path: '/rw/system',
60
74
  headers: { Accept: 'application/xhtml+xml;v=2.0' },
61
- ...(useHttps ? { agent } : {}),
75
+ // rejectUnauthorized must also be per-request: hosts that swap the agent
76
+ // (VS Code extension host, non-localhost targets) drop agent-level TLS
77
+ // settings — real controllers have self-signed certs (issue #2).
78
+ // Under strictTls neither is set, so certs verify normally.
79
+ ...(insecure ? { agent, rejectUnauthorized: false } : {}),
62
80
  };
63
81
  const tid = setTimeout(() => { req.destroy(); resolve(null); }, timeoutMs);
64
82
  const req = (useHttps ? https : http).request(options, res => {
@@ -92,8 +110,8 @@ export class RobotManager {
92
110
  { port: 9403, useHttps: true },
93
111
  ];
94
112
  /** 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)));
113
+ static async detectAllControllers(host, strictTls = false) {
114
+ const results = await Promise.all(RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 3000, strictTls)));
97
115
  return results.filter((r) => r !== null);
98
116
  }
99
117
  /**
@@ -105,13 +123,13 @@ export class RobotManager {
105
123
  * TCP scan of the local-VC port range — this catches RobotStudio VCs whose
106
124
  * ports are randomly assigned each startup.
107
125
  */
108
- static async discoverControllers(extraHosts = []) {
126
+ static async discoverControllers(extraHosts = [], strictTls = false) {
109
127
  const hosts = [
110
128
  '127.0.0.1', // Local virtual controllers (RobotStudio)
111
129
  '192.168.125.1', // ABB standard service port — both IRC5 and OmniCore real robots
112
130
  ...extraHosts,
113
131
  ];
114
- const probes = hosts.flatMap(host => RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 1500)
132
+ const probes = hosts.flatMap(host => RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 1500, strictTls)
115
133
  .then((r) => r ? { host, port: r.port, useHttps: r.useHttps, authType: r.authType } : null)));
116
134
  const results = await Promise.all(probes);
117
135
  const found = results.filter((r) => r !== null);
@@ -120,15 +138,30 @@ export class RobotManager {
120
138
  const localHits = found.filter(c => c.host === '127.0.0.1' || c.host === 'localhost');
121
139
  if (localHits.length === 0) {
122
140
  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');
141
+ const wide = await RobotManager.wideHostScan('127.0.0.1', strictTls);
124
142
  Logger.info(`wide scan found ${wide.length} ABB controller(s) on 127.0.0.1`);
125
143
  found.push(...wide);
126
144
  }
127
145
  return found;
128
146
  }
147
+ /**
148
+ * Discover controllers via mDNS/Bonjour — additive alternative to the TCP
149
+ * probing of `discoverControllers`. Both real controllers and RobotStudio
150
+ * VCs advertise `RobotWebServices_<systemname>` on `_http._tcp.local`
151
+ * (VCs via the mDNSResponder service RobotStudio installs), so this finds
152
+ * VCs on their randomly-assigned ports without a port scan, and carries
153
+ * RW7 metadata (version, GUID, ports) from the TXT records.
154
+ *
155
+ * `probableProtocol` is a heuristic from the advertisement (RW7 attaches
156
+ * TXT metadata, RW6 does not) — confirm with `probeSpecificPort` before
157
+ * connecting. See MdnsDiscovery.ts for the live-verified wire details.
158
+ */
159
+ static async discoverControllersMdns(opts) {
160
+ return discoverControllersMdns(opts);
161
+ }
129
162
  /** Returns only the first responding controller (used internally during connect). */
130
- static async detectController(host) {
131
- const all = await RobotManager.detectAllControllers(host);
163
+ static async detectController(host, strictTls = false) {
164
+ const all = await RobotManager.detectAllControllers(host, strictTls);
132
165
  return all[0] ?? null;
133
166
  }
134
167
  /**
@@ -137,9 +170,9 @@ export class RobotManager {
137
170
  * Returns null only if neither responds — protects against guessing wrong
138
171
  * (e.g. RobotStudio-assigned port 5466 is HTTPS but doesn't fit any heuristic).
139
172
  */
140
- static async probeSpecificPort(host, port) {
141
- return ((await RobotManager.probePort(host, port, true, 2000)) ??
142
- (await RobotManager.probePort(host, port, false, 2000)));
173
+ static async probeSpecificPort(host, port, strictTls = false) {
174
+ return ((await RobotManager.probePort(host, port, true, 2000, strictTls)) ??
175
+ (await RobotManager.probePort(host, port, false, 2000, strictTls)));
143
176
  }
144
177
  /** Fast TCP-only check to see if a port is accepting connections. */
145
178
  static tcpPing(host, port, timeoutMs = 100) {
@@ -163,7 +196,7 @@ export class RobotManager {
163
196
  * Heavy operation — only call when standard-port detection finds nothing.
164
197
  * Prefer host=127.0.0.1; scanning a remote host this aggressively is rude.
165
198
  */
166
- static async wideHostScan(host) {
199
+ static async wideHostScan(host, strictTls = false) {
167
200
  // RobotStudio assigns RWS ports across a wide range. Observed values include
168
201
  // 5466, 9403, 11811, 15120, 16146, 28447 — covering 4000–30000 catches them all.
169
202
  const startPort = 1024;
@@ -184,7 +217,7 @@ export class RobotManager {
184
217
  await Promise.all(Array.from({ length: concurrency }, worker));
185
218
  Logger.info(`wide scan: ${tcpOpen.length} open TCP port(s) on ${host}, HTTP-probing each…`);
186
219
  // HTTP-probe TCP-open ports — filter to actual ABB controllers
187
- const probes = await Promise.all(tcpOpen.map(port => RobotManager.probeSpecificPort(host, port)
220
+ const probes = await Promise.all(tcpOpen.map(port => RobotManager.probeSpecificPort(host, port, strictTls)
188
221
  .then((r) => r ? { host, port: r.port, useHttps: r.useHttps, authType: r.authType } : null)));
189
222
  return probes.filter((r) => r !== null);
190
223
  }
@@ -195,20 +228,54 @@ export class RobotManager {
195
228
  * Required when two controllers share the same host IP.
196
229
  * @param useHttps If provided alongside port, sets the protocol explicitly.
197
230
  *
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.
231
+ * Rapid duplicate calls with the SAME target are coalesced — concurrent
232
+ * callers receive the same in-flight promise. Without this, fast double-clicks
233
+ * would spawn parallel adapters/timers and overwhelm the controller's session
234
+ * pool. A call with a DIFFERENT target instead cancels the in-flight attempt
235
+ * and connects fresh — otherwise the caller ends up connected, but not to
236
+ * what it asked for. Same for a SAME-target call after a disconnect() has
237
+ * cancelled the in-flight attempt: coalescing onto it would hand the caller
238
+ * a promise that resolves with the manager still disconnected.
201
239
  */
202
240
  connect(host, username, password, port, useHttps) {
203
241
  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;
242
+ const pending = this.connectingArgs;
243
+ const sameArgs = !!pending
244
+ && pending.host === host && pending.username === username
245
+ && pending.password === password && pending.port === port
246
+ && pending.useHttps === useHttps;
247
+ const stillCurrent = !!pending && pending.epoch === this.connectEpoch && !pending.cancelled;
248
+ if (sameArgs && stillCurrent) {
249
+ Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} ignored (same connect already in flight)`);
250
+ return this.connectingPromise;
251
+ }
252
+ Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} supersedes ${sameArgs ? 'cancelled' : 'in-flight'} connect to ${pending?.host ?? '?'}`);
253
+ return this.startConnect(host, username, password, port, useHttps, this.connectingPromise);
254
+ }
255
+ return this.startConnect(host, username, password, port, useHttps);
256
+ }
257
+ startConnect(host, username, password, port, useHttps, supersedes) {
258
+ const attempt = { host, username, password, port, useHttps, epoch: this.connectEpoch };
259
+ this.connectingArgs = attempt;
260
+ const p = (async () => {
261
+ if (supersedes) {
262
+ await this.disconnectInternal(); // bumps connectEpoch → the in-flight doConnect unwinds
263
+ attempt.epoch = this.connectEpoch; // our own cancel of the old attempt doesn't invalidate this one
264
+ await supersedes.catch(() => { }); // let it finish unwinding before we start fresh
265
+ }
266
+ await this.doConnect(host, username, password, port, useHttps, attempt);
267
+ })().finally(() => {
268
+ // Only clear if we're still the current attempt — a superseding connect
269
+ // may have replaced these fields while we were settling.
270
+ if (this.connectingPromise === p) {
271
+ this.connectingPromise = null;
272
+ this.connectingArgs = null;
273
+ }
274
+ });
275
+ this.connectingPromise = p;
276
+ return p;
210
277
  }
211
- async doConnect(host, username, password, port, useHttps) {
278
+ async doConnect(host, username, password, port, useHttps, attempt) {
212
279
  // If we're already connected to the same controller, this is a no-op.
213
280
  // Without this, every accidental click of the Connect button burns a session
214
281
  // through the disconnect → /logout → /rw/system reconnect cycle.
@@ -216,18 +283,35 @@ export class RobotManager {
216
283
  if (this._state.connected && cfg
217
284
  && cfg.host === host
218
285
  && (port === undefined || cfg.port === port)
219
- && cfg.username === username) {
286
+ && cfg.username === username
287
+ && cfg.password === password) {
220
288
  Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} skipped (already connected)`);
221
289
  return;
222
290
  }
223
291
  if (this._state.connected) {
224
- await this.disconnect();
292
+ await this.disconnectInternal();
293
+ }
294
+ // Any disconnect() after this point (user click, removeRobot) bumps the
295
+ // epoch; we re-check after every await so an aborted connect can't
296
+ // resurrect timers or subscriptions. The attempt record mirrors the value
297
+ // so connect() knows whether coalescing onto this attempt is still valid.
298
+ // User cancellations additionally set attempt.cancelled, which survives
299
+ // this re-read — otherwise a disconnect() racing our own teardown above
300
+ // would be erased here and the connection resurrected.
301
+ const epoch = this.connectEpoch;
302
+ if (attempt) {
303
+ attempt.epoch = epoch;
304
+ }
305
+ const aborted = () => attempt?.cancelled === true || epoch !== this.connectEpoch;
306
+ if (attempt?.cancelled) {
307
+ Logger.info(`connect → ${host} aborted (disconnected before probing)`);
308
+ return;
225
309
  }
226
310
  Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ' (auto-detect)'} as "${username}"`);
227
311
  let probe;
228
312
  if (port !== undefined) {
229
313
  // Port is pinned in config — verify it's actually reachable with the right protocol.
230
- const verified = await RobotManager.probeSpecificPort(host, port);
314
+ const verified = await RobotManager.probeSpecificPort(host, port, this.strictTls);
231
315
  if (verified) {
232
316
  probe = verified;
233
317
  Logger.info(`port ${port} verified: ${verified.useHttps ? 'HTTPS' : 'HTTP'}/${verified.authType}`);
@@ -237,11 +321,11 @@ export class RobotManager {
237
321
  Logger.warn(`saved port ${port} not responding — scanning ${host} for an active controller…`);
238
322
  const expectedAuth = (useHttps ?? (port === 443 || port === 9403)) ? 'basic' : 'digest';
239
323
  // Phase 1: quick scan of the standard ports
240
- let candidates = await RobotManager.detectAllControllers(host);
324
+ let candidates = await RobotManager.detectAllControllers(host, this.strictTls);
241
325
  // Phase 2: if nothing on standard ports and we're scanning localhost, do a wide scan
242
326
  if (candidates.length === 0 && (host === '127.0.0.1' || host === 'localhost')) {
243
327
  Logger.info(`standard ports empty — running wide scan 1024–30000 (this takes ~3 s)…`);
244
- const wide = await RobotManager.wideHostScan(host);
328
+ const wide = await RobotManager.wideHostScan(host, this.strictTls);
245
329
  candidates = wide.map(c => ({ port: c.port, useHttps: c.useHttps, authType: c.authType }));
246
330
  Logger.info(`wide scan found ${candidates.length} ABB controller(s) on ${host}`);
247
331
  }
@@ -261,7 +345,7 @@ export class RobotManager {
261
345
  else {
262
346
  // Auto-detect: probe all common ports
263
347
  Logger.info(`auto-detecting controller at ${host} (ports 80, 443, 28447, 9403)`);
264
- const found = await RobotManager.detectController(host);
348
+ const found = await RobotManager.detectController(host, this.strictTls);
265
349
  if (!found) {
266
350
  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
351
  Logger.error(`auto-detect failed for ${host}`);
@@ -270,6 +354,10 @@ export class RobotManager {
270
354
  probe = found;
271
355
  Logger.info(`auto-detected: port ${probe.port} ${probe.useHttps ? 'HTTPS' : 'HTTP'}/${probe.authType}`);
272
356
  }
357
+ if (aborted()) {
358
+ Logger.info(`connect → ${host} aborted (disconnected while probing)`);
359
+ return;
360
+ }
273
361
  const cookieKey = `${host}:${probe.port}`;
274
362
  const prevCfg = this.adapterConfig;
275
363
  const sameConfig = prevCfg
@@ -278,7 +366,7 @@ export class RobotManager {
278
366
  if (!this.adapter || !sameConfig) {
279
367
  if (probe.authType === 'basic') {
280
368
  const scheme = probe.useHttps ? 'https' : 'http';
281
- this.adapter = new RWS2Adapter(`${scheme}://${host}:${probe.port}`, username, password);
369
+ this.adapter = new RWS2Adapter(`${scheme}://${host}:${probe.port}`, username, password, { rejectUnauthorized: this.strictTls });
282
370
  }
283
371
  else {
284
372
  // Session cookie keyed by host:port so two controllers on same IP don't clobber each other
@@ -295,6 +383,11 @@ export class RobotManager {
295
383
  Logger.error(`adapter.connect() failed for ${host}:${probe.port}`, e);
296
384
  throw e;
297
385
  }
386
+ if (aborted()) {
387
+ Logger.info(`connect → ${host} aborted (disconnected mid-connect) — closing session`);
388
+ await this.adapter.disconnect().catch(() => { });
389
+ return;
390
+ }
298
391
  const cookie = this.adapter.getSessionCookie();
299
392
  if (cookie) {
300
393
  this.saveSessionCookie(cookieKey, cookie);
@@ -304,15 +397,28 @@ export class RobotManager {
304
397
  Logger.info(`connected to ${host}:${probe.port}`);
305
398
  this.notify();
306
399
  // 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).
400
+ // If subscriptions succeed, polling runs at 5× refreshIntervalMs (positions only).
401
+ // If they fail, polling runs at refreshIntervalMs (full state coverage as before).
309
402
  await this.startSubscriptions();
403
+ if (aborted()) {
404
+ Logger.info(`connect → ${host} aborted (disconnected during subscription setup)`);
405
+ if (this.unsubscribeFn) {
406
+ await this.unsubscribeFn().catch(() => { });
407
+ this.unsubscribeFn = null;
408
+ }
409
+ this.subscriptionActive = false;
410
+ await this.adapter.disconnect().catch(() => { });
411
+ return;
412
+ }
310
413
  // Every connect() invalidates older polling cycles so any timer that
311
414
  // wasn't cleared (race between disconnect/reconnect) self-cancels.
312
415
  const myGeneration = ++this.pollGeneration;
313
416
  this.consecutiveFails = 0;
314
417
  await this.fetchAll(myGeneration);
315
- const pollMs = this.subscriptionActive ? 5000 : 1000;
418
+ if (aborted()) {
419
+ return;
420
+ }
421
+ const pollMs = this.subscriptionActive ? 5 * this.refreshIntervalMs : this.refreshIntervalMs;
316
422
  // Single-flight guard: if a fetchAll is still running when the timer
317
423
  // fires, skip this tick. Prevents the request pile-up that caused
318
424
  // 10-second timeouts on /cartesian and /tasks during heavy motion
@@ -327,8 +433,21 @@ export class RobotManager {
327
433
  }, pollMs);
328
434
  }
329
435
  async disconnect() {
436
+ // A user disconnect permanently cancels any in-flight connect attempt.
437
+ // The flag (not just the epoch) is what makes it stick: doConnect re-reads
438
+ // the epoch after its own internal teardowns, which would otherwise erase
439
+ // this cancellation and resurrect the connection.
440
+ if (this.connectingArgs) {
441
+ this.connectingArgs.cancelled = true;
442
+ }
443
+ return this.disconnectInternal();
444
+ }
445
+ /** Teardown used by doConnect/startConnect for their own reconnect cycles — must not cancel the attempt that invoked it. */
446
+ async disconnectInternal() {
330
447
  // Bump generation FIRST so any in-flight fetchAll calls bail before they
331
- // can trigger another disconnect cascade.
448
+ // can trigger another disconnect cascade. The epoch likewise cancels any
449
+ // in-flight doConnect() so it can't re-install timers/subscriptions.
450
+ this.connectEpoch++;
332
451
  this.pollGeneration++;
333
452
  if (this.timer) {
334
453
  clearInterval(this.timer);
@@ -458,6 +577,24 @@ export class RobotManager {
458
577
  }
459
578
  await this.adapter.setOperationMode(mode);
460
579
  }
580
+ // ─── Simulation panel (virtual controllers, RWS 2.0 only) ──────────────────
581
+ //
582
+ // Thin passthroughs to the RWS2 client's sim* methods — the endpoints exist
583
+ // only on RW7 virtual controllers (404 on real hardware and on RW6).
584
+ simAdapter() {
585
+ if (!(this.adapter instanceof RWS2Adapter)) {
586
+ throw new RwsError('Simulation panel requires an OmniCore (RWS 2.0) virtual controller', 'UNKNOWN');
587
+ }
588
+ return this.adapter;
589
+ }
590
+ async simEmergencyStop() { return this.simAdapter().simEmergencyStop(); }
591
+ async simResetEmergencyStop() { return this.simAdapter().simResetEmergencyStop(); }
592
+ async simGeneralStop(engage = true) { return this.simAdapter().simGeneralStop(engage); }
593
+ async simAutoStop(engage = true) { return this.simAdapter().simAutoStop(engage); }
594
+ async simEnableSwitch(on) { return this.simAdapter().simEnableSwitch(on); }
595
+ async teleportMechunit(mechunit, joints, extJoints) {
596
+ return this.simAdapter().teleportMechunit(mechunit, joints, extJoints);
597
+ }
461
598
  // ─── RAPID execution ────────────────────────────────────────────────────────
462
599
  //
463
600
  // start/stop/resetpp/cycle all REQUIRE 'edit' mastership on RWS 2.0
@@ -554,10 +691,10 @@ export class RobotManager {
554
691
  await this.adapter.startRapid();
555
692
  });
556
693
  }
557
- /** Active task name. We currently target T_ROB1; future: track multi-task. */
694
+ /** Active task name the task flagged active, else the first task, else T_ROB1. */
558
695
  activeTaskName() {
559
696
  const active = this._state.tasks.find(t => t.active);
560
- return active?.name ?? 'T_ROB1';
697
+ return active?.name ?? this._state.tasks[0]?.name ?? 'T_ROB1';
561
698
  }
562
699
  async stopRapid() {
563
700
  if (!this.adapter) {
@@ -1310,7 +1447,7 @@ export class RobotManager {
1310
1447
  'execution',
1311
1448
  'coldetstate',
1312
1449
  { type: 'elog', domain: 0 },
1313
- ], event => this.handleSubscriptionEvent(event));
1450
+ ], event => this.handleSubscriptionEvent(event), () => this.handleSubscriptionLost());
1314
1451
  this.subscriptionActive = true;
1315
1452
  }
1316
1453
  catch (e) {
@@ -1319,6 +1456,29 @@ export class RobotManager {
1319
1456
  console.log(`[RobotManager] WS subscriptions unavailable, using polling only: ${e instanceof Error ? e.message : String(e)}`);
1320
1457
  }
1321
1458
  }
1459
+ /**
1460
+ * Adapter reports the event stream as terminally lost (WS reconnect attempts
1461
+ * exhausted). Drop back to the fast polling cadence so state stays fresh —
1462
+ * without this the manager keeps the 5× slow poll and the UI goes stale.
1463
+ */
1464
+ handleSubscriptionLost() {
1465
+ if (!this.subscriptionActive) {
1466
+ return;
1467
+ }
1468
+ this.subscriptionActive = false;
1469
+ Logger.warn('live event stream lost — resuming fast polling');
1470
+ if (!this._state.connected || !this.timer) {
1471
+ return;
1472
+ }
1473
+ clearInterval(this.timer);
1474
+ const myGeneration = this.pollGeneration;
1475
+ this.timer = setInterval(() => {
1476
+ if (this.fetchInFlight) {
1477
+ return;
1478
+ }
1479
+ this.fetchAll(myGeneration);
1480
+ }, this.refreshIntervalMs);
1481
+ }
1322
1482
  handleSubscriptionEvent(event) {
1323
1483
  if (!this._state.connected) {
1324
1484
  return;
@@ -1395,7 +1555,24 @@ export class RobotManager {
1395
1555
  }
1396
1556
  catch { /* new file */ }
1397
1557
  data[host] = cookie;
1398
- fs.writeFileSync(SESSION_FILE, JSON.stringify(data), 'utf8');
1558
+ // The file is shared across every RobotManager (and every host process),
1559
+ // so replace it atomically: write a temp file in the same directory,
1560
+ // then rename over the real one. A plain write lets a concurrent
1561
+ // manager read a half-written file and drop entries.
1562
+ const tmp = `${SESSION_FILE}.${process.pid}.${Date.now()}.tmp`;
1563
+ fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
1564
+ try {
1565
+ fs.renameSync(tmp, SESSION_FILE);
1566
+ }
1567
+ catch {
1568
+ // A concurrent writer beat us to the rename (Windows briefly locks the
1569
+ // destination) — drop our temp file and let the other writer win; the
1570
+ // cookie is re-saved on the next connect anyway.
1571
+ try {
1572
+ fs.unlinkSync(tmp);
1573
+ }
1574
+ catch { /* already gone */ }
1575
+ }
1399
1576
  }
1400
1577
  catch { /* non-fatal */ }
1401
1578
  }
@@ -1405,8 +1582,12 @@ export class RobotManager {
1405
1582
  consecutiveFails = 0;
1406
1583
  fetchInFlight = false;
1407
1584
  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) {
1585
+ // Bail if this fetch belongs to an old generation (we've reconnected or
1586
+ // disconnected since). Re-checked after every await below — a disconnect
1587
+ // mid-poll clears _state, and a late-resolving request must not resurrect
1588
+ // the stale snapshot into it.
1589
+ const stale = () => generation !== undefined && generation !== this.pollGeneration;
1590
+ if (stale()) {
1410
1591
  return;
1411
1592
  }
1412
1593
  if (!this.adapter) {
@@ -1417,23 +1598,36 @@ export class RobotManager {
1417
1598
  }
1418
1599
  this.fetchInFlight = true;
1419
1600
  try {
1420
- const taskName = 'T_ROB1';
1421
- const [execInfo, ctrlstate, opmode, speedRatio, tasks, modules, joints, cartesianFull] = await Promise.all([
1601
+ const [execInfo, ctrlstate, opmode, speedRatio, tasks, joints, cartesianFull] = await Promise.all([
1422
1602
  this.adapter.getRapidExecutionInfo(),
1423
1603
  this.adapter.getControllerState(),
1424
1604
  this.adapter.getOperationMode(),
1425
1605
  this.adapter.getSpeedRatio(),
1426
1606
  this.adapter.getRapidTasks(),
1427
- this.adapter.listModules(taskName),
1428
1607
  this.adapter.getJointPositions(),
1429
1608
  this.adapter.getCartesianFull(),
1430
1609
  ]);
1610
+ if (stale()) {
1611
+ return;
1612
+ }
1613
+ // Module list needs a task name — resolve it from the tasks we just
1614
+ // fetched, not a hardcoded T_ROB1 (multi-task systems and OmniCore
1615
+ // single-arm variants name their tasks differently).
1616
+ this._state.tasks = tasks;
1617
+ const modules = await this.adapter.listModules(this.activeTaskName());
1618
+ if (stale()) {
1619
+ return;
1620
+ }
1431
1621
  const cartesian = { x: cartesianFull.x, y: cartesianFull.y, z: cartesianFull.z, q1: cartesianFull.q1, q2: cartesianFull.q2, q3: cartesianFull.q3, q4: cartesianFull.q4 };
1432
1622
  Object.assign(this._state, {
1433
1623
  ctrlstate, opmode, execstate: execInfo.state, execCycle: execInfo.cycle,
1434
1624
  speedRatio, tasks, modules, joints, cartesian, cartesianFull,
1435
1625
  });
1436
- this._state.coldetstate = await this.adapter.getCollisionDetectionState().catch(() => null);
1626
+ const coldetstate = await this.adapter.getCollisionDetectionState().catch(() => null);
1627
+ if (stale()) {
1628
+ return;
1629
+ }
1630
+ this._state.coldetstate = coldetstate;
1437
1631
  this.fetchCount++;
1438
1632
  // Identity / systemInfo / eventLog / mechunits — only refresh occasionally,
1439
1633
  // BUT keep retrying every poll until they're populated (so a transient first-poll
@@ -1449,6 +1643,9 @@ export class RobotManager {
1449
1643
  this.adapter.getEventLog(0, 'en').catch(e => { Logger.warn(`getEventLog failed: ${e instanceof Error ? e.message : String(e)}`); return []; }),
1450
1644
  this.adapter.listMechunits().catch(e => { Logger.warn(`listMechunits failed: ${e instanceof Error ? e.message : String(e)}`); return ['ROB_1']; }),
1451
1645
  ]);
1646
+ if (stale()) {
1647
+ return;
1648
+ }
1452
1649
  if (identity) {
1453
1650
  this._state.identity = identity;
1454
1651
  }
@@ -1465,6 +1662,9 @@ export class RobotManager {
1465
1662
  try {
1466
1663
  while (true) {
1467
1664
  const page = await this.adapter.listAllSignals(start, PAGE);
1665
+ if (stale()) {
1666
+ return;
1667
+ }
1468
1668
  all.push(...page);
1469
1669
  if (page.length < PAGE) {
1470
1670
  break;
@@ -1475,12 +1675,15 @@ export class RobotManager {
1475
1675
  }
1476
1676
  catch { /* non-fatal */ }
1477
1677
  }
1678
+ if (stale()) {
1679
+ return;
1680
+ }
1478
1681
  this.consecutiveFails = 0;
1479
1682
  this.notify();
1480
1683
  }
1481
1684
  catch (e) {
1482
1685
  // Stale poll (disconnect or reconnect happened mid-flight) — don't count this failure.
1483
- if (generation !== undefined && generation !== this.pollGeneration) {
1686
+ if (stale()) {
1484
1687
  return;
1485
1688
  }
1486
1689
  this.consecutiveFails++;
@@ -1498,8 +1701,10 @@ export class RobotManager {
1498
1701
  Logger.error(`disconnecting after 3 failed polls`, e);
1499
1702
  Logger.show();
1500
1703
  // Capture config BEFORE disconnect (which clears it) so the Reconnect button works.
1704
+ // Internal variant: this tears down the DEAD connection — it must not
1705
+ // cancel a fresh connect attempt the user may have started meanwhile.
1501
1706
  const cfg = this.adapterConfig;
1502
- await this.disconnect();
1707
+ await this.disconnectInternal();
1503
1708
  // Hand off to the host's error listener (VS Code extension, CLI, etc.).
1504
1709
  // If no listener is installed, the failure is silent beyond the log lines above.
1505
1710
  if (this.errorListener) {