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,7 +1,9 @@
1
1
  import * as https from 'https';
2
2
  import * as http from 'http';
3
3
  import { XhtmlParser } from './XhtmlParser.js';
4
+ import { HalJsonParser } from './HalJsonParser.js';
4
5
  import { Logger } from './Logger.js';
6
+ import { RwsError } from './types.js';
5
7
  /**
6
8
  * RWS 2.0 protocol client for ABB OmniCore controllers (RobotWare 7.x).
7
9
  *
@@ -12,10 +14,15 @@ import { Logger } from './Logger.js';
12
14
  * Key differences vs RWS 1.0 (all confirmed by live virtual-controller probing):
13
15
  * - HTTP Basic auth instead of Digest
14
16
  * - Path-based actions: /rw/rapid/execution/stop (not ?action=stop)
15
- * - XHTML responses only (Accept: application/xhtml+xml;v=2.0)
17
+ * - GETs are negotiated as HAL JSON (Accept: application/hal+json;v=2.0
18
+ * live-verified 2026-07-09 on RW7.21 for every GET family) with an automatic
19
+ * per-instance fallback to application/xhtml+xml;v=2.0 for older RW7
20
+ * releases; form-POST responses and subscription events are XHTML-only
16
21
  * - Mastership domains: 'edit' replaces both 'cfg' and 'rapid'
17
22
  * - FileService home: 'HOME' not '$HOME'
18
- * - Self-signed TLS on virtual controllers → rejectUnauthorized: false
23
+ * - Self-signed TLS on all shipping controllers → verification is OFF by default;
24
+ * pass `{ rejectUnauthorized: true }` to keep it on (e.g. controllers with a
25
+ * properly installed certificate).
19
26
  */
20
27
  export class RwsClient2 {
21
28
  baseUrl;
@@ -25,25 +32,60 @@ export class RwsClient2 {
25
32
  httpsAgent;
26
33
  httpAgent;
27
34
  isHttps;
35
+ /** Per-request timeout in ms (constructor `opts.timeout`, default 10000). */
36
+ timeoutMs;
37
+ /** When true, TLS certificate verification stays ON everywhere (requests, subscription POST, WebSocket). */
38
+ rejectUnauthorized;
28
39
  /** Session cookie set by the controller on first auth — REQUIRED to avoid creating
29
40
  * a new session per request (controller's session pool fills in seconds otherwise). */
30
41
  sessionCookie = null;
31
42
  /** Signal name → {network, device} — populated by listAllSignals for writeSignal lookups */
32
43
  sigCoords = new Map();
33
- constructor(baseUrl, username, password) {
44
+ constructor(baseUrl, username, password, opts = {}) {
34
45
  this.baseUrl = baseUrl;
35
46
  this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
36
47
  this.isHttps = baseUrl.startsWith('https');
48
+ this.timeoutMs = opts.timeout ?? 10000;
49
+ this.rejectUnauthorized = opts.rejectUnauthorized ?? false;
37
50
  // keepAlive reuses the TCP connection so we don't churn sessions on every poll.
38
- this.httpsAgent = new https.Agent({ rejectUnauthorized: false, keepAlive: true });
51
+ this.httpsAgent = new https.Agent({
52
+ keepAlive: true,
53
+ ...(this.rejectUnauthorized ? {} : { rejectUnauthorized: false }),
54
+ });
39
55
  this.httpAgent = new http.Agent({ keepAlive: true });
40
56
  }
41
57
  // ─── HTTP transport ────────────────────────────────────────────────────────
58
+ /** Primary GET representation. Officially supported on RWS 2.0; live-verified
59
+ * 2026-07-09 on OmniCore VC RW7.21 for every GET endpoint family in this client. */
60
+ static ACCEPT_HAL = 'application/hal+json;v=2.0';
61
+ /** Representation for writes, fileservice, subscriptions, and the fallback GET path. */
62
+ static ACCEPT_XHTML = 'application/xhtml+xml;v=2.0';
63
+ /** Set once a controller rejects hal+json (HTTP 406 or a non-JSON reply to a
64
+ * hal+json GET) — older RW7 releases predate HAL JSON. All subsequent GETs on
65
+ * this instance then go straight to XHTML instead of re-negotiating each time. */
66
+ preferXhtml = false;
67
+ /** GET paths that must keep the XHTML Accept: fileservice serves raw file bytes
68
+ * (a content-type-based negotiation retry would double every file read, and the
69
+ * service rejects some Accept values), and /logout's body is ignored anyway. */
70
+ static isXhtmlOnlyPath(path) {
71
+ return path.startsWith('/fileservice') || path === '/logout';
72
+ }
73
+ /** Picks the parser for a response body: HAL JSON (primary GET representation)
74
+ * or XHTML (fallback GETs, form-POST responses). Both expose the same reads. */
75
+ static parse(body) {
76
+ return HalJsonParser.looksLikeJson(body) ? new HalJsonParser(body) : new XhtmlParser(body);
77
+ }
78
+ /** Error block from either representation (JSON status.code/msg or XHTML spans). */
79
+ static extractError(body) {
80
+ return RwsClient2.parse(body).getError();
81
+ }
42
82
  /**
43
83
  * Core HTTP request. acceptExtra lists additional success status codes beyond 200/204.
44
84
  * Used by subscribe() to accept HTTP 201 (Created) from POST /subscription.
85
+ * acceptOverride pins the Accept header for callers that must not negotiate
86
+ * (e.g. getDeviceTree, which promises a raw XHTML document).
45
87
  */
46
- async req(method, path, body, rawBody, rawContentType, acceptExtra = []) {
88
+ async req(method, path, body, rawBody, rawContentType, acceptExtra = [], acceptOverride) {
47
89
  const wait = RwsClient2.MIN_MS - (Date.now() - this.lastReqTime);
48
90
  if (wait > 0) {
49
91
  await new Promise(r => setTimeout(r, wait));
@@ -54,6 +96,11 @@ export class RwsClient2 {
54
96
  // RWS 2.0 requires Content-Type on all POST/PUT/DELETE requests, even with no body
55
97
  // (mastership and a few other endpoints return HTTP 406 without it).
56
98
  const writingMethod = method === 'POST' || method === 'PUT' || method === 'DELETE';
99
+ // GETs negotiate HAL JSON; writes stay XHTML (form-POST responses are XHTML-only).
100
+ const wantsHal = method === 'GET' && !this.preferXhtml
101
+ && !acceptOverride && !RwsClient2.isXhtmlOnlyPath(path);
102
+ const accept = acceptOverride
103
+ ?? (wantsHal ? RwsClient2.ACCEPT_HAL : RwsClient2.ACCEPT_XHTML);
57
104
  const options = {
58
105
  method,
59
106
  hostname: url.hostname,
@@ -61,7 +108,7 @@ export class RwsClient2 {
61
108
  path: url.pathname + url.search,
62
109
  headers: {
63
110
  Authorization: this.authHeader,
64
- Accept: 'application/xhtml+xml;v=2.0',
111
+ Accept: accept,
65
112
  ...(this.sessionCookie ? { Cookie: this.sessionCookie } : {}),
66
113
  ...(writingMethod ? {
67
114
  'Content-Type': rawContentType ?? 'application/x-www-form-urlencoded;v=2.0',
@@ -69,6 +116,13 @@ export class RwsClient2 {
69
116
  } : {}),
70
117
  },
71
118
  agent: this.isHttps ? this.httpsAgent : this.httpAgent,
119
+ // Must ALSO be set per-request, not only on the agent: hosts that replace the
120
+ // agent (VS Code's extension host patches http/https and swaps custom agents for
121
+ // non-localhost targets) would otherwise re-enable TLS verification and fail on
122
+ // the self-signed certs ABB controllers ship. Live-reported on a real OmniCore RC
123
+ // (abb-rws-vscode issue #2, 2026-05-18); localhost VCs never hit this because the
124
+ // extension host doesn't intercept localhost traffic.
125
+ ...(this.isHttps && !this.rejectUnauthorized ? { rejectUnauthorized: false } : {}),
72
126
  };
73
127
  const startedAt = Date.now();
74
128
  Logger.trace?.('http.req', `RWS2 ${method} ${path}`, {
@@ -95,16 +149,34 @@ export class RwsClient2 {
95
149
  resolve('');
96
150
  return;
97
151
  }
152
+ // HAL JSON negotiation fallback: a controller predating hal+json either
153
+ // rejects the Accept outright (406) or ignores it and answers XHTML.
154
+ // Retry this one request as XHTML and remember the preference so every
155
+ // later GET on this instance skips the failed negotiation.
156
+ if (wantsHal) {
157
+ const contentType = String(res.headers['content-type'] ?? '');
158
+ if (status === 406 || (status < 400 && !/json/i.test(contentType))) {
159
+ this.preferXhtml = true;
160
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status} (hal+json not served — falling back to XHTML for this client)`, {
161
+ protocol: 'rws2', method, path, status, durationMs, contentType,
162
+ });
163
+ resolve(this.req(method, path, body, rawBody, rawContentType, acceptExtra));
164
+ return;
165
+ }
166
+ }
98
167
  if (acceptExtra.includes(status)) {
99
168
  Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status}`, { protocol: 'rws2', method, path, status, durationMs, bodyPreview: raw.slice(0, 200) });
100
169
  resolve(raw);
101
170
  return;
102
171
  }
103
172
  if (status >= 400) {
104
- const err = new XhtmlParser(raw).getError();
173
+ const err = RwsClient2.extractError(raw);
105
174
  Logger.trace?.('http.err', `RWS2 ${method} ${path} → ${status}`, { protocol: 'rws2', method, path, status, durationMs, errCode: err?.code, errMsg: err?.msg, bodyPreview: raw.slice(0, 300) });
106
- reject(new Error(`RWS2 ${method} ${path}: HTTP ${status}` +
107
- (err ? ` ${err.msg}` : '')));
175
+ const code = status === 401 ? 'AUTH_FAILED' :
176
+ status === 503 ? 'CONTROLLER_BUSY' :
177
+ status === 429 ? 'RATE_LIMITED' : 'UNKNOWN';
178
+ reject(new RwsError(`RWS2 ${method} ${path}: HTTP ${status}` +
179
+ (err ? ` — ${err.msg}` : ''), code, status, err?.msg));
108
180
  return;
109
181
  }
110
182
  Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status} (${raw.length}b)`, { protocol: 'rws2', method, path, status, durationMs, bodyLen: raw.length });
@@ -113,12 +185,12 @@ export class RwsClient2 {
113
185
  });
114
186
  req.on('error', (e) => {
115
187
  Logger.trace?.('http.err', `RWS2 ${method} ${path} → network error`, { protocol: 'rws2', method, path, error: String(e), durationMs: Date.now() - startedAt });
116
- reject(e);
188
+ reject(new RwsError(e instanceof Error ? e.message : String(e), 'NETWORK_ERROR'));
117
189
  });
118
- req.setTimeout(10000, () => {
190
+ req.setTimeout(this.timeoutMs, () => {
119
191
  req.destroy();
120
192
  Logger.trace?.('http.err', `RWS2 ${method} ${path} → timeout`, { protocol: 'rws2', method, path, durationMs: Date.now() - startedAt });
121
- reject(new Error(`RWS2 timeout: ${path}`));
193
+ reject(new RwsError(`RWS2 timeout: ${path}`, 'NETWORK_ERROR'));
122
194
  });
123
195
  if (bodyStr) {
124
196
  req.write(bodyStr);
@@ -139,18 +211,18 @@ export class RwsClient2 {
139
211
  getSessionCookie() { return this.sessionCookie; }
140
212
  // ─── Panel ─────────────────────────────────────────────────────────────────
141
213
  async getControllerState() {
142
- const p = new XhtmlParser(await this.req('GET', '/rw/panel/ctrl-state'));
214
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/ctrl-state'));
143
215
  return (p.getState('pnl-ctrlstate')['ctrlstate'] ?? 'init');
144
216
  }
145
217
  setControllerState(state) {
146
218
  return this.req('POST', '/rw/panel/ctrl-state', { 'ctrl-state': state }).then(() => { });
147
219
  }
148
220
  async getOperationMode() {
149
- const p = new XhtmlParser(await this.req('GET', '/rw/panel/opmode'));
221
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/opmode'));
150
222
  return (p.getState('pnl-opmode')['opmode'] ?? 'MANR');
151
223
  }
152
224
  async getSpeedRatio() {
153
- const p = new XhtmlParser(await this.req('GET', '/rw/panel/speedratio'));
225
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/speedratio'));
154
226
  return Number(p.getState('pnl-speedratio')['speedratio'] ?? 100);
155
227
  }
156
228
  /**
@@ -176,7 +248,7 @@ export class RwsClient2 {
176
248
  }
177
249
  }
178
250
  async getCollisionDetectionState() {
179
- const p = new XhtmlParser(await this.req('GET', '/rw/panel/coldetstate'));
251
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/coldetstate'));
180
252
  return (p.getState('pnl-coldetstate')['coldetstate'] ?? 'INIT');
181
253
  }
182
254
  lockOperationMode(pin, permanent = false) {
@@ -219,11 +291,11 @@ export class RwsClient2 {
219
291
  }
220
292
  // ─── RAPID execution ────────────────────────────────────────────────────────
221
293
  async getRapidExecutionState() {
222
- const p = new XhtmlParser(await this.req('GET', '/rw/rapid/execution'));
294
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/execution'));
223
295
  return (p.getState('rap-execution')['ctrlexecstate'] ?? 'stopped');
224
296
  }
225
297
  async getRapidExecutionInfo() {
226
- const p = new XhtmlParser(await this.req('GET', '/rw/rapid/execution'));
298
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/execution'));
227
299
  // Live: <li class="rap-execution"><span class="ctrlexecstate">stopped</span><span class="cycle">forever</span>
228
300
  const d = p.getState('rap-execution');
229
301
  return {
@@ -247,7 +319,7 @@ export class RwsClient2 {
247
319
  return this.req('POST', '/rw/rapid/execution/cycle', { cycle }).then(() => { });
248
320
  }
249
321
  async getRapidTasks() {
250
- const p = new XhtmlParser(await this.req('GET', '/rw/rapid/tasks'));
322
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/tasks'));
251
323
  return p.getAllStates('rap-task-li').map(t => ({
252
324
  name: t['name'] ?? '',
253
325
  type: t['type'] ?? 'normal',
@@ -302,7 +374,7 @@ export class RwsClient2 {
302
374
  }
303
375
  // ─── RAPID modules & variables ──────────────────────────────────────────────
304
376
  async listModules(task) {
305
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
377
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
306
378
  return p.getAllStates('rap-module-info-li').map(m => m['name']).filter(Boolean);
307
379
  }
308
380
  /**
@@ -310,7 +382,7 @@ export class RwsClient2 {
310
382
  * Single round-trip — same endpoint as `listModules` but exposes more fields.
311
383
  */
312
384
  async listModulesDetailed(task) {
313
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
385
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
314
386
  return p.getAllStates('rap-module-info-li')
315
387
  .map(m => ({ name: m['name'] ?? '', type: m['type'] ?? '' }))
316
388
  .filter(m => m.name);
@@ -337,7 +409,7 @@ export class RwsClient2 {
337
409
  async getRapidVariable(task, module, symbol) {
338
410
  // RWS 2.0 symbol API: suffix-style — /rw/rapid/symbol/{symburl}/data
339
411
  // (RWS 1.0 puts /data at the front: /rw/rapid/symbol/data/{symburl})
340
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/data`));
412
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/data`));
341
413
  return p.get('value') ?? '';
342
414
  }
343
415
  setRapidVariable(task, module, symbol, value) {
@@ -356,7 +428,7 @@ export class RwsClient2 {
356
428
  }
357
429
  }
358
430
  async getRapidSymbolProperties(task, module, symbol) {
359
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/properties`));
431
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/properties`));
360
432
  const d = p.getState('rap-sympropvar') || p.getState('rap-sympropvar-li') || p.getState('rap-symbol-properties');
361
433
  return {
362
434
  symburl: d['symburl'] ?? `RAPID/${task}/${module}/${symbol}`,
@@ -429,7 +501,7 @@ export class RwsClient2 {
429
501
  ];
430
502
  const out = [];
431
503
  for (const cls of liClasses) {
432
- const p = new XhtmlParser(xhtml);
504
+ const p = RwsClient2.parse(xhtml);
433
505
  for (const s of p.getAllStates(cls)) {
434
506
  out.push({
435
507
  symburl: s['symburl'] ?? '',
@@ -447,7 +519,7 @@ export class RwsClient2 {
447
519
  }
448
520
  async getActiveUiInstruction() {
449
521
  try {
450
- const p = new XhtmlParser(await this.req('GET', '/rw/rapid/uiinstr/active'));
522
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/uiinstr/active'));
451
523
  const d = p.getState('rap-uiinstr-li') || p.getState('rap-uiinstr');
452
524
  if (!d['instr']) {
453
525
  return null;
@@ -464,7 +536,7 @@ export class RwsClient2 {
464
536
  }
465
537
  // ─── Motion ─────────────────────────────────────────────────────────────────
466
538
  async getJointPositions(mechunit = 'ROB_1') {
467
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/jointtarget`));
539
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/jointtarget`));
468
540
  const d = p.getState('ms-jointtarget');
469
541
  return {
470
542
  rax_1: +d['rax_1'], rax_2: +d['rax_2'], rax_3: +d['rax_3'],
@@ -472,7 +544,7 @@ export class RwsClient2 {
472
544
  };
473
545
  }
474
546
  async getCartesianFull(mechunit = 'ROB_1') {
475
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/cartesian`));
547
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/cartesian`));
476
548
  // Live: cf1/cf4/cf6/cfx in RWS 2.0 map to j1/j4/j6/jx in CartesianFull type
477
549
  const d = p.getState('ms-mechunit-cartesian');
478
550
  return {
@@ -482,7 +554,7 @@ export class RwsClient2 {
482
554
  };
483
555
  }
484
556
  async listMechunits() {
485
- const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/mechunits'));
557
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/mechunits'));
486
558
  // Live: <li class="ms-mechunit-li" title="ROB_1">
487
559
  return p.getAllStates('ms-mechunit-li')
488
560
  .map(m => m['_title'])
@@ -490,18 +562,22 @@ export class RwsClient2 {
490
562
  }
491
563
  // ─── System info ─────────────────────────────────────────────────────────────
492
564
  async getSystemInfo() {
493
- const p = new XhtmlParser(await this.req('GET', '/rw/system'));
565
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system'));
494
566
  const d = p.getState('sys-system');
495
- const opts = p.getAllStates('sys-option').map(o => o['option']).filter(Boolean);
567
+ // Type-name drift between representations (live-verified 2026-07-09, RW7.21):
568
+ // XHTML lists options as class="sys-option"; HAL JSON nests them under the
569
+ // sys-options-li resource as _type="sys-options". Collect both.
570
+ const opts = [...p.getAllStates('sys-option'), ...p.getAllStates('sys-options')]
571
+ .map(o => o['option']).filter(Boolean);
496
572
  return { name: d['name'] ?? '', rwVersion: d['rwversion'] ?? '', sysid: d['sysid'] ?? '', startTime: d['starttm'] ?? '', options: opts };
497
573
  }
498
574
  async getControllerIdentity() {
499
- const p = new XhtmlParser(await this.req('GET', '/ctrl/identity'));
575
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/identity'));
500
576
  const d = p.getState('ctrl-identity-info');
501
577
  return { name: d['ctrl-name'] ?? '', id: '', type: d['ctrl-type'] ?? '', mac: '' };
502
578
  }
503
579
  async getControllerClock() {
504
- const p = new XhtmlParser(await this.req('GET', '/ctrl/clock'));
580
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/clock'));
505
581
  return { datetime: p.getState('ctrl-clock-info')['datetime'] ?? '' };
506
582
  }
507
583
  setControllerClock(year, month, day, hour, min, sec) {
@@ -521,7 +597,7 @@ export class RwsClient2 {
521
597
  // ─── Event log ───────────────────────────────────────────────────────────────
522
598
  async getEventLog(domain = 0) {
523
599
  // lang=en required to get title/desc/causes/actions (confirmed by live probe)
524
- const p = new XhtmlParser(await this.req('GET', `/rw/elog/${domain}?lang=en`));
600
+ const p = RwsClient2.parse(await this.req('GET', `/rw/elog/${domain}?lang=en`));
525
601
  return p.getAllStates('elog-message-li').map(m => {
526
602
  const parts = (m['_title'] ?? '').split('/');
527
603
  return {
@@ -547,7 +623,7 @@ export class RwsClient2 {
547
623
  }
548
624
  // ─── I/O signals ─────────────────────────────────────────────────────────────
549
625
  async listAllSignals(start = 0, limit = 200) {
550
- const p = new XhtmlParser(await this.req('GET', `/rw/iosystem/signals?start=${start}&limit=${limit}`));
626
+ const p = RwsClient2.parse(await this.req('GET', `/rw/iosystem/signals?start=${start}&limit=${limit}`));
551
627
  return p.getAllStates('ios-signal-li').map(s => {
552
628
  const name = s['name'] ?? s['_title']?.split('/').pop() ?? '';
553
629
  const parts = (s['_title'] ?? '').split('/');
@@ -558,7 +634,7 @@ export class RwsClient2 {
558
634
  });
559
635
  }
560
636
  async readSignal(network, device, name) {
561
- const p = new XhtmlParser(await this.req('GET', `/rw/iosystem/signals/${network}/${device}/${name}`));
637
+ const p = RwsClient2.parse(await this.req('GET', `/rw/iosystem/signals/${network}/${device}/${name}`));
562
638
  const d = p.getState('ios-signal-li');
563
639
  return { name: d['name'] ?? name, value: d['lvalue'] ?? '0', type: (d['type'] ?? 'DI'), lvalue: d['lvalue'] ?? '0' };
564
640
  }
@@ -566,13 +642,17 @@ export class RwsClient2 {
566
642
  let n = network, d = device;
567
643
  if (!n || !d) {
568
644
  const c = this.sigCoords.get(name);
569
- n = c?.n ?? '';
570
- d = c?.d ?? '';
645
+ if (!c) {
646
+ // Without coordinates the URL would degenerate to /signals///{name}/set-value.
647
+ return Promise.reject(new RwsError(`writeSignal: network/device unknown for signal "${name}" — pass them explicitly or call listAllSignals() first`, 'UNKNOWN'));
648
+ }
649
+ n = c.n;
650
+ d = c.d;
571
651
  }
572
652
  return this.req('POST', `/rw/iosystem/signals/${n}/${d}/${name}/set-value`, { lvalue: value }).then(() => { });
573
653
  }
574
654
  async listNetworks() {
575
- const p = new XhtmlParser(await this.req('GET', '/rw/iosystem/networks'));
655
+ const p = RwsClient2.parse(await this.req('GET', '/rw/iosystem/networks'));
576
656
  // Live: <li class="ios-network-li" title="IntegratedIONetwork">
577
657
  // <span class="name">IntegratedIONetwork</span><span class="pstate">running</span><span class="lstate">started</span>
578
658
  return p.getAllStates('ios-network-li').map(n => ({
@@ -582,7 +662,7 @@ export class RwsClient2 {
582
662
  }));
583
663
  }
584
664
  async listDevices(network) {
585
- const p = new XhtmlParser(await this.req('GET', `/rw/iosystem/devices?network=${encodeURIComponent(network)}`));
665
+ const p = RwsClient2.parse(await this.req('GET', `/rw/iosystem/devices?network=${encodeURIComponent(network)}`));
586
666
  // Live: <li class="ios-device-li" title="IntBus/EPanel">
587
667
  // <span class="name">EPanel</span><span class="lstate">enabled</span><span class="pstate">running</span><span class="address"></span>
588
668
  return p.getAllStates('ios-device-li').map(d => ({
@@ -594,9 +674,14 @@ export class RwsClient2 {
594
674
  }));
595
675
  }
596
676
  // ─── File system ──────────────────────────────────────────────────────────────
597
- rws2Path(path) { return path.replace(/\$HOME/g, 'HOME'); }
677
+ rws2Path(path) {
678
+ // Percent-encode per segment so names with spaces, '#', '%', etc. survive
679
+ // URL parsing ('#' would otherwise be treated as a fragment and truncate the path).
680
+ return path.replace(/\$HOME/g, 'HOME')
681
+ .split('/').map(encodeURIComponent).join('/');
682
+ }
598
683
  async listDirectory(path) {
599
- const p = new XhtmlParser(await this.req('GET', `/fileservice/${this.rws2Path(path)}`));
684
+ const p = RwsClient2.parse(await this.req('GET', `/fileservice/${this.rws2Path(path)}`));
600
685
  const dirs = p.getAllStates('fs-dir').map(d => ({ name: d['_title'] ?? '', type: 'dir', modified: d['fs-mdate'] }));
601
686
  const files = p.getAllStates('fs-file').map(f => ({ name: f['_title'] ?? '', type: 'file', size: f['fs-size'] ? +f['fs-size'] : undefined, created: f['fs-cdate'], modified: f['fs-mdate'], readonly: f['fs-readonly'] === 'true' }));
602
687
  return [...dirs, ...files];
@@ -627,9 +712,26 @@ export class RwsClient2 {
627
712
  }
628
713
  // ─── Configuration database `/rw/cfg` ───────────────────────────────────────
629
714
  async listCfgDomains() {
630
- const p = new XhtmlParser(await this.req('GET', '/rw/cfg'));
715
+ const p = RwsClient2.parse(await this.req('GET', '/rw/cfg'));
631
716
  return p.getAllStates('cfg-domain-li').map(d => d['_title'] ?? d['name']).filter(Boolean);
632
717
  }
718
+ /**
719
+ * Next-page path from a paginated list response, resolved relative to the
720
+ * parent of the current request path (matches the controller's relative
721
+ * hrefs; live-verified on the XHTML `rel="next"` links and, 2026-07-09 on
722
+ * RW7.21, on the HAL `_links.next.href` form). Both representations XML-escape
723
+ * ampersands in the href — even inside JSON strings — hence the unescape.
724
+ * Returns '' when there is no further page.
725
+ */
726
+ static nextPagePath(responseBody, currentPath) {
727
+ const rel = HalJsonParser.looksLikeJson(responseBody)
728
+ ? new HalJsonParser(responseBody).nextHref()
729
+ : responseBody.match(/<a\s+href="([^"]+)"\s+rel="next"/)?.[1];
730
+ if (!rel) {
731
+ return '';
732
+ }
733
+ return currentPath.replace(/[^/]*$/, '') + rel.replace(/&amp;/g, '&');
734
+ }
633
735
  async listCfgTypes(domain) {
634
736
  // Live-verified class: cfg-dt-li (datatype-li). Paginated — controller returns 70/page.
635
737
  // Pagination quirk: the `rel="next"` href is relative to the response's <base href>
@@ -639,18 +741,9 @@ export class RwsClient2 {
639
741
  let pages = 0;
640
742
  while (path && pages < 50) {
641
743
  const html = await this.req('GET', path);
642
- const p = new XhtmlParser(html);
744
+ const p = RwsClient2.parse(html);
643
745
  types.push(...p.getAllStates('cfg-dt-li').map(t => t['_title'] ?? t['name']).filter(Boolean));
644
- const nextMatch = html.match(/<a\s+href="([^"]+)"\s+rel="next"/);
645
- if (nextMatch) {
646
- const rel = nextMatch[1].replace(/&amp;/g, '&');
647
- // Resolve relative to the parent of the current path (controller's <base href> is /rw/cfg/).
648
- const parent = path.replace(/[^/]*$/, '');
649
- path = parent + rel;
650
- }
651
- else {
652
- path = '';
653
- }
746
+ path = RwsClient2.nextPagePath(html, path);
654
747
  pages++;
655
748
  }
656
749
  return types;
@@ -672,17 +765,9 @@ export class RwsClient2 {
672
765
  catch {
673
766
  return instances;
674
767
  } // invalid type or no permission — silent empty
675
- const p = new XhtmlParser(html);
768
+ const p = RwsClient2.parse(html);
676
769
  instances.push(...p.getAllStates('cfg-dt-instance-li').map(i => i['_title'] ?? '').filter(Boolean));
677
- const nextMatch = html.match(/<a\s+href="([^"]+)"\s+rel="next"/);
678
- if (nextMatch) {
679
- const rel = nextMatch[1].replace(/&amp;/g, '&');
680
- const parent = path.replace(/[^/]*$/, '');
681
- path = parent + rel;
682
- }
683
- else {
684
- path = '';
685
- }
770
+ path = RwsClient2.nextPagePath(html, path);
686
771
  pages++;
687
772
  }
688
773
  return instances;
@@ -692,7 +777,7 @@ export class RwsClient2 {
692
777
  // Returns an outer cfg-dt-instance li with NESTED cfg-ia-t li elements.
693
778
  // Each attribute: <li class="cfg-ia-t" title="ATTR_NAME"><span class="value">VALUE</span></li>
694
779
  const html = await this.req('GET', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`);
695
- const p = new XhtmlParser(html);
780
+ const p = RwsClient2.parse(html);
696
781
  const attribs = p.getAllStates('cfg-ia-t');
697
782
  const result = {};
698
783
  for (const attr of attribs) {
@@ -704,15 +789,42 @@ export class RwsClient2 {
704
789
  }
705
790
  return result;
706
791
  }
792
+ /**
793
+ * Update attributes on an existing configuration instance. Requires 'edit'
794
+ * mastership (callers hold it; RobotManager wraps these with mastership).
795
+ *
796
+ * Live-verified 2026-07-09 on OmniCore VC RW7.21 via probe-cfg-rws2.mjs:
797
+ * ✓ POST /rw/cfg/{domain}/{type}/instances/{instance}
798
+ * body: each attribute in BRACKET representation `Attr=[value,1]` joined
799
+ * by '&', values literal (not percent-encoded), Content-Type
800
+ * application/x-www-form-urlencoded;v=2.0 → 204. Partial attribute sets
801
+ * are accepted; unknown attribute names → 400 "Error set attribute".
802
+ * ✗ POST /rw/cfg/{domain}/{type}/{instance} (no /instances/) → 404
803
+ */
707
804
  async setCfgInstance(domain, type, instance, attrs) {
708
- await this.req('POST', `/rw/cfg/${domain}/${type}/${encodeURIComponent(instance)}`, attrs);
805
+ const body = Object.entries(attrs).map(([k, v]) => `${k}=[${v},1]`).join('&');
806
+ await this.req('POST', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`, undefined, body, 'application/x-www-form-urlencoded;v=2.0');
709
807
  }
808
+ /**
809
+ * Create a new configuration instance, then apply `attrs`. Requires 'edit'
810
+ * mastership. Live-verified 2026-07-09 on OmniCore VC RW7.21:
811
+ * ✓ POST /rw/cfg/{domain}/{type}/instances/create-default body name={instance} → 201,
812
+ * followed by the setCfgInstance shape above for the attribute values.
813
+ * ✗ POST /rw/cfg/{domain}/{type}/{instance}/create → 404 (endpoint doesn't exist)
814
+ */
710
815
  async createCfgInstance(domain, type, instance, attrs) {
711
- // POST on the type root with name=... creates a new instance
712
- await this.req('POST', `/rw/cfg/${domain}/${type}/${encodeURIComponent(instance)}/create`, attrs);
816
+ await this.req('POST', `/rw/cfg/${domain}/${type}/instances/create-default`, undefined, `name=${instance}`, 'application/x-www-form-urlencoded;v=2.0');
817
+ if (Object.keys(attrs).length > 0) {
818
+ await this.setCfgInstance(domain, type, instance, attrs);
819
+ }
713
820
  }
821
+ /**
822
+ * Delete a configuration instance. Requires 'edit' mastership.
823
+ * Live-verified 2026-07-09 on OmniCore VC RW7.21:
824
+ * ✓ DELETE /rw/cfg/{domain}/{type}/instances/{instance} → 204 (readback → 404)
825
+ */
714
826
  async removeCfgInstance(domain, type, instance) {
715
- await this.req('DELETE', `/rw/cfg/${domain}/${type}/${encodeURIComponent(instance)}`);
827
+ await this.req('DELETE', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`);
716
828
  }
717
829
  async loadCfgFile(filepath, action = 'replace') {
718
830
  await this.req('POST', '/rw/cfg', { 'action-type': action, filepath });
@@ -724,7 +836,7 @@ export class RwsClient2 {
724
836
  async listBackups() {
725
837
  // Backups live under /fileservice/BACKUP — list that volume
726
838
  try {
727
- const p = new XhtmlParser(await this.req('GET', '/fileservice/BACKUP'));
839
+ const p = RwsClient2.parse(await this.req('GET', '/fileservice/BACKUP'));
728
840
  return p.getAllStates('fs-dir').map(d => ({
729
841
  name: d['_title'] ?? '',
730
842
  created: d['fs-cdate'],
@@ -741,7 +853,7 @@ export class RwsClient2 {
741
853
  await this.req('POST', '/ctrl/backup/restore', { 'backup': `BACKUP/${name}` });
742
854
  }
743
855
  async getBackupStatus() {
744
- const p = new XhtmlParser(await this.req('GET', '/ctrl/backup'));
856
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/backup'));
745
857
  const d = p.getState('ctrl-backup-info-li') || p.getState('ctrl-backup-info');
746
858
  const phase = d['progress-state'] ?? d['phase'] ?? '';
747
859
  return {
@@ -754,17 +866,17 @@ export class RwsClient2 {
754
866
  // RWS exposes these via the mechunit's tool-name / wobj-name attributes;
755
867
  // setting requires updating the active task's tooldata/wobjdata RAPID symbols.
756
868
  async getActiveTool(mechunit = 'ROB_1') {
757
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
869
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
758
870
  const d = p.getState('ms-mechunit');
759
871
  return { name: d['tool-name'] ?? 'tool0' };
760
872
  }
761
873
  async getActiveWobj(mechunit = 'ROB_1') {
762
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
874
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
763
875
  const d = p.getState('ms-mechunit');
764
876
  return { name: d['wobj-name'] ?? 'wobj0' };
765
877
  }
766
878
  async getActivePayload(mechunit = 'ROB_1') {
767
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
879
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
768
880
  const d = p.getState('ms-mechunit');
769
881
  return { name: d['total-payload-name'] ?? d['payload-name'] ?? 'load0' };
770
882
  }
@@ -780,7 +892,7 @@ export class RwsClient2 {
780
892
  }
781
893
  // ─── DIPC `/rw/dipc` ───────────────────────────────────────────────────────
782
894
  async listDipcQueues() {
783
- const p = new XhtmlParser(await this.req('GET', '/rw/dipc'));
895
+ const p = RwsClient2.parse(await this.req('GET', '/rw/dipc'));
784
896
  return p.getAllStates('dipc-queue-li').map(q => ({
785
897
  name: q['queue-name'] ?? q['_title'] ?? '',
786
898
  size: q['queue-size'] ? +q['queue-size'] : undefined,
@@ -806,7 +918,7 @@ export class RwsClient2 {
806
918
  }
807
919
  async readDipcMessage(queue, timeoutMs = 0) {
808
920
  try {
809
- const p = new XhtmlParser(await this.req('POST', `/rw/dipc/${encodeURIComponent(queue)}/read`, {
921
+ const p = RwsClient2.parse(await this.req('POST', `/rw/dipc/${encodeURIComponent(queue)}/read`, {
810
922
  'dipc-timeout': String(timeoutMs),
811
923
  }));
812
924
  const d = p.getState('dipc-message');
@@ -850,7 +962,7 @@ export class RwsClient2 {
850
962
  */
851
963
  async requestMastershipWithId(domain) {
852
964
  const xhtml = await this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/request-with-id`);
853
- const id = new XhtmlParser(xhtml).get('mastership-id');
965
+ const id = RwsClient2.parse(xhtml).get('mastership-id');
854
966
  if (!id) {
855
967
  throw new Error('RWS2 request-with-id: no mastership-id in response');
856
968
  }
@@ -877,13 +989,13 @@ export class RwsClient2 {
877
989
  }
878
990
  /** Read mastership status for one domain — returns 'nomaster' | 'remote' | 'local' | similar. */
879
991
  async getMastershipStatus(domain) {
880
- const p = new XhtmlParser(await this.req('GET', `/rw/mastership/${this.rws2Domain(domain)}`));
992
+ const p = RwsClient2.parse(await this.req('GET', `/rw/mastership/${this.rws2Domain(domain)}`));
881
993
  const d = p.getState('msh-resource');
882
994
  return { mastership: d['mastership'] ?? 'unknown', uid: d['uid'], application: d['application'] };
883
995
  }
884
996
  /** List all mastership domains the controller exposes (typically `['edit', 'motion']`). */
885
997
  async listMastershipDomains() {
886
- const p = new XhtmlParser(await this.req('GET', '/rw/mastership'));
998
+ const p = RwsClient2.parse(await this.req('GET', '/rw/mastership'));
887
999
  return p.getAllStates('msh-resource-li').map(d => d['_title']).filter(Boolean);
888
1000
  }
889
1001
  // ─── Devices `/rw/devices` ──────────────────────────────────────────────────
@@ -893,15 +1005,17 @@ export class RwsClient2 {
893
1005
  * Drill into each group with `getDeviceTree(group)`.
894
1006
  */
895
1007
  async listSystemDevices() {
896
- const p = new XhtmlParser(await this.req('GET', '/rw/devices'));
1008
+ const p = RwsClient2.parse(await this.req('GET', '/rw/devices'));
897
1009
  return p.getAllStates('dev-id-li').map(d => ({
898
1010
  id: d['_title'] ?? '',
899
1011
  name: d['name'] ?? '',
900
1012
  }));
901
1013
  }
902
- /** Drill into a device group (e.g. 'HW_DEVICES'). Returns sub-tree as raw XHTML map. */
1014
+ /** Drill into a device group (e.g. 'HW_DEVICES'). Returns sub-tree as raw XHTML map.
1015
+ * Accept is pinned to XHTML so the promised raw format never changes under
1016
+ * the HAL JSON negotiation. */
903
1017
  async getDeviceTree(group) {
904
- return this.req('GET', `/rw/devices/${encodeURIComponent(group)}`);
1018
+ return this.req('GET', `/rw/devices/${encodeURIComponent(group)}`, undefined, undefined, undefined, [], RwsClient2.ACCEPT_XHTML);
905
1019
  }
906
1020
  /**
907
1021
  * List ALL configured I/O devices across every network in one call.
@@ -909,7 +1023,7 @@ export class RwsClient2 {
909
1023
  * one's handy when you want a flat overview without enumerating networks first.)
910
1024
  */
911
1025
  async listAllIoDevices() {
912
- const p = new XhtmlParser(await this.req('GET', '/rw/iosystem/devices'));
1026
+ const p = RwsClient2.parse(await this.req('GET', '/rw/iosystem/devices'));
913
1027
  return p.getAllStates('ios-device-li').map(d => {
914
1028
  const title = d['_title'] ?? '';
915
1029
  const network = title.split('/')[0] ?? '';
@@ -939,7 +1053,7 @@ export class RwsClient2 {
939
1053
  tool, wobj,
940
1054
  }).toString();
941
1055
  const xhtml = await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}?action=CalcRobTFromJoints`, undefined, body);
942
- const p = new XhtmlParser(xhtml);
1056
+ const p = RwsClient2.parse(xhtml);
943
1057
  if (p.getError()) {
944
1058
  throw new Error(`FK rejected: ${p.getError()?.msg ?? 'unknown'} (likely missing PC Interface 616-1 license)`);
945
1059
  }
@@ -964,7 +1078,7 @@ export class RwsClient2 {
964
1078
  // ─── Vision `/rw/vision` ────────────────────────────────────────────────────
965
1079
  async listVisionSystems() {
966
1080
  try {
967
- const p = new XhtmlParser(await this.req('GET', '/rw/vision'));
1081
+ const p = RwsClient2.parse(await this.req('GET', '/rw/vision'));
968
1082
  return p.getAllStates('vision-system-li').map(s => ({
969
1083
  name: s['_title'] ?? s['name'] ?? '',
970
1084
  status: s['status'],
@@ -975,11 +1089,11 @@ export class RwsClient2 {
975
1089
  }
976
1090
  }
977
1091
  async getVisionSystemInfo(name) {
978
- const p = new XhtmlParser(await this.req('GET', `/rw/vision/${encodeURIComponent(name)}`));
1092
+ const p = RwsClient2.parse(await this.req('GET', `/rw/vision/${encodeURIComponent(name)}`));
979
1093
  return p.getState('vision-system');
980
1094
  }
981
1095
  async listVisionJobs(system) {
982
- const p = new XhtmlParser(await this.req('GET', `/rw/vision/${encodeURIComponent(system)}/jobs`));
1096
+ const p = RwsClient2.parse(await this.req('GET', `/rw/vision/${encodeURIComponent(system)}/jobs`));
983
1097
  return p.getAllStates('vision-job-li').map(j => ({
984
1098
  name: j['name'] ?? j['_title'] ?? '',
985
1099
  active: j['active'] === 'true',
@@ -991,7 +1105,7 @@ export class RwsClient2 {
991
1105
  // ─── Safety controller `/ctrl/safety` ──────────────────────────────────────
992
1106
  async getSafetyStatus() {
993
1107
  try {
994
- const p = new XhtmlParser(await this.req('GET', '/ctrl/safety'));
1108
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/safety'));
995
1109
  const d = p.getState('ctrl-safety') || p.getState('ctrl-safety-info');
996
1110
  return { state: d['state'] ?? 'unknown', details: d };
997
1111
  }
@@ -1001,7 +1115,7 @@ export class RwsClient2 {
1001
1115
  }
1002
1116
  async listSafetyZones() {
1003
1117
  try {
1004
- const p = new XhtmlParser(await this.req('GET', '/ctrl/safety/zones'));
1118
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/safety/zones'));
1005
1119
  return p.getAllStates('ctrl-safety-zone-li');
1006
1120
  }
1007
1121
  catch {
@@ -1021,7 +1135,7 @@ export class RwsClient2 {
1021
1135
  // /vtspeed → class="ctrl-vtspeed" → span "vtcurrspeed" (1.0=real, 10=10x)
1022
1136
  const fetch = async (sub) => {
1023
1137
  try {
1024
- const p = new XhtmlParser(await this.req('GET', `/ctrl/virtualtime/${sub}`));
1138
+ const p = RwsClient2.parse(await this.req('GET', `/ctrl/virtualtime/${sub}`));
1025
1139
  return p.getState(`ctrl-${sub}`) || {};
1026
1140
  }
1027
1141
  catch {
@@ -1048,7 +1162,7 @@ export class RwsClient2 {
1048
1162
  // ─── Certificate store `/ctrl/certstore` ──────────────────────────────────
1049
1163
  async listCertificates() {
1050
1164
  try {
1051
- const p = new XhtmlParser(await this.req('GET', '/ctrl/certstore'));
1165
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/certstore'));
1052
1166
  return p.getAllStates('ctrl-cert-li').map(c => ({
1053
1167
  name: c['name'] ?? c['_title'] ?? '',
1054
1168
  subject: c['subject'],
@@ -1068,7 +1182,7 @@ export class RwsClient2 {
1068
1182
  // ─── Registry `/ctrl/registry` ────────────────────────────────────────────
1069
1183
  async getRegistry() {
1070
1184
  try {
1071
- const p = new XhtmlParser(await this.req('GET', '/ctrl/registry'));
1185
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/registry'));
1072
1186
  return p.getState('ctrl-registry');
1073
1187
  }
1074
1188
  catch {
@@ -1082,7 +1196,7 @@ export class RwsClient2 {
1082
1196
  // ─── File service — list volumes ──────────────────────────────────────────
1083
1197
  async listFileVolumes() {
1084
1198
  try {
1085
- const p = new XhtmlParser(await this.req('GET', '/fileservice'));
1199
+ const p = RwsClient2.parse(await this.req('GET', '/fileservice'));
1086
1200
  return p.getAllStates('fs-volume').map(v => v['_title'] ?? v['name']).filter(Boolean);
1087
1201
  }
1088
1202
  catch {
@@ -1121,7 +1235,7 @@ export class RwsClient2 {
1121
1235
  async listBreakpoints(task) {
1122
1236
  try {
1123
1237
  // Live-verified: /rw/rapid/tasks/{task}/program/breakpoints (not /breakpoint at task root)
1124
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/program/breakpoints`));
1238
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/program/breakpoints`));
1125
1239
  return p.getAllStates('rap-breakpoint-li').map(b => ({
1126
1240
  module: b['modulename'] ?? b['modulemame'] ?? b['module'] ?? '',
1127
1241
  row: +(b['begin-position-row'] ?? '0'),
@@ -1149,7 +1263,7 @@ export class RwsClient2 {
1149
1263
  // ─── Mechunit detailed endpoints ────────────────────────────────────────────
1150
1264
  async getMechunitBaseFrame(mechunit = 'ROB_1') {
1151
1265
  // Live-verified class: ms-mechunit-baseframe (not ms-baseframe)
1152
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/baseframe`));
1266
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/baseframe`));
1153
1267
  const d = p.getState('ms-mechunit-baseframe') || p.getState('ms-baseframe');
1154
1268
  return {
1155
1269
  x: +d['x'], y: +d['y'], z: +d['z'],
@@ -1165,7 +1279,7 @@ export class RwsClient2 {
1165
1279
  async getMechunitAxes(mechunit = 'ROB_1') {
1166
1280
  // Live-verified: /axes returns a count + sub-resource links (axes/1..N).
1167
1281
  // Fetch each axis individually and assemble the result.
1168
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes`));
1282
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes`));
1169
1283
  const total = p.getState('ms-mechunit-axes');
1170
1284
  const axisCount = +(total['axes'] ?? 0);
1171
1285
  if (axisCount === 0) {
@@ -1174,7 +1288,7 @@ export class RwsClient2 {
1174
1288
  const axes = [];
1175
1289
  for (let i = 1; i <= axisCount; i++) {
1176
1290
  try {
1177
- const ap = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes/${i}`));
1291
+ const ap = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes/${i}`));
1178
1292
  const ad = ap.getState('ms-mechunit-axis') || ap.getState('ms-axis');
1179
1293
  axes.push({ axis: String(i), ...ad });
1180
1294
  }
@@ -1185,7 +1299,7 @@ export class RwsClient2 {
1185
1299
  return axes;
1186
1300
  }
1187
1301
  async getMechunitPjoints(mechunit = 'ROB_1') {
1188
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/pjoints`));
1302
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/pjoints`));
1189
1303
  const d = p.getState('ms-pjoints');
1190
1304
  const out = {};
1191
1305
  for (const [k, v] of Object.entries(d)) {
@@ -1196,18 +1310,58 @@ export class RwsClient2 {
1196
1310
  return out;
1197
1311
  }
1198
1312
  async getMechunitInfo(mechunit = 'ROB_1') {
1199
- const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
1313
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
1200
1314
  return p.getState('ms-mechunit');
1201
1315
  }
1202
1316
  // ─── Module detailed endpoints ──────────────────────────────────────────────
1203
1317
  async getModuleSource(task, moduleName) {
1204
- // Modules can live in $HOME, DATA, etc.first fetch metadata to find path
1205
- const info = await this.getModuleInfo(task, moduleName);
1206
- const filepath = info['path'] ?? info['file-path'] ?? `$HOME/${moduleName}.mod`;
1207
- return this.readFile(filepath);
1318
+ // Program memory is the source of truththe save round-trip reads it
1319
+ // directly, so it is the PRIMARY path. A direct file read can return a
1320
+ // stale on-disk copy (module edited in memory, or a leftover HOME file
1321
+ // shadowing a module that was actually loaded from .pgf / RobotStudio),
1322
+ // and module metadata exposes no reliable backing path to trust: the
1323
+ // per-module GET only carries a bare `filename` span (live-verified
1324
+ // 2026-07-09 on OmniCore VC RW7.21 — no path/file-path field exists).
1325
+ try {
1326
+ return await this.readModuleViaSave(task, moduleName);
1327
+ }
1328
+ catch {
1329
+ // Save endpoint failed (permissions, disk, transient) — fall back to the
1330
+ // backing file named by metadata, or the conventional HOME location.
1331
+ const info = await this.getModuleInfo(task, moduleName).catch(() => ({}));
1332
+ const filepath = info['path'] ?? info['file-path']
1333
+ ?? (info['filename'] ? `HOME/${info['filename']}` : `$HOME/${moduleName}.mod`);
1334
+ return this.readFile(filepath);
1335
+ }
1336
+ }
1337
+ /**
1338
+ * Read a module's source by round-tripping it through the TEMP volume.
1339
+ * Live-verified 2026-07-08 on OmniCore VC RW7.21:
1340
+ * POST /rw/rapid/tasks/{task}/modules/{module}/save body name=<tmp>&path=TEMP:
1341
+ * → 204, no mastership required. The controller ALWAYS appends '.modx' to
1342
+ * the given name (even for SysMod modules — never '.sysx'), so the name is
1343
+ * passed without extension. TEMP: avoids any risk of clobbering HOME files.
1344
+ */
1345
+ async readModuleViaSave(task, moduleName) {
1346
+ const tmp = `${moduleName}_${Date.now().toString(36)}${Math.floor(Math.random() * 0xffff).toString(36)}`;
1347
+ await this.req('POST', `/rw/rapid/tasks/${task}/modules/${encodeURIComponent(moduleName)}/save`, undefined, `name=${tmp}&path=TEMP:`);
1348
+ try {
1349
+ return await this.readFile(`TEMP/${tmp}.modx`);
1350
+ }
1351
+ finally {
1352
+ await this.deleteFile(`TEMP/${tmp}.modx`).catch(() => { });
1353
+ }
1208
1354
  }
1209
1355
  async getModuleInfo(task, moduleName) {
1210
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/modules/${encodeURIComponent(moduleName)}`));
1356
+ // Live-verified 2026-07-09 on OmniCore VC RW7.21: the per-module GET returns
1357
+ // <li class="rap-module" title="{task}/{module}"> with spans modname,
1358
+ // filename (bare name like 'BASE.sysx' — NO path) and attribute.
1359
+ // (rap-module-info-li is the class used by the module LIST endpoint.)
1360
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/modules/${encodeURIComponent(moduleName)}`));
1361
+ const d = p.getState('rap-module');
1362
+ if (Object.keys(d).length > 0) {
1363
+ return d;
1364
+ }
1211
1365
  return p.getState('rap-module-info-li') || p.getState('rap-module-info');
1212
1366
  }
1213
1367
  async listModuleSymbols(task, moduleName) {
@@ -1216,15 +1370,15 @@ export class RwsClient2 {
1216
1370
  }
1217
1371
  // ─── Per-task additional endpoints ──────────────────────────────────────────
1218
1372
  async getTaskStructuralChangeCount(task) {
1219
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/structural-changecount`));
1373
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/structural-changecount`));
1220
1374
  return Number(p.get('change-count') ?? p.get('structural-changecount') ?? 0);
1221
1375
  }
1222
1376
  async getTaskMotion(task) {
1223
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/motion`));
1377
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/motion`));
1224
1378
  return p.getState('rap-task-motion') || {};
1225
1379
  }
1226
1380
  async getTaskActivationRecord(task) {
1227
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/activation-record`));
1381
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/activation-record`));
1228
1382
  return p.getState('rap-activation-record') || {};
1229
1383
  }
1230
1384
  async getTaskProgramInfo(task) {
@@ -1233,7 +1387,7 @@ export class RwsClient2 {
1233
1387
  if (!xml) {
1234
1388
  return {};
1235
1389
  }
1236
- return new XhtmlParser(xml).getState('rap-program-info') || {};
1390
+ return RwsClient2.parse(xml).getState('rap-program-info') || {};
1237
1391
  }
1238
1392
  // ─── WebSocket subscriptions ──────────────────────────────────────────────────
1239
1393
  /**
@@ -1286,22 +1440,26 @@ export class RwsClient2 {
1286
1440
  }
1287
1441
  return path; // fallback: keep full path
1288
1442
  }
1289
- async subscribe(resources, handler) {
1290
- // 1. Build subscription body
1291
- const paths = resources.map(r => RwsClient2.rws2ResourcePath(r)).filter(Boolean);
1292
- if (paths.length === 0) {
1293
- return async () => { };
1294
- }
1295
- const parts = [`resources=${paths.length}`];
1296
- paths.forEach((p, i) => {
1297
- // Format: <idx>=<path;stateParam>&<idx>-p=<priority>
1298
- // Semicolons must be LITERAL do NOT encodeURIComponent
1299
- parts.push(`${i + 1}=${p}&${i + 1}-p=1`);
1300
- });
1301
- const bodyStr = parts.join('&');
1302
- // 2. POST /subscription accept HTTP 201 (Created)
1303
- // Capture the Location header (authoritative WS URL) and Set-Cookie (required for WS auth)
1304
- const { wsUrl, deleteUrl, cookieStr } = await new Promise((resolve, reject) => {
1443
+ /** First reconnect delay after a dropped subscription WebSocket (doubles per attempt). */
1444
+ static WS_RECONNECT_BASE_MS = 500;
1445
+ /** Give up re-subscribing after this many consecutive failed attempts. */
1446
+ static WS_RECONNECT_MAX_ATTEMPTS = 6;
1447
+ /** How long to wait for the WebSocket upgrade to complete before treating the attempt as failed. */
1448
+ static WS_OPEN_TIMEOUT_MS = 8000;
1449
+ /**
1450
+ * POST /subscription — accept HTTP 201 (Created).
1451
+ * Captures the Location header (authoritative WS URL) and the group resource
1452
+ * path (`/subscription/{id}` — the URL a DELETE must target to free the group).
1453
+ *
1454
+ * Rides the client's main HTTP session: live-verified 2026-07-09 on OmniCore
1455
+ * VC RW7.21 (probe-sub-session.mjs) — POST /subscription with the existing
1456
+ * session Cookie returns 201 with NO Set-Cookie (no new session minted) and
1457
+ * the WebSocket authenticates with that same cookie. Without the Cookie the
1458
+ * controller mints one session per subscribe, and reconnect loops would burn
1459
+ * through the 5-sessions-per-IP budget.
1460
+ */
1461
+ createSubscription(bodyStr) {
1462
+ return new Promise((resolve, reject) => {
1305
1463
  const url = new URL('/subscription', this.baseUrl);
1306
1464
  const encoded = Buffer.from(bodyStr);
1307
1465
  const options = {
@@ -1314,8 +1472,12 @@ export class RwsClient2 {
1314
1472
  Accept: 'application/xhtml+xml;v=2.0',
1315
1473
  'Content-Type': 'application/x-www-form-urlencoded;v=2.0',
1316
1474
  'Content-Length': String(encoded.length),
1475
+ ...(this.sessionCookie ? { Cookie: this.sessionCookie } : {}),
1317
1476
  },
1318
- ...(this.isHttps ? { agent: this.httpsAgent } : {}),
1477
+ // Per-request as well as on the agent see req() for why (issue #2).
1478
+ ...(this.isHttps
1479
+ ? { agent: this.httpsAgent, ...(this.rejectUnauthorized ? {} : { rejectUnauthorized: false }) }
1480
+ : {}),
1319
1481
  };
1320
1482
  const transport = this.isHttps ? https : http;
1321
1483
  const req = transport.request(options, res => {
@@ -1326,24 +1488,34 @@ export class RwsClient2 {
1326
1488
  reject(new Error(`RWS2 subscribe POST returned ${res.statusCode}`));
1327
1489
  return;
1328
1490
  }
1491
+ const body = Buffer.concat(chunks).toString('utf8');
1329
1492
  // Location header contains the WebSocket URL (wss://host/poll/{id})
1330
1493
  const location = (res.headers['location'] ?? '');
1331
1494
  let wsUrl;
1332
- let deleteUrl;
1333
1495
  if (location.startsWith('wss://') || location.startsWith('ws://')) {
1334
1496
  wsUrl = location;
1335
- deleteUrl = location.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
1336
1497
  }
1337
1498
  else {
1338
1499
  // Fallback: parse from XHTML body
1339
- const body = Buffer.concat(chunks).toString('utf8');
1340
- const wsMatch = body.match(/href="(wss?:\/\/[^"]+)"/);
1341
- wsUrl = wsMatch?.[1] ?? '';
1342
- deleteUrl = wsUrl.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
1500
+ wsUrl = body.match(/href="(wss?:\/\/[^"]+)"/)?.[1] ?? '';
1343
1501
  }
1344
- // Capture session cookies WebSocket needs Cookie header, NOT Authorization
1502
+ // Group resource for cleanup. Live-verified 2026-07-09 on OmniCore VC
1503
+ // RW7.21: DELETE /subscription/{id} → 200 and the group disappears;
1504
+ // DELETE on the /poll/{id} URL → 404 (it is NOT a deletable resource).
1505
+ // The 201 body carries <a href="subscription/{id}" rel="group"/>.
1506
+ const groupId = body.match(/href="[^"]*subscription\/([^"/]+)"[^>]*rel="group"/)?.[1]
1507
+ ?? body.match(/rel="group"[^>]*href="[^"]*subscription\/([^"/]+)"/)?.[1]
1508
+ ?? wsUrl.match(/\/poll\/([^/?#]+)/)?.[1]
1509
+ ?? '';
1510
+ const deleteUrl = groupId ? `/subscription/${groupId}` : '';
1511
+ // Capture the session cookie if this POST minted one (first-ever request
1512
+ // on this client) — same capture rule as req(). The WebSocket authenticates
1513
+ // with Cookie, NOT Authorization.
1345
1514
  const setCookies = (res.headers['set-cookie'] ?? []);
1346
- const cookieStr = setCookies.map((c) => c.split(';')[0]).join('; ');
1515
+ if (setCookies.length > 0 && !this.sessionCookie) {
1516
+ this.sessionCookie = setCookies.map((c) => c.split(';')[0]).join('; ');
1517
+ }
1518
+ const cookieStr = this.sessionCookie ?? '';
1347
1519
  if (!wsUrl) {
1348
1520
  reject(new Error('RWS2 subscribe: no WebSocket URL'));
1349
1521
  return;
@@ -1355,85 +1527,215 @@ export class RwsClient2 {
1355
1527
  req.write(encoded);
1356
1528
  req.end();
1357
1529
  });
1358
- // 3. Open WebSocket and wait for confirmation it actually connected.
1359
- // Auth: Cookie from subscription response (NOT Authorization header).
1360
- // Subprotocol: "robapi2_subscription" (ABB standard; may not be supported on all VCs).
1361
- // We dynamically import 'ws' so callers who never subscribe don't pay for it.
1362
- // (ESM-safe; the package is `"type": "module"`, so `require` is undefined.)
1530
+ }
1531
+ async subscribe(resources, handler, onLost) {
1532
+ // 1. Build subscription body
1533
+ const paths = resources.map(r => RwsClient2.rws2ResourcePath(r)).filter(Boolean);
1534
+ if (paths.length === 0) {
1535
+ return async () => { };
1536
+ }
1537
+ const parts = [`resources=${paths.length}`];
1538
+ paths.forEach((p, i) => {
1539
+ // Format: <idx>=<path;stateParam>&<idx>-p=<priority>
1540
+ // Semicolons must be LITERAL — do NOT encodeURIComponent
1541
+ parts.push(`${i + 1}=${p}&${i + 1}-p=1`);
1542
+ });
1543
+ const bodyStr = parts.join('&');
1544
+ // We dynamically import 'ws' so callers who never subscribe don't pay for it.
1545
+ // (ESM-safe; the package is `"type": "module"`, so `require` is undefined.)
1363
1546
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1364
1547
  const wsMod = await import('ws');
1365
1548
  const WsImpl = wsMod.default;
1366
- const ws = new WsImpl(wsUrl, ['robapi2_subscription'], {
1367
- rejectUnauthorized: false,
1368
- headers: { Cookie: cookieStr },
1369
- });
1370
- // Wait up to 8 s for the WebSocket to open. If the controller rejects the upgrade
1371
- // (e.g. RWS 2.0 virtual controller), we clean up and throw so the caller falls back to polling.
1372
- await new Promise((resolve, reject) => {
1373
- const timer = setTimeout(() => {
1374
- ws.terminate();
1375
- reject(new Error('WebSocket connection timed out after 8 s'));
1376
- }, 8000);
1377
- ws.on('open', () => {
1378
- clearTimeout(timer);
1379
- resolve();
1380
- });
1381
- // unexpected-response fires when the HTTP upgrade is rejected (e.g. 400)
1549
+ // Connection state shared between the reconnect logic and unsubscribe.
1550
+ const conn = {
1382
1551
  // eslint-disable-next-line @typescript-eslint/no-explicit-any
1383
- ws.on('unexpected-response', (_req, res) => {
1384
- clearTimeout(timer);
1385
- const chunks = [];
1386
- res.on('data', (c) => chunks.push(c));
1387
- res.on('end', () => {
1388
- const body = (Buffer.concat(chunks).toString().trim() || '').slice(0, 120);
1552
+ ws: null,
1553
+ deleteUrl: '',
1554
+ pingTimer: null,
1555
+ reconnectTimer: null,
1556
+ closed: false,
1557
+ attempts: 0,
1558
+ lostNotified: false,
1559
+ };
1560
+ // Best-effort removal of a subscription group (DELETE /subscription/{id}).
1561
+ // Groups live as long as the session that owns them, and the session is the
1562
+ // client's main one — orphaned groups would pile up on every reconnect.
1563
+ const dropGroup = (path) => path ? this.req('DELETE', path).then(() => { }, () => { }) : Promise.resolve();
1564
+ // The subscription rides the main HTTP session (see createSubscription), so a
1565
+ // dropped WebSocket does NOT invalidate the group — but its poll URL is spent.
1566
+ // Every (re)connect drops the previous group, then POSTs a fresh /subscription
1567
+ // on the same session; no extra sessions are ever minted.
1568
+ const open = async () => {
1569
+ if (conn.deleteUrl) {
1570
+ await dropGroup(conn.deleteUrl);
1571
+ conn.deleteUrl = '';
1572
+ }
1573
+ const { wsUrl, deleteUrl, cookieStr } = await this.createSubscription(bodyStr);
1574
+ conn.deleteUrl = deleteUrl;
1575
+ // 2. Open WebSocket and wait for confirmation it actually connected.
1576
+ // Auth: Cookie from subscription response (NOT Authorization header).
1577
+ // Subprotocol: "rws_subscription" — the RWS 2.0 name. Live-verified 2026-07-08
1578
+ // on OmniCore VC RW7.21: "robapi2_subscription" (the RWS 1.0 name) is rejected
1579
+ // with HTTP 400; "rws_subscription" upgrades with 101.
1580
+ const ws = new WsImpl(wsUrl, ['rws_subscription'], {
1581
+ ...(this.rejectUnauthorized ? {} : { rejectUnauthorized: false }),
1582
+ headers: { Cookie: cookieStr },
1583
+ });
1584
+ // Wait for the WebSocket to open. If the controller rejects the upgrade,
1585
+ // we clean up and throw so the caller falls back to polling.
1586
+ await new Promise((resolve, reject) => {
1587
+ let settled = false;
1588
+ const timer = setTimeout(() => {
1589
+ if (settled) {
1590
+ return;
1591
+ }
1592
+ settled = true;
1389
1593
  ws.terminate();
1390
- this.reqDelete(deleteUrl).catch(() => { });
1391
- reject(new Error(`RWS2 WebSocket upgrade rejected (HTTP ${res.statusCode}): ${body}`));
1594
+ dropGroup(deleteUrl);
1595
+ reject(new Error(`WebSocket connection timed out after ${RwsClient2.WS_OPEN_TIMEOUT_MS} ms`));
1596
+ }, RwsClient2.WS_OPEN_TIMEOUT_MS);
1597
+ ws.on('open', () => {
1598
+ if (settled) {
1599
+ return;
1600
+ }
1601
+ settled = true;
1602
+ clearTimeout(timer);
1603
+ resolve();
1604
+ });
1605
+ // unexpected-response fires when the HTTP upgrade is rejected (e.g. 400)
1606
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1607
+ ws.on('unexpected-response', (_req, res) => {
1608
+ clearTimeout(timer);
1609
+ const chunks = [];
1610
+ res.on('data', (c) => chunks.push(c));
1611
+ res.on('end', () => {
1612
+ if (settled) {
1613
+ return;
1614
+ }
1615
+ settled = true;
1616
+ const body = (Buffer.concat(chunks).toString().trim() || '').slice(0, 120);
1617
+ ws.terminate();
1618
+ dropGroup(deleteUrl);
1619
+ reject(new Error(`RWS2 WebSocket upgrade rejected (HTTP ${res.statusCode}): ${body}`));
1620
+ });
1621
+ });
1622
+ ws.on('error', (err) => {
1623
+ if (settled) {
1624
+ return;
1625
+ }
1626
+ settled = true;
1627
+ clearTimeout(timer);
1628
+ dropGroup(deleteUrl);
1629
+ reject(err);
1392
1630
  });
1393
1631
  });
1632
+ // Unsubscribed while the handshake was in flight — discard the connection.
1633
+ if (conn.closed) {
1634
+ ws.close();
1635
+ dropGroup(deleteUrl);
1636
+ return;
1637
+ }
1638
+ conn.ws = ws;
1639
+ // 3. Ping every 25 s (controller closes if no activity within 30 s)
1640
+ conn.pingTimer = setInterval(() => {
1641
+ if (ws.readyState === 1 /* OPEN */) {
1642
+ ws.send('PING');
1643
+ }
1644
+ }, 25000);
1645
+ // 4. Parse incoming events (same approach as abb-rws-client WsSubscriber)
1646
+ ws.on('message', (data) => {
1647
+ const raw = data.toString();
1648
+ if (raw === 'PONG') {
1649
+ return;
1650
+ }
1651
+ const liPat = /<li[^>]*>([\s\S]*?)<\/li>/gi;
1652
+ let m;
1653
+ while ((m = liPat.exec(raw)) !== null) {
1654
+ const block = m[1];
1655
+ const hrefM = block.match(/<a[^>]*href="([^"]+)"/i);
1656
+ const spanM = block.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
1657
+ if (!hrefM || !spanM) {
1658
+ continue;
1659
+ }
1660
+ handler({
1661
+ resource: RwsClient2.resourcePathToName(hrefM[1]),
1662
+ value: spanM[1].trim(),
1663
+ timestamp: new Date(),
1664
+ });
1665
+ }
1666
+ });
1667
+ // Non-fatal error after open — the matching 'close' event drives cleanup/reconnect.
1394
1668
  ws.on('error', (err) => {
1395
- clearTimeout(timer);
1396
- this.reqDelete(deleteUrl).catch(() => { });
1397
- reject(err);
1669
+ console.warn('[RWS2] WebSocket error:', err.message);
1398
1670
  });
1399
- });
1400
- // 4. Ping every 25 s (controller closes if no activity within 30 s)
1401
- const pingTimer = setInterval(() => {
1402
- if (ws.readyState === 1 /* OPEN */) {
1403
- ws.send('PING');
1404
- }
1405
- }, 25000);
1406
- // 5. Parse incoming events (same approach as abb-rws-client WsSubscriber)
1407
- ws.on('message', (data) => {
1408
- const raw = data.toString();
1409
- if (raw === 'PONG') {
1671
+ ws.on('close', () => {
1672
+ if (conn.pingTimer) {
1673
+ clearInterval(conn.pingTimer);
1674
+ conn.pingTimer = null;
1675
+ }
1676
+ if (!conn.closed) {
1677
+ scheduleReconnect();
1678
+ }
1679
+ });
1680
+ };
1681
+ const scheduleReconnect = () => {
1682
+ // unsubscribe() clears the pending timer, but an open() already in
1683
+ // flight lands here through its .catch — without this guard it would
1684
+ // keep retrying (and eventually fire onLost) after the consumer left.
1685
+ if (conn.closed) {
1410
1686
  return;
1411
1687
  }
1412
- const liPat = /<li[^>]*>([\s\S]*?)<\/li>/gi;
1413
- let m;
1414
- while ((m = liPat.exec(raw)) !== null) {
1415
- const block = m[1];
1416
- const hrefM = block.match(/<a[^>]*href="([^"]+)"/i);
1417
- const spanM = block.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
1418
- if (!hrefM || !spanM) {
1419
- continue;
1688
+ if (conn.attempts >= RwsClient2.WS_RECONNECT_MAX_ATTEMPTS) {
1689
+ const msg = `RWS2 subscription lost — giving up after ${conn.attempts} reconnect attempts`;
1690
+ Logger.error(msg);
1691
+ console.error(`[RWS2] ${msg}`);
1692
+ void dropGroup(conn.deleteUrl);
1693
+ conn.deleteUrl = '';
1694
+ if (!conn.lostNotified) {
1695
+ conn.lostNotified = true;
1696
+ try {
1697
+ onLost?.();
1698
+ }
1699
+ catch { /* consumer callback — never let it break us */ }
1420
1700
  }
1421
- handler({
1422
- resource: RwsClient2.resourcePathToName(hrefM[1]),
1423
- value: spanM[1].trim(),
1424
- timestamp: new Date(),
1425
- });
1701
+ return;
1426
1702
  }
1427
- });
1428
- // Non-fatal error after open (connection dropped mid-session)
1429
- ws.on('error', (err) => {
1430
- console.warn('[RWS2] WebSocket error:', err.message);
1431
- });
1432
- // 6. Return unsubscribe — close WS and DELETE the subscription
1703
+ const delay = RwsClient2.WS_RECONNECT_BASE_MS * 2 ** conn.attempts;
1704
+ conn.attempts++;
1705
+ Logger.trace?.('subscription', `RWS2 WebSocket dropped — reconnect attempt ${conn.attempts} in ${delay} ms`);
1706
+ conn.reconnectTimer = setTimeout(() => {
1707
+ open()
1708
+ .then(() => {
1709
+ if (conn.closed) {
1710
+ // unsubscribe() won the race against this reconnect — tear the
1711
+ // fresh socket/group down instead of leaving a zombie stream.
1712
+ conn.ws?.close();
1713
+ const url = conn.deleteUrl;
1714
+ conn.deleteUrl = '';
1715
+ void dropGroup(url);
1716
+ return;
1717
+ }
1718
+ conn.attempts = 0;
1719
+ })
1720
+ .catch(e => {
1721
+ Logger.warn(`RWS2 subscription reconnect failed: ${e instanceof Error ? e.message : String(e)}`);
1722
+ scheduleReconnect();
1723
+ });
1724
+ }, delay);
1725
+ };
1726
+ await open();
1727
+ // 5. Return unsubscribe — close WS and DELETE the subscription group
1433
1728
  return async () => {
1434
- clearInterval(pingTimer);
1435
- ws.close();
1436
- await this.reqDelete(deleteUrl).catch(() => { });
1729
+ conn.closed = true;
1730
+ if (conn.reconnectTimer) {
1731
+ clearTimeout(conn.reconnectTimer);
1732
+ }
1733
+ if (conn.pingTimer) {
1734
+ clearInterval(conn.pingTimer);
1735
+ conn.pingTimer = null;
1736
+ }
1737
+ conn.ws?.close();
1738
+ await dropGroup(conn.deleteUrl);
1437
1739
  };
1438
1740
  }
1439
1741
  // ─── Remote Mastership Privilege (RMMP) ────────────────────────────────────────
@@ -1449,7 +1751,7 @@ export class RwsClient2 {
1449
1751
  */
1450
1752
  async getRmmpPrivilege() {
1451
1753
  const xml = await this.req('GET', '/users/rmmp');
1452
- const p = new XhtmlParser(xml);
1754
+ const p = RwsClient2.parse(xml);
1453
1755
  const priv = p.get('privilege') ?? 'none';
1454
1756
  const heldByMe = (p.get('rmmpheldbyme') ?? 'false').toLowerCase() === 'true';
1455
1757
  if (priv === 'none') {
@@ -1480,13 +1782,95 @@ export class RwsClient2 {
1480
1782
  ].join('&');
1481
1783
  await this.req('POST', '/rw/motionsystem/jog', undefined, body, 'application/x-www-form-urlencoded;v=2.0');
1482
1784
  }
1785
+ // ─── Simulation panel (virtual controllers only) ─────────────────────────────
1786
+ // RobotWare 7 VCs expose the panel hardware (e-stop chain, enabling device) and
1787
+ // a joint-teleport endpoint for simulation. Real controllers do not serve these
1788
+ // paths (404) — the FlexPendant hardware is the source of truth there — so every
1789
+ // method below translates a 404 into a clear "virtual controllers only" error.
1790
+ // All wire shapes live-verified 2026-07-09 on an OmniCore VC RW7.21.
1791
+ /** Shared POST for the VC-only simulation endpoints. */
1792
+ async simPost(label, path, body, rawBody) {
1793
+ try {
1794
+ await this.req('POST', path, body, rawBody);
1795
+ }
1796
+ catch (e) {
1797
+ if (e instanceof RwsError && e.httpStatus === 404) {
1798
+ throw new RwsError(`${label}: ${path} returned 404 — simulation endpoints exist only on RobotWare 7 virtual controllers (not on real hardware or RW6)`, 'UNKNOWN', 404, e.rwsDetail);
1799
+ }
1800
+ throw e;
1801
+ }
1802
+ }
1803
+ /**
1804
+ * Engage the (internal) emergency stop — controller state goes to
1805
+ * `emergencystop`. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1806
+ * POST /rw/panel/emergency-stop body `state=off` → 204.
1807
+ * The polarity is INVERTED from the ABB Swagger example: state=off OPENS the
1808
+ * safety chain (engages the stop), state=on closes it again. Fully reversible
1809
+ * on a VC via {@link simResetEmergencyStop} — no physical reset step exists
1810
+ * there (unlike real hardware, which latches until the button is released).
1811
+ */
1812
+ simEmergencyStop() {
1813
+ return this.simPost('simEmergencyStop', '/rw/panel/emergency-stop', { state: 'off' });
1814
+ }
1815
+ /** Release the simulated emergency stop (`state=on`) — controller returns to
1816
+ * `motoroff`. See {@link simEmergencyStop} for the polarity note. */
1817
+ simResetEmergencyStop() {
1818
+ return this.simPost('simResetEmergencyStop', '/rw/panel/emergency-stop', { state: 'on' });
1819
+ }
1820
+ /**
1821
+ * Engage the general stop (controller state → `guardstop`); pass `false` to
1822
+ * release it again (→ `motoroff`). Live-verified 2026-07-09 on OmniCore VC
1823
+ * RW7.21: POST /rw/panel/general-stop, `state=off` engages / `state=on`
1824
+ * releases (same inverted polarity as the e-stop endpoints).
1825
+ */
1826
+ simGeneralStop(engage = true) {
1827
+ return this.simPost('simGeneralStop', '/rw/panel/general-stop', { state: engage ? 'off' : 'on' });
1828
+ }
1829
+ /**
1830
+ * Engage the automatic stop (controller state → `guardstop`); pass `false` to
1831
+ * release it. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1832
+ * POST /rw/panel/auto-stop, `state=off` engages / `state=on` releases.
1833
+ */
1834
+ simAutoStop(engage = true) {
1835
+ return this.simPost('simAutoStop', '/rw/panel/auto-stop', { state: engage ? 'off' : 'on' });
1836
+ }
1837
+ /**
1838
+ * Press (`true`) or release (`false`) the simulated three-position enabling
1839
+ * device. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1840
+ * POST /rw/panel/enable-switch body `state=on|off` → 204.
1841
+ * This endpoint's polarity is direct (no inversion). In AUTO the controller
1842
+ * accepts the call as a no-op; driving motors on requires manual mode.
1843
+ */
1844
+ simEnableSwitch(on) {
1845
+ return this.simPost('simEnableSwitch', '/rw/panel/enable-switch', { state: on ? 'on' : 'off' });
1846
+ }
1847
+ /**
1848
+ * Teleport a mechanical unit to absolute joint values (degrees) — the VC
1849
+ * equivalent of dragging the robot in RobotStudio; no motors, mastership, or
1850
+ * program stop needed. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1851
+ * POST /rw/motionsystem/mechunits/{mechunit}/position
1852
+ * body `rob_joint=[j1,j2,j3,j4,j5,j6]&ext_joint=[e1,e2,e3,e4,e5,e6]` → 204
1853
+ * BOTH keys are required by the controller (omitting either → 400
1854
+ * "No rob_joint parameter"), which is why `extJoints` defaults to six zeros.
1855
+ * The readback (`getJointPositions`) may show sub-µdeg float rounding.
1856
+ * Caveat (live-verified 2026-07-09): while an operation-mode change is
1857
+ * pending (opmode AUTO_CH — FlexPendant acknowledge outstanding) the endpoint
1858
+ * answers 403 "Operation not allowed for user in current operation mode".
1859
+ */
1860
+ async teleportMechunit(mechunit, joints, extJoints) {
1861
+ if (joints.length !== 6 || (extJoints !== undefined && extJoints.length !== 6)) {
1862
+ throw new RwsError('teleportMechunit: exactly 6 robot joint values (and 6 external-axis values, if given) are required', 'UNKNOWN');
1863
+ }
1864
+ const ext = extJoints ?? [0, 0, 0, 0, 0, 0];
1865
+ await this.simPost('teleportMechunit', `/rw/motionsystem/mechunits/${mechunit}/position`, undefined, `rob_joint=[${joints.join(',')}]&ext_joint=[${ext.join(',')}]`);
1866
+ }
1483
1867
  // ─── System detail endpoints ────────────────────────────────────────────────
1484
1868
  async getLicenseInfo() {
1485
- const p = new XhtmlParser(await this.req('GET', '/rw/system/license'));
1869
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/license'));
1486
1870
  return { entries: p.getAllStates('sys-license') };
1487
1871
  }
1488
1872
  async listProducts() {
1489
- const p = new XhtmlParser(await this.req('GET', '/rw/system/products'));
1873
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/products'));
1490
1874
  // Live-verified class: sys-product-li (with -li suffix). Each product has a _title
1491
1875
  // (the product name e.g. "RobotControl") plus version and version-name spans.
1492
1876
  return p.getAllStates('sys-product-li').map(p => ({
@@ -1496,20 +1880,20 @@ export class RwsClient2 {
1496
1880
  }));
1497
1881
  }
1498
1882
  async getRobotType() {
1499
- const p = new XhtmlParser(await this.req('GET', '/rw/system/robottype'));
1883
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/robottype'));
1500
1884
  const d = p.getState('sys-robottype');
1501
1885
  // Live-verified: span class is 'robot-type' (with hyphen), not 'robottype'
1502
1886
  return { type: d['robot-type'] ?? d['robottype'] ?? d['type'] ?? '', variant: d['variant'] };
1503
1887
  }
1504
1888
  async getEnergyStats() {
1505
- const p = new XhtmlParser(await this.req('GET', '/rw/system/energy'));
1889
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/energy'));
1506
1890
  // Live-verified class: sys-energy-state (not sys-energy)
1507
1891
  return p.getState('sys-energy-state');
1508
1892
  }
1509
1893
  // ─── Return-code lookup ─────────────────────────────────────────────────────
1510
1894
  async getReturnCode(code, lang = 'en') {
1511
1895
  try {
1512
- const p = new XhtmlParser(await this.req('GET', `/rw/retcode?code=${code}&lang=${lang}`));
1896
+ const p = RwsClient2.parse(await this.req('GET', `/rw/retcode?code=${code}&lang=${lang}`));
1513
1897
  const d = p.getState('rw-retcode') || p.getState('rw-retcode-li');
1514
1898
  if (!d['title'] && !d['desc']) {
1515
1899
  return null;
@@ -1522,29 +1906,29 @@ export class RwsClient2 {
1522
1906
  }
1523
1907
  // ─── Controller detail endpoints ────────────────────────────────────────────
1524
1908
  async listControllerOptions() {
1525
- const p = new XhtmlParser(await this.req('GET', '/ctrl/options'));
1909
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/options'));
1526
1910
  return p.getAllStates('ctrl-option').map(o => ({
1527
1911
  name: o['option'] ?? o['name'] ?? '',
1528
1912
  description: o['description'],
1529
1913
  }));
1530
1914
  }
1531
1915
  async listFeatures() {
1532
- const p = new XhtmlParser(await this.req('GET', '/ctrl/features'));
1916
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/features'));
1533
1917
  return p.getAllStates('ctrl-feature');
1534
1918
  }
1535
1919
  // ─── Motion detail endpoints ────────────────────────────────────────────────
1536
1920
  async getMotionChangeCount() {
1537
- const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem?resource=change-count'));
1921
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem?resource=change-count'));
1538
1922
  return Number(p.get('change-count') ?? 0);
1539
1923
  }
1540
1924
  async getMotionErrorState() {
1541
- const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/errorstate'));
1925
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/errorstate'));
1542
1926
  const d = p.getState('ms-errorstate-li') || p.getState('ms-errorstate');
1543
1927
  return { state: d['err-state'] ?? d['state'] ?? 'unknown', details: d };
1544
1928
  }
1545
1929
  async getNonMotionExecution() {
1546
1930
  // Live-verified: class="ms-nonmotionexecution", span "mode" returns quoted "OFF" or "ON".
1547
- const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/nonmotionexecution'));
1931
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/nonmotionexecution'));
1548
1932
  const v = (p.get('mode') ?? p.get('state') ?? 'OFF').replace(/"/g, '').toUpperCase();
1549
1933
  return v === 'ON';
1550
1934
  }
@@ -1554,7 +1938,7 @@ export class RwsClient2 {
1554
1938
  async getCollisionPredictionMode() {
1555
1939
  // Live-verified: class="ms-collision-prediction-mode" with span "collision-prediction-mode-enabled"
1556
1940
  // returning "true" / "false". Map back to ON/OFF for caller convenience.
1557
- const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/collisionprediction'));
1941
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/collisionprediction'));
1558
1942
  const enabled = p.get('collision-prediction-mode-enabled') ?? p.get('mode') ?? 'false';
1559
1943
  return enabled.toLowerCase() === 'true' ? 'ON' : 'OFF';
1560
1944
  }
@@ -1563,20 +1947,20 @@ export class RwsClient2 {
1563
1947
  }
1564
1948
  // ─── Panel detail endpoints ─────────────────────────────────────────────────
1565
1949
  async getEnableRequest() {
1566
- const p = new XhtmlParser(await this.req('GET', '/rw/panel/enreq'));
1950
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/enreq'));
1567
1951
  const d = p.getState('pnl-enreq') || p.getState('pnl-enreq-li');
1568
1952
  return { state: d['state'] ?? d['enreq'] ?? 'unknown', raw: d };
1569
1953
  }
1570
1954
  // ─── RAPID detail endpoints ─────────────────────────────────────────────────
1571
1955
  async listAliasIO() {
1572
- const p = new XhtmlParser(await this.req('GET', '/rw/rapid/aliasio'));
1956
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/aliasio'));
1573
1957
  return p.getAllStates('rap-aliasio-li').map(a => ({
1574
1958
  alias: a['name'] ?? a['alias'] ?? '',
1575
1959
  signal: a['signal'] ?? a['_title'] ?? '',
1576
1960
  }));
1577
1961
  }
1578
1962
  async getTaskSelection() {
1579
- const p = new XhtmlParser(await this.req('GET', '/rw/rapid/taskselection'));
1963
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/taskselection'));
1580
1964
  const sel = p.getAllStates('rap-taskselection-li').map(t => t['name']).filter(Boolean);
1581
1965
  const all = p.getAllStates('rap-task-li').map(t => t['name']).filter(Boolean);
1582
1966
  return { selected: sel, available: all };
@@ -1592,7 +1976,7 @@ export class RwsClient2 {
1592
1976
  // beginposition → "row,col" combined string
1593
1977
  // endposition → "row,col"
1594
1978
  // changecount, executiontype
1595
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/pcp`));
1979
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/pcp`));
1596
1980
  const d = p.getState('pcp-info') || p.getState('program-pointer-state') || p.getState('rap-pcp-li');
1597
1981
  const begin = (d['beginposition'] ?? '').split(',');
1598
1982
  return {
@@ -1606,7 +1990,7 @@ export class RwsClient2 {
1606
1990
  async getMotionPointer(task) {
1607
1991
  // Live-verified: /syncstate/motion-pointer returns class="rap-task-sync-state"
1608
1992
  // with a single span class="motion-pointer-state" containing 'Off' or position info.
1609
- const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/syncstate/motion-pointer`));
1993
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/syncstate/motion-pointer`));
1610
1994
  const d = p.getState('rap-task-sync-state');
1611
1995
  const stateVal = d['motion-pointer-state'] ?? '';
1612
1996
  return {
@@ -1637,7 +2021,7 @@ export class RwsClient2 {
1637
2021
  `elog_at_error=false`,
1638
2022
  ].join('&');
1639
2023
  const html = await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}/joints-from-cartesian`, undefined, bodyStr, 'application/x-www-form-urlencoded;v=2.0');
1640
- const p = new XhtmlParser(html);
2024
+ const p = RwsClient2.parse(html);
1641
2025
  const d = p.getState('ms-jointtarget');
1642
2026
  if (!d['rax_1']) {
1643
2027
  throw new Error('IK: no joint values in response');
@@ -1647,26 +2031,5 @@ export class RwsClient2 {
1647
2031
  rax_4: +d['rax_4'], rax_5: +d['rax_5'], rax_6: +d['rax_6'],
1648
2032
  };
1649
2033
  }
1650
- /**
1651
- * Send a DELETE request to an absolute URL (used to clean up subscriptions).
1652
- * The URL is absolute (https://host:port/poll/{id}) so we can't use req() directly.
1653
- */
1654
- reqDelete(absoluteUrl) {
1655
- return new Promise((resolve) => {
1656
- const url = new URL(absoluteUrl);
1657
- const isHttps = url.protocol === 'https:';
1658
- const options = {
1659
- method: 'DELETE',
1660
- hostname: url.hostname,
1661
- port: url.port ? +url.port : (isHttps ? 443 : 80),
1662
- path: url.pathname,
1663
- headers: { Authorization: this.authHeader },
1664
- ...(isHttps ? { agent: this.httpsAgent } : {}),
1665
- };
1666
- const req = (isHttps ? https : http).request(options, res => { res.resume(); resolve(); });
1667
- req.on('error', () => resolve()); // best-effort
1668
- req.end();
1669
- });
1670
- }
1671
2034
  }
1672
2035
  //# sourceMappingURL=RwsClient2.js.map