abb-rws-client 0.5.0 → 0.7.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (63) hide show
  1. package/CHANGELOG.md +203 -0
  2. package/README.md +215 -28
  3. package/dist/HttpSession.d.ts.map +1 -1
  4. package/dist/HttpSession.js +18 -1
  5. package/dist/HttpSession.js.map +1 -1
  6. package/dist/IRWSAdapter.d.ts +421 -0
  7. package/dist/IRWSAdapter.d.ts.map +1 -0
  8. package/dist/IRWSAdapter.js +2 -0
  9. package/dist/IRWSAdapter.js.map +1 -0
  10. package/dist/Logger.d.ts +35 -0
  11. package/dist/Logger.d.ts.map +1 -0
  12. package/dist/Logger.js +24 -0
  13. package/dist/Logger.js.map +1 -0
  14. package/dist/MultiRobotManager.d.ts +56 -0
  15. package/dist/MultiRobotManager.d.ts.map +1 -0
  16. package/dist/MultiRobotManager.js +129 -0
  17. package/dist/MultiRobotManager.js.map +1 -0
  18. package/dist/RWS1Adapter.d.ts +297 -0
  19. package/dist/RWS1Adapter.d.ts.map +1 -0
  20. package/dist/RWS1Adapter.js +870 -0
  21. package/dist/RWS1Adapter.js.map +1 -0
  22. package/dist/RWS2Adapter.d.ts +20 -0
  23. package/dist/RWS2Adapter.d.ts.map +1 -0
  24. package/dist/RWS2Adapter.js +19 -0
  25. package/dist/RWS2Adapter.js.map +1 -0
  26. package/dist/ResourceMapper.d.ts +9 -2
  27. package/dist/ResourceMapper.d.ts.map +1 -1
  28. package/dist/ResourceMapper.js +11 -3
  29. package/dist/ResourceMapper.js.map +1 -1
  30. package/dist/RobotManager.d.ts +536 -0
  31. package/dist/RobotManager.d.ts.map +1 -0
  32. package/dist/RobotManager.js +1523 -0
  33. package/dist/RobotManager.js.map +1 -0
  34. package/dist/RwsClient.d.ts +33 -3
  35. package/dist/RwsClient.d.ts.map +1 -1
  36. package/dist/RwsClient.js +74 -10
  37. package/dist/RwsClient.js.map +1 -1
  38. package/dist/RwsClient2.d.ts +438 -0
  39. package/dist/RwsClient2.d.ts.map +1 -0
  40. package/dist/RwsClient2.js +1672 -0
  41. package/dist/RwsClient2.js.map +1 -0
  42. package/dist/XhtmlParser.d.ts +24 -0
  43. package/dist/XhtmlParser.d.ts.map +1 -0
  44. package/dist/XhtmlParser.js +56 -0
  45. package/dist/XhtmlParser.js.map +1 -0
  46. package/dist/detect.d.ts +54 -0
  47. package/dist/detect.d.ts.map +1 -0
  48. package/dist/detect.js +187 -0
  49. package/dist/detect.js.map +1 -0
  50. package/dist/index.d.ts +29 -2
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +30 -2
  53. package/dist/index.js.map +1 -1
  54. package/dist/types.d.ts +2 -2
  55. package/dist/types.d.ts.map +1 -1
  56. package/dist/types.js.map +1 -1
  57. package/examples/01-quickstart-auto.mjs +20 -0
  58. package/examples/02-rws1-explicit.mjs +30 -0
  59. package/examples/03-rws2-explicit.mjs +26 -0
  60. package/examples/04-multi-robot.mjs +35 -0
  61. package/examples/05-remote-control-rmmp.mjs +52 -0
  62. package/examples/06-pull-module-source.mjs +38 -0
  63. package/package.json +30 -4
@@ -0,0 +1,1672 @@
1
+ import * as https from 'https';
2
+ import * as http from 'http';
3
+ import { XhtmlParser } from './XhtmlParser.js';
4
+ import { Logger } from './Logger.js';
5
+ /**
6
+ * RWS 2.0 protocol client for ABB OmniCore controllers (RobotWare 7.x).
7
+ *
8
+ * Companion to `RwsClient` (RWS 1.0 / IRC5 / RobotWare 6.x). If you don't know
9
+ * which protocol your controller uses, prefer `createClient(host)` from this
10
+ * package — it probes the auth challenge and returns the right client.
11
+ *
12
+ * Key differences vs RWS 1.0 (all confirmed by live virtual-controller probing):
13
+ * - HTTP Basic auth instead of Digest
14
+ * - Path-based actions: /rw/rapid/execution/stop (not ?action=stop)
15
+ * - XHTML responses only (Accept: application/xhtml+xml;v=2.0)
16
+ * - Mastership domains: 'edit' replaces both 'cfg' and 'rapid'
17
+ * - FileService home: 'HOME' not '$HOME'
18
+ * - Self-signed TLS on virtual controllers → rejectUnauthorized: false
19
+ */
20
+ export class RwsClient2 {
21
+ baseUrl;
22
+ lastReqTime = 0;
23
+ static MIN_MS = 55;
24
+ authHeader;
25
+ httpsAgent;
26
+ httpAgent;
27
+ isHttps;
28
+ /** Session cookie set by the controller on first auth — REQUIRED to avoid creating
29
+ * a new session per request (controller's session pool fills in seconds otherwise). */
30
+ sessionCookie = null;
31
+ /** Signal name → {network, device} — populated by listAllSignals for writeSignal lookups */
32
+ sigCoords = new Map();
33
+ constructor(baseUrl, username, password) {
34
+ this.baseUrl = baseUrl;
35
+ this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
36
+ this.isHttps = baseUrl.startsWith('https');
37
+ // keepAlive reuses the TCP connection so we don't churn sessions on every poll.
38
+ this.httpsAgent = new https.Agent({ rejectUnauthorized: false, keepAlive: true });
39
+ this.httpAgent = new http.Agent({ keepAlive: true });
40
+ }
41
+ // ─── HTTP transport ────────────────────────────────────────────────────────
42
+ /**
43
+ * Core HTTP request. acceptExtra lists additional success status codes beyond 200/204.
44
+ * Used by subscribe() to accept HTTP 201 (Created) from POST /subscription.
45
+ */
46
+ async req(method, path, body, rawBody, rawContentType, acceptExtra = []) {
47
+ const wait = RwsClient2.MIN_MS - (Date.now() - this.lastReqTime);
48
+ if (wait > 0) {
49
+ await new Promise(r => setTimeout(r, wait));
50
+ }
51
+ this.lastReqTime = Date.now();
52
+ const url = new URL(path, this.baseUrl);
53
+ const bodyStr = rawBody ?? (body ? new URLSearchParams(body).toString() : undefined);
54
+ // RWS 2.0 requires Content-Type on all POST/PUT/DELETE requests, even with no body
55
+ // (mastership and a few other endpoints return HTTP 406 without it).
56
+ const writingMethod = method === 'POST' || method === 'PUT' || method === 'DELETE';
57
+ const options = {
58
+ method,
59
+ hostname: url.hostname,
60
+ port: url.port ? +url.port : (this.isHttps ? 443 : 80),
61
+ path: url.pathname + url.search,
62
+ headers: {
63
+ Authorization: this.authHeader,
64
+ Accept: 'application/xhtml+xml;v=2.0',
65
+ ...(this.sessionCookie ? { Cookie: this.sessionCookie } : {}),
66
+ ...(writingMethod ? {
67
+ 'Content-Type': rawContentType ?? 'application/x-www-form-urlencoded;v=2.0',
68
+ 'Content-Length': String(bodyStr ? Buffer.byteLength(bodyStr) : 0),
69
+ } : {}),
70
+ },
71
+ agent: this.isHttps ? this.httpsAgent : this.httpAgent,
72
+ };
73
+ const startedAt = Date.now();
74
+ Logger.trace?.('http.req', `RWS2 ${method} ${path}`, {
75
+ protocol: 'rws2', method, path,
76
+ bodyPreview: bodyStr ? bodyStr.slice(0, 200) : undefined,
77
+ });
78
+ return new Promise((resolve, reject) => {
79
+ const transport = this.isHttps ? https : http;
80
+ const req = transport.request(options, res => {
81
+ // Capture session cookie on first response (controller assigns it on first auth).
82
+ // Without this we leak one session per request → controller pool fills in seconds.
83
+ const setCookies = res.headers['set-cookie'];
84
+ if (setCookies && setCookies.length > 0 && !this.sessionCookie) {
85
+ this.sessionCookie = setCookies.map(c => c.split(';')[0]).join('; ');
86
+ }
87
+ const chunks = [];
88
+ res.on('data', (c) => chunks.push(c));
89
+ res.on('end', () => {
90
+ const raw = Buffer.concat(chunks).toString('utf8');
91
+ const durationMs = Date.now() - startedAt;
92
+ const status = res.statusCode ?? 0;
93
+ if (status === 204) {
94
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → 204`, { protocol: 'rws2', method, path, status, durationMs });
95
+ resolve('');
96
+ return;
97
+ }
98
+ if (acceptExtra.includes(status)) {
99
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status}`, { protocol: 'rws2', method, path, status, durationMs, bodyPreview: raw.slice(0, 200) });
100
+ resolve(raw);
101
+ return;
102
+ }
103
+ if (status >= 400) {
104
+ const err = new XhtmlParser(raw).getError();
105
+ 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}` : '')));
108
+ return;
109
+ }
110
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status} (${raw.length}b)`, { protocol: 'rws2', method, path, status, durationMs, bodyLen: raw.length });
111
+ resolve(raw);
112
+ });
113
+ });
114
+ req.on('error', (e) => {
115
+ Logger.trace?.('http.err', `RWS2 ${method} ${path} → network error`, { protocol: 'rws2', method, path, error: String(e), durationMs: Date.now() - startedAt });
116
+ reject(e);
117
+ });
118
+ req.setTimeout(10000, () => {
119
+ req.destroy();
120
+ Logger.trace?.('http.err', `RWS2 ${method} ${path} → timeout`, { protocol: 'rws2', method, path, durationMs: Date.now() - startedAt });
121
+ reject(new Error(`RWS2 timeout: ${path}`));
122
+ });
123
+ if (bodyStr) {
124
+ req.write(bodyStr);
125
+ }
126
+ req.end();
127
+ });
128
+ }
129
+ // ─── Connection ────────────────────────────────────────────────────────────
130
+ async connect() { await this.req('GET', '/rw/system'); }
131
+ async disconnect() {
132
+ // /logout invalidates the session server-side (frees the slot in the controller's pool).
133
+ await this.req('GET', '/logout').catch(() => { });
134
+ this.sessionCookie = null;
135
+ // Drop pooled keep-alive sockets so the next connect() starts clean.
136
+ this.httpsAgent.destroy();
137
+ this.httpAgent.destroy();
138
+ }
139
+ getSessionCookie() { return this.sessionCookie; }
140
+ // ─── Panel ─────────────────────────────────────────────────────────────────
141
+ async getControllerState() {
142
+ const p = new XhtmlParser(await this.req('GET', '/rw/panel/ctrl-state'));
143
+ return (p.getState('pnl-ctrlstate')['ctrlstate'] ?? 'init');
144
+ }
145
+ setControllerState(state) {
146
+ return this.req('POST', '/rw/panel/ctrl-state', { 'ctrl-state': state }).then(() => { });
147
+ }
148
+ async getOperationMode() {
149
+ const p = new XhtmlParser(await this.req('GET', '/rw/panel/opmode'));
150
+ return (p.getState('pnl-opmode')['opmode'] ?? 'MANR');
151
+ }
152
+ async getSpeedRatio() {
153
+ const p = new XhtmlParser(await this.req('GET', '/rw/panel/speedratio'));
154
+ return Number(p.getState('pnl-speedratio')['speedratio'] ?? 100);
155
+ }
156
+ /**
157
+ * Set the speed ratio (0-100). Live-verified format on OmniCore VC RW7.21
158
+ * via scripts/probe-speedratio.js (2026-05-07):
159
+ * ✓ POST /rw/panel/speedratio?action=setspeedratio body speed-ratio=N
160
+ * (RWS 1.0 wire format — OmniCore kept the legacy path)
161
+ * Requires `edit` mastership: 403 "user does not have required
162
+ * mastership" without it.
163
+ * ✗ POST /rw/panel/speedratio body speedratio=N → 400 "Invalid input form data"
164
+ * ✗ POST /rw/panel/speedratio/set → 404 (path doesn't exist)
165
+ *
166
+ * Acquires `edit` mastership internally and releases it after.
167
+ */
168
+ async setSpeedRatio(ratio) {
169
+ const v = Math.round(Math.max(0, Math.min(100, ratio)));
170
+ await this.requestMastership('rapid'); // 'rapid' is renamed to 'edit' internally
171
+ try {
172
+ await this.req('POST', '/rw/panel/speedratio?action=setspeedratio', { 'speed-ratio': String(v) });
173
+ }
174
+ finally {
175
+ await this.releaseMastership('rapid').catch(() => { });
176
+ }
177
+ }
178
+ async getCollisionDetectionState() {
179
+ const p = new XhtmlParser(await this.req('GET', '/rw/panel/coldetstate'));
180
+ return (p.getState('pnl-coldetstate')['coldetstate'] ?? 'INIT');
181
+ }
182
+ lockOperationMode(pin, permanent = false) {
183
+ // POST /rw/panel/opmode/lock with pin and permanent flag
184
+ return this.req('POST', '/rw/panel/opmode/lock', {
185
+ pin,
186
+ permanent: permanent ? '1' : '0',
187
+ }).then(() => { });
188
+ }
189
+ unlockOperationMode() {
190
+ return this.req('POST', '/rw/panel/opmode/unlock').then(() => { });
191
+ }
192
+ /**
193
+ * Switch the controller's operation mode. **Virtual controllers only** —
194
+ * real hardware respects the FlexPendant key switch.
195
+ *
196
+ * Endpoint + wire format — ALL live-verified on OmniCore VC RW7.x via
197
+ * scripts/probe-opmode-write.js (2026-05-07):
198
+ * ✓ POST /rw/panel/opmode body opmode=auto → AUTO (200 OK)
199
+ * ✓ POST /rw/panel/opmode body opmode=man → MANR (200 OK)
200
+ * ✓ POST /rw/panel/opmode body opmode=manf → MANF (200 OK) — NOTE: `manf`,
201
+ * NOT `manfs` as RWS 1.0 uses. RWS 2.0 dropped the 's'.
202
+ * ✗ POST /rw/panel/opmode/set → 404 (path doesn't exist)
203
+ * ✗ POST /rw/panel/opmode body opmode=AUTO → 400 invalid value
204
+ * ✗ POST /rw/panel/opmode body opmode=manr → 400 invalid value
205
+ *
206
+ * The wire value is lowercase and uses the RWS 1.0 abbreviations *except*
207
+ * for MANF (`manf` on RWS 2.0 vs `manfs` on RWS 1.0). And NEITHER matches
208
+ * the GET-response casing (`AUTO`/`MANR`/`MANF`). This asymmetry is one
209
+ * of the documented protocol quirks of RWS 2.0.
210
+ *
211
+ * Side note: the controller pops up a confirmation dialog on the FlexPendant
212
+ * after the call returns 200 OK; the operator must approve before the mode
213
+ * actually flips. There is no API path to bypass this — UAS-grant changes
214
+ * are FlexPendant-only by design.
215
+ */
216
+ setOperationMode(mode) {
217
+ const wire = mode === 'AUTO' ? 'auto' : mode === 'MANR' ? 'man' : 'manf';
218
+ return this.req('POST', '/rw/panel/opmode', { opmode: wire }).then(() => { });
219
+ }
220
+ // ─── RAPID execution ────────────────────────────────────────────────────────
221
+ async getRapidExecutionState() {
222
+ const p = new XhtmlParser(await this.req('GET', '/rw/rapid/execution'));
223
+ return (p.getState('rap-execution')['ctrlexecstate'] ?? 'stopped');
224
+ }
225
+ async getRapidExecutionInfo() {
226
+ const p = new XhtmlParser(await this.req('GET', '/rw/rapid/execution'));
227
+ // Live: <li class="rap-execution"><span class="ctrlexecstate">stopped</span><span class="cycle">forever</span>
228
+ const d = p.getState('rap-execution');
229
+ return {
230
+ state: (d['ctrlexecstate'] ?? 'stopped'),
231
+ cycle: d['cycle'] ?? 'asis',
232
+ };
233
+ }
234
+ startRapid() {
235
+ return this.req('POST', '/rw/rapid/execution/start', {
236
+ regain: 'continue', execmode: 'continue', cycle: 'asis',
237
+ condition: 'none', stopatbp: 'disabled', alltaskbytsp: 'false',
238
+ }).then(() => { });
239
+ }
240
+ stopRapid() {
241
+ return this.req('POST', '/rw/rapid/execution/stop', { stopmode: 'stop' }).then(() => { });
242
+ }
243
+ resetRapid() {
244
+ return this.req('POST', '/rw/rapid/execution/resetpp').then(() => { });
245
+ }
246
+ setExecutionCycle(cycle) {
247
+ return this.req('POST', '/rw/rapid/execution/cycle', { cycle }).then(() => { });
248
+ }
249
+ async getRapidTasks() {
250
+ const p = new XhtmlParser(await this.req('GET', '/rw/rapid/tasks'));
251
+ return p.getAllStates('rap-task-li').map(t => ({
252
+ name: t['name'] ?? '',
253
+ type: t['type'] ?? 'normal',
254
+ taskstate: t['taskstate'] ?? '',
255
+ excstate: (t['excstate'] === 'running' ? 'running' : 'stopped'),
256
+ active: t['active'] === 'On' || t['active'] === 'true',
257
+ motiontask: t['motiontask'] === 'TRUE' || t['motiontask'] === 'True',
258
+ }));
259
+ }
260
+ async activateRapidTask(task) {
261
+ await this.requestMastership('rapid');
262
+ try {
263
+ await this.req('POST', '/rw/rapid/tasks/activate', { task });
264
+ }
265
+ finally {
266
+ await this.releaseMastership('rapid').catch(() => { });
267
+ }
268
+ }
269
+ async deactivateRapidTask(task) {
270
+ await this.requestMastership('rapid');
271
+ try {
272
+ await this.req('POST', '/rw/rapid/tasks/deactivate', { task });
273
+ }
274
+ finally {
275
+ await this.releaseMastership('rapid').catch(() => { });
276
+ }
277
+ }
278
+ async activateAllRapidTasks() {
279
+ // Get task list then activate each
280
+ const tasks = await this.getRapidTasks();
281
+ await this.requestMastership('rapid');
282
+ try {
283
+ for (const t of tasks) {
284
+ await this.req('POST', '/rw/rapid/tasks/activate', { task: t.name }).catch(() => { });
285
+ }
286
+ }
287
+ finally {
288
+ await this.releaseMastership('rapid').catch(() => { });
289
+ }
290
+ }
291
+ async deactivateAllRapidTasks() {
292
+ const tasks = await this.getRapidTasks();
293
+ await this.requestMastership('rapid');
294
+ try {
295
+ for (const t of tasks) {
296
+ await this.req('POST', '/rw/rapid/tasks/deactivate', { task: t.name }).catch(() => { });
297
+ }
298
+ }
299
+ finally {
300
+ await this.releaseMastership('rapid').catch(() => { });
301
+ }
302
+ }
303
+ // ─── RAPID modules & variables ──────────────────────────────────────────────
304
+ async listModules(task) {
305
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
306
+ return p.getAllStates('rap-module-info-li').map(m => m['name']).filter(Boolean);
307
+ }
308
+ /**
309
+ * Returns each loaded module's name + type (SysMod | ProgMod | …).
310
+ * Single round-trip — same endpoint as `listModules` but exposes more fields.
311
+ */
312
+ async listModulesDetailed(task) {
313
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
314
+ return p.getAllStates('rap-module-info-li')
315
+ .map(m => ({ name: m['name'] ?? '', type: m['type'] ?? '' }))
316
+ .filter(m => m.name);
317
+ }
318
+ async loadModule(task, path, replace = false) {
319
+ // RWS 2.0 module-load endpoint: POST /rw/rapid/tasks/{task}/loadmod with `modulepath`.
320
+ // (The /program/load endpoint is for full multi-module .pgf programs and uses a different
321
+ // "virtual root" path scheme that doesn't accept user-uploaded HOME/* files.)
322
+ //
323
+ // The path needs to be in fileservice form WITHOUT the leading `$` — translate
324
+ // `$HOME/...` → `HOME/...` so the same code works for callers passing either format.
325
+ const modulePath = path.replace(/^\$HOME\//, 'HOME/').replace(/^\$/, '');
326
+ const body = { modulepath: modulePath };
327
+ if (replace) {
328
+ body['replace'] = 'true';
329
+ }
330
+ await this.req('POST', `/rw/rapid/tasks/${task}/loadmod`, body);
331
+ }
332
+ unloadModule(task, name) {
333
+ // RWS 2.0 unload is path-based action: POST /rw/rapid/tasks/{task}/unloadmod
334
+ // (DELETE on the module URL returns 405; only POST + body works.)
335
+ return this.req('POST', `/rw/rapid/tasks/${task}/unloadmod`, { module: name }).then(() => { });
336
+ }
337
+ async getRapidVariable(task, module, symbol) {
338
+ // RWS 2.0 symbol API: suffix-style — /rw/rapid/symbol/{symburl}/data
339
+ // (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`));
341
+ return p.get('value') ?? '';
342
+ }
343
+ setRapidVariable(task, module, symbol, value) {
344
+ return this.req('POST', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/data`, { value }).then(() => { });
345
+ }
346
+ async validateRapidValue(task, value, datatype) {
347
+ // RWS 2.0: endpoint path differs — use per-task validate
348
+ try {
349
+ await this.req('POST', `/rw/rapid/symbol/RAPID/${task}/data?action=validate`, {
350
+ value, dattyp: datatype,
351
+ });
352
+ return true;
353
+ }
354
+ catch {
355
+ return false;
356
+ }
357
+ }
358
+ async getRapidSymbolProperties(task, module, symbol) {
359
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/properties`));
360
+ const d = p.getState('rap-sympropvar') || p.getState('rap-sympropvar-li') || p.getState('rap-symbol-properties');
361
+ return {
362
+ symburl: d['symburl'] ?? `RAPID/${task}/${module}/${symbol}`,
363
+ symtyp: d['symtyp'] ?? '',
364
+ named: d['named'] === 'true',
365
+ dattyp: d['dattyp'] ?? '',
366
+ ndim: Number(d['ndim'] ?? 0),
367
+ dim: d['dim'] ?? '',
368
+ heap: d['heap'] === 'true',
369
+ linked: d['linked'] === 'true',
370
+ local: d['local'] === 'true',
371
+ ro: d['rdonly'] === 'true' || d['ro'] === 'true',
372
+ taskvar: d['taskvar'] === 'true',
373
+ storage: d['storage'] ?? '',
374
+ typurl: d['typurl'] ?? '',
375
+ };
376
+ }
377
+ async searchRapidSymbols(params) {
378
+ // RWS 2.0 /rw/rapid/symbols/search expects view=block + blockurl + symtyp=any
379
+ // (NOT a `task` field — that returns 400 "Invalid parameter").
380
+ // It returns one <li> per match. The `class` of the <li> tells you the kind:
381
+ // rap-sympropvar-li → variable (VAR)
382
+ // rap-syproppers-li → persistent (PERS)
383
+ // rap-sympropconst-li → constant (CONST)
384
+ // rap-sympropproc-li → procedure (PROC)
385
+ // rap-sympropfun-li → function (FUNC)
386
+ // rap-sympropmod-li → module
387
+ // Earlier versions only parsed vars — missing all the routines.
388
+ const body = {};
389
+ if (params.view) {
390
+ body['view'] = params.view;
391
+ }
392
+ if (params.vartyp) {
393
+ body['vartyp'] = params.vartyp;
394
+ }
395
+ if (params.symtyp) {
396
+ body['symtyp'] = params.symtyp;
397
+ }
398
+ if (params.dattyp) {
399
+ body['dattyp'] = params.dattyp;
400
+ }
401
+ if (params.regexp) {
402
+ body['regexp'] = params.regexp;
403
+ }
404
+ if (params.recursive !== undefined) {
405
+ body['recursive'] = String(params.recursive);
406
+ }
407
+ if (params.blockurl) {
408
+ body['blockurl'] = params.blockurl;
409
+ }
410
+ // Sensible defaults so callers can pass just `{ blockurl }`
411
+ if (!body['view']) {
412
+ body['view'] = 'block';
413
+ }
414
+ if (!body['symtyp']) {
415
+ body['symtyp'] = 'any';
416
+ }
417
+ if (!body['recursive']) {
418
+ body['recursive'] = 'TRUE';
419
+ }
420
+ const xhtml = await this.req('POST', '/rw/rapid/symbols/search', body);
421
+ const liClasses = [
422
+ 'rap-sympropvar-li',
423
+ 'rap-syproppers-li',
424
+ 'rap-sympropconst-li',
425
+ 'rap-sympropproc-li',
426
+ 'rap-sympropfun-li',
427
+ 'rap-sympropmod-li',
428
+ 'rap-symproptrap-li',
429
+ ];
430
+ const out = [];
431
+ for (const cls of liClasses) {
432
+ const p = new XhtmlParser(xhtml);
433
+ for (const s of p.getAllStates(cls)) {
434
+ out.push({
435
+ symburl: s['symburl'] ?? '',
436
+ name: s['name'] ?? '',
437
+ symtyp: s['symtyp'] ?? '',
438
+ dattyp: s['dattyp'] ?? '',
439
+ ndim: Number(s['ndim'] ?? 0),
440
+ local: s['local'] === 'true',
441
+ ro: s['rdonly'] === 'true',
442
+ taskvar: s['taskvar'] === 'true',
443
+ });
444
+ }
445
+ }
446
+ return out;
447
+ }
448
+ async getActiveUiInstruction() {
449
+ try {
450
+ const p = new XhtmlParser(await this.req('GET', '/rw/rapid/uiinstr/active'));
451
+ const d = p.getState('rap-uiinstr-li') || p.getState('rap-uiinstr');
452
+ if (!d['instr']) {
453
+ return null;
454
+ }
455
+ return { instr: d['instr'], event: d['event'] ?? '', stack: d['stack'] ?? '', execlv: d['execlv'] ?? '', msg: d['msg'] ?? '' };
456
+ }
457
+ catch {
458
+ return null;
459
+ }
460
+ }
461
+ setUiInstructionParam(stackurl, uiparam, value) {
462
+ // RWS 2.0: POST /rw/rapid/uiinstr/active/param/{stackurl}/{uiparam}
463
+ return this.req('POST', `/rw/rapid/uiinstr/active/param/${encodeURIComponent(stackurl)}/${encodeURIComponent(uiparam)}`, { value }).then(() => { });
464
+ }
465
+ // ─── Motion ─────────────────────────────────────────────────────────────────
466
+ async getJointPositions(mechunit = 'ROB_1') {
467
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/jointtarget`));
468
+ const d = p.getState('ms-jointtarget');
469
+ return {
470
+ rax_1: +d['rax_1'], rax_2: +d['rax_2'], rax_3: +d['rax_3'],
471
+ rax_4: +d['rax_4'], rax_5: +d['rax_5'], rax_6: +d['rax_6'],
472
+ };
473
+ }
474
+ async getCartesianFull(mechunit = 'ROB_1') {
475
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/cartesian`));
476
+ // Live: cf1/cf4/cf6/cfx in RWS 2.0 map to j1/j4/j6/jx in CartesianFull type
477
+ const d = p.getState('ms-mechunit-cartesian');
478
+ return {
479
+ x: +d['x'], y: +d['y'], z: +d['z'],
480
+ q1: +d['q1'], q2: +d['q2'], q3: +d['q3'], q4: +d['q4'],
481
+ j1: +d['cf1'], j4: +d['cf4'], j6: +d['cf6'], jx: +d['cfx'],
482
+ };
483
+ }
484
+ async listMechunits() {
485
+ const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/mechunits'));
486
+ // Live: <li class="ms-mechunit-li" title="ROB_1">
487
+ return p.getAllStates('ms-mechunit-li')
488
+ .map(m => m['_title'])
489
+ .filter(Boolean);
490
+ }
491
+ // ─── System info ─────────────────────────────────────────────────────────────
492
+ async getSystemInfo() {
493
+ const p = new XhtmlParser(await this.req('GET', '/rw/system'));
494
+ const d = p.getState('sys-system');
495
+ const opts = p.getAllStates('sys-option').map(o => o['option']).filter(Boolean);
496
+ return { name: d['name'] ?? '', rwVersion: d['rwversion'] ?? '', sysid: d['sysid'] ?? '', startTime: d['starttm'] ?? '', options: opts };
497
+ }
498
+ async getControllerIdentity() {
499
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/identity'));
500
+ const d = p.getState('ctrl-identity-info');
501
+ return { name: d['ctrl-name'] ?? '', id: '', type: d['ctrl-type'] ?? '', mac: '' };
502
+ }
503
+ async getControllerClock() {
504
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/clock'));
505
+ return { datetime: p.getState('ctrl-clock-info')['datetime'] ?? '' };
506
+ }
507
+ setControllerClock(year, month, day, hour, min, sec) {
508
+ // PUT /ctrl/clock — field names confirmed from RwsClient ResourceMapper
509
+ return this.req('PUT', '/ctrl/clock', {
510
+ 'sys-clock-year': String(year),
511
+ 'sys-clock-month': String(month),
512
+ 'sys-clock-day': String(day),
513
+ 'sys-clock-hour': String(hour),
514
+ 'sys-clock-min': String(min),
515
+ 'sys-clock-sec': String(sec),
516
+ }).then(() => { });
517
+ }
518
+ restartController(mode = 'restart') {
519
+ return this.req('POST', '/ctrl/restart', { 'restart-mode': mode }).then(() => { });
520
+ }
521
+ // ─── Event log ───────────────────────────────────────────────────────────────
522
+ async getEventLog(domain = 0) {
523
+ // 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`));
525
+ return p.getAllStates('elog-message-li').map(m => {
526
+ const parts = (m['_title'] ?? '').split('/');
527
+ return {
528
+ seqnum: Number(parts[parts.length - 1] ?? 0),
529
+ code: Number(m['code'] ?? 0),
530
+ msgtype: Number(m['msgtype'] ?? 1),
531
+ timestamp: m['tstamp'] ?? '',
532
+ srcName: m['src-name'] ?? '',
533
+ title: m['title'] ?? `Event ${m['code']}`,
534
+ desc: m['desc'] ?? '',
535
+ causes: m['causes'] ?? '',
536
+ consequences: m['conseqs'] ?? '',
537
+ actions: m['actions'] ?? '',
538
+ };
539
+ });
540
+ }
541
+ clearEventLog(domain = 0) {
542
+ return this.req('POST', `/rw/elog/${domain}/clear`).then(() => { });
543
+ }
544
+ clearAllEventLogs() {
545
+ // Live confirmed: POST /rw/elog/clearall → 204
546
+ return this.req('POST', '/rw/elog/clearall').then(() => { });
547
+ }
548
+ // ─── I/O signals ─────────────────────────────────────────────────────────────
549
+ async listAllSignals(start = 0, limit = 200) {
550
+ const p = new XhtmlParser(await this.req('GET', `/rw/iosystem/signals?start=${start}&limit=${limit}`));
551
+ return p.getAllStates('ios-signal-li').map(s => {
552
+ const name = s['name'] ?? s['_title']?.split('/').pop() ?? '';
553
+ const parts = (s['_title'] ?? '').split('/');
554
+ if (parts.length >= 3) {
555
+ this.sigCoords.set(name, { n: parts[0], d: parts[1] });
556
+ }
557
+ return { name, value: s['lvalue'] ?? '0', type: (s['type'] ?? 'DI'), lvalue: s['lvalue'] ?? '0' };
558
+ });
559
+ }
560
+ async readSignal(network, device, name) {
561
+ const p = new XhtmlParser(await this.req('GET', `/rw/iosystem/signals/${network}/${device}/${name}`));
562
+ const d = p.getState('ios-signal-li');
563
+ return { name: d['name'] ?? name, value: d['lvalue'] ?? '0', type: (d['type'] ?? 'DI'), lvalue: d['lvalue'] ?? '0' };
564
+ }
565
+ writeSignal(network, device, name, value) {
566
+ let n = network, d = device;
567
+ if (!n || !d) {
568
+ const c = this.sigCoords.get(name);
569
+ n = c?.n ?? '';
570
+ d = c?.d ?? '';
571
+ }
572
+ return this.req('POST', `/rw/iosystem/signals/${n}/${d}/${name}/set-value`, { lvalue: value }).then(() => { });
573
+ }
574
+ async listNetworks() {
575
+ const p = new XhtmlParser(await this.req('GET', '/rw/iosystem/networks'));
576
+ // Live: <li class="ios-network-li" title="IntegratedIONetwork">
577
+ // <span class="name">IntegratedIONetwork</span><span class="pstate">running</span><span class="lstate">started</span>
578
+ return p.getAllStates('ios-network-li').map(n => ({
579
+ name: n['name'] ?? n['_title'] ?? '',
580
+ pstate: n['pstate'] ?? '',
581
+ lstate: n['lstate'] ?? '',
582
+ }));
583
+ }
584
+ async listDevices(network) {
585
+ const p = new XhtmlParser(await this.req('GET', `/rw/iosystem/devices?network=${encodeURIComponent(network)}`));
586
+ // Live: <li class="ios-device-li" title="IntBus/EPanel">
587
+ // <span class="name">EPanel</span><span class="lstate">enabled</span><span class="pstate">running</span><span class="address"></span>
588
+ return p.getAllStates('ios-device-li').map(d => ({
589
+ name: d['name'] ?? d['_title']?.split('/').pop() ?? '',
590
+ network,
591
+ lstate: d['lstate'] ?? '',
592
+ pstate: d['pstate'] ?? '',
593
+ address: d['address'] ?? '',
594
+ }));
595
+ }
596
+ // ─── File system ──────────────────────────────────────────────────────────────
597
+ rws2Path(path) { return path.replace(/\$HOME/g, 'HOME'); }
598
+ async listDirectory(path) {
599
+ const p = new XhtmlParser(await this.req('GET', `/fileservice/${this.rws2Path(path)}`));
600
+ const dirs = p.getAllStates('fs-dir').map(d => ({ name: d['_title'] ?? '', type: 'dir', modified: d['fs-mdate'] }));
601
+ 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
+ return [...dirs, ...files];
603
+ }
604
+ readFile(path) { return this.req('GET', `/fileservice/${this.rws2Path(path)}`); }
605
+ uploadFile(path, content) {
606
+ // RWS 2.0 requires the versioned content type: 'text/plain;v=2.0' or
607
+ // 'application/octet-stream;v=2.0'. Plain 'text/plain' returns HTTP 415.
608
+ return this.req('PUT', `/fileservice/${this.rws2Path(path)}`, undefined, content, 'text/plain;v=2.0').then(() => { });
609
+ }
610
+ deleteFile(path) {
611
+ return this.req('DELETE', `/fileservice/${this.rws2Path(path)}`).then(() => { });
612
+ }
613
+ /**
614
+ * Create a directory under `parentPath`. Live-verified RWS 2.0 API:
615
+ * POST /fileservice/{parent}/create
616
+ * body: fs-newname={dirName}
617
+ *
618
+ * The earlier shape (`/fileservice/{parent}/{dirName}/create` with no body)
619
+ * returned 404 because the controller treated `{parent}/{dirName}` as the
620
+ * parent and looked for an already-existing `{dirName}` segment.
621
+ */
622
+ createDirectory(parentPath, dirName) {
623
+ return this.req('POST', `/fileservice/${this.rws2Path(parentPath)}/create`, { 'fs-newname': dirName }).then(() => { });
624
+ }
625
+ copyFile(sourcePath, destPath) {
626
+ return this.req('POST', `/fileservice/${this.rws2Path(sourcePath)}/copy`, { destination: destPath }).then(() => { });
627
+ }
628
+ // ─── Configuration database `/rw/cfg` ───────────────────────────────────────
629
+ async listCfgDomains() {
630
+ const p = new XhtmlParser(await this.req('GET', '/rw/cfg'));
631
+ return p.getAllStates('cfg-domain-li').map(d => d['_title'] ?? d['name']).filter(Boolean);
632
+ }
633
+ async listCfgTypes(domain) {
634
+ // Live-verified class: cfg-dt-li (datatype-li). Paginated — controller returns 70/page.
635
+ // Pagination quirk: the `rel="next"` href is relative to the response's <base href>
636
+ // which is /rw/cfg/, NOT to /rw/. Resolve relative to the current request's parent path.
637
+ const types = [];
638
+ let path = `/rw/cfg/${domain}`;
639
+ let pages = 0;
640
+ while (path && pages < 50) {
641
+ const html = await this.req('GET', path);
642
+ const p = new XhtmlParser(html);
643
+ 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
+ }
654
+ pages++;
655
+ }
656
+ return types;
657
+ }
658
+ async listCfgInstances(domain, type) {
659
+ // Live-verified: instances live under /{domain}/{type}/instances (with /instances/ suffix).
660
+ // Each is class="cfg-dt-instance-li" with the instance name as the title attribute.
661
+ // Paginated: controller returns 70/page with `rel="next"` link.
662
+ // Note: a few "types" returned by listCfgTypes are placeholders (e.g. SYS/SYSTEM_NAME)
663
+ // that error with HTTP 400 "Invalid type id" — return [] silently for those.
664
+ const instances = [];
665
+ let path = `/rw/cfg/${domain}/${type}/instances`;
666
+ let pages = 0;
667
+ while (path && pages < 50) {
668
+ let html;
669
+ try {
670
+ html = await this.req('GET', path);
671
+ }
672
+ catch {
673
+ return instances;
674
+ } // invalid type or no permission — silent empty
675
+ const p = new XhtmlParser(html);
676
+ 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
+ }
686
+ pages++;
687
+ }
688
+ return instances;
689
+ }
690
+ async getCfgInstance(domain, type, instance) {
691
+ // Live-verified: /{domain}/{type}/instances/{instance}
692
+ // Returns an outer cfg-dt-instance li with NESTED cfg-ia-t li elements.
693
+ // Each attribute: <li class="cfg-ia-t" title="ATTR_NAME"><span class="value">VALUE</span></li>
694
+ const html = await this.req('GET', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`);
695
+ const p = new XhtmlParser(html);
696
+ const attribs = p.getAllStates('cfg-ia-t');
697
+ const result = {};
698
+ for (const attr of attribs) {
699
+ const name = attr['_title'];
700
+ const value = attr['value'] ?? '';
701
+ if (name) {
702
+ result[name] = value;
703
+ }
704
+ }
705
+ return result;
706
+ }
707
+ async setCfgInstance(domain, type, instance, attrs) {
708
+ await this.req('POST', `/rw/cfg/${domain}/${type}/${encodeURIComponent(instance)}`, attrs);
709
+ }
710
+ 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);
713
+ }
714
+ async removeCfgInstance(domain, type, instance) {
715
+ await this.req('DELETE', `/rw/cfg/${domain}/${type}/${encodeURIComponent(instance)}`);
716
+ }
717
+ async loadCfgFile(filepath, action = 'replace') {
718
+ await this.req('POST', '/rw/cfg', { 'action-type': action, filepath });
719
+ }
720
+ async saveCfgFile(domain, filepath) {
721
+ await this.req('POST', `/rw/cfg/${domain}/save`, { filepath });
722
+ }
723
+ // ─── Backup / Restore `/ctrl/backup` ────────────────────────────────────────
724
+ async listBackups() {
725
+ // Backups live under /fileservice/BACKUP — list that volume
726
+ try {
727
+ const p = new XhtmlParser(await this.req('GET', '/fileservice/BACKUP'));
728
+ return p.getAllStates('fs-dir').map(d => ({
729
+ name: d['_title'] ?? '',
730
+ created: d['fs-cdate'],
731
+ }));
732
+ }
733
+ catch {
734
+ return [];
735
+ }
736
+ }
737
+ async createBackup(name) {
738
+ await this.req('POST', '/ctrl/backup/create', { 'backup': `BACKUP/${name}` });
739
+ }
740
+ async restoreBackup(name) {
741
+ await this.req('POST', '/ctrl/backup/restore', { 'backup': `BACKUP/${name}` });
742
+ }
743
+ async getBackupStatus() {
744
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/backup'));
745
+ const d = p.getState('ctrl-backup-info-li') || p.getState('ctrl-backup-info');
746
+ const phase = d['progress-state'] ?? d['phase'] ?? '';
747
+ return {
748
+ active: phase !== '' && phase !== 'idle' && phase !== 'finished',
749
+ progress: d['progress'] ? +d['progress'] : undefined,
750
+ phase,
751
+ };
752
+ }
753
+ // ─── Tool / WObj management ─────────────────────────────────────────────────
754
+ // RWS exposes these via the mechunit's tool-name / wobj-name attributes;
755
+ // setting requires updating the active task's tooldata/wobjdata RAPID symbols.
756
+ async getActiveTool(mechunit = 'ROB_1') {
757
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
758
+ const d = p.getState('ms-mechunit');
759
+ return { name: d['tool-name'] ?? 'tool0' };
760
+ }
761
+ async getActiveWobj(mechunit = 'ROB_1') {
762
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
763
+ const d = p.getState('ms-mechunit');
764
+ return { name: d['wobj-name'] ?? 'wobj0' };
765
+ }
766
+ async getActivePayload(mechunit = 'ROB_1') {
767
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
768
+ const d = p.getState('ms-mechunit');
769
+ return { name: d['total-payload-name'] ?? d['payload-name'] ?? 'load0' };
770
+ }
771
+ async setActiveTool(mechunit, toolName) {
772
+ await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}`, { 'tool': toolName });
773
+ }
774
+ async setActiveWobj(mechunit, wobjName) {
775
+ await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}`, { 'wobj': wobjName });
776
+ }
777
+ // ─── Service routine / PROC call ────────────────────────────────────────────
778
+ async callServiceRoutine(task, routineName, args = {}) {
779
+ await this.req('POST', `/rw/rapid/tasks/${task}/serviceroutine`, { routine: routineName, ...args });
780
+ }
781
+ // ─── DIPC `/rw/dipc` ───────────────────────────────────────────────────────
782
+ async listDipcQueues() {
783
+ const p = new XhtmlParser(await this.req('GET', '/rw/dipc'));
784
+ return p.getAllStates('dipc-queue-li').map(q => ({
785
+ name: q['queue-name'] ?? q['_title'] ?? '',
786
+ size: q['queue-size'] ? +q['queue-size'] : undefined,
787
+ }));
788
+ }
789
+ async createDipcQueue(name, options = {}) {
790
+ const body = { 'dipc-queue-name': name };
791
+ if (options.maxsize) {
792
+ body['dipc-max-size'] = String(options.maxsize);
793
+ }
794
+ if (options.maxmessages) {
795
+ body['dipc-max-number-of-messages'] = String(options.maxmessages);
796
+ }
797
+ await this.req('POST', '/rw/dipc', body);
798
+ }
799
+ async sendDipcMessage(queue, payload, type = 'string') {
800
+ await this.req('POST', `/rw/dipc/${encodeURIComponent(queue)}`, {
801
+ 'dipc-src-queue-name': queue,
802
+ 'dipc-cmd': '111', // SEND
803
+ 'dipc-data': payload,
804
+ 'dipc-msgtype': type === 'string' ? '0' : type === 'num' ? '1' : type === 'dnum' ? '2' : '3',
805
+ });
806
+ }
807
+ async readDipcMessage(queue, timeoutMs = 0) {
808
+ try {
809
+ const p = new XhtmlParser(await this.req('POST', `/rw/dipc/${encodeURIComponent(queue)}/read`, {
810
+ 'dipc-timeout': String(timeoutMs),
811
+ }));
812
+ const d = p.getState('dipc-message');
813
+ if (!d['dipc-data']) {
814
+ return null;
815
+ }
816
+ return { payload: d['dipc-data'], type: d['dipc-msgtype'] ?? 'string' };
817
+ }
818
+ catch {
819
+ return null;
820
+ }
821
+ }
822
+ async removeDipcQueue(name) {
823
+ await this.req('DELETE', `/rw/dipc/${encodeURIComponent(name)}`);
824
+ }
825
+ // ─── Mastership ───────────────────────────────────────────────────────────────
826
+ rws2Domain(domain) {
827
+ // RWS 2.0 renames: 'rapid' and 'cfg' both become 'edit' (confirmed: /rapid/request → 404)
828
+ return (domain === 'rapid' || domain === 'cfg') ? 'edit' : domain;
829
+ }
830
+ requestMastership(domain) {
831
+ return this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/request`).then(() => { });
832
+ }
833
+ releaseMastership(domain) {
834
+ return this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/release`).then(() => { });
835
+ }
836
+ /** Request mastership on ALL domains at once (RWS 2.0). Cheaper than calling per-domain. */
837
+ requestMastershipAll() {
838
+ return this.req('POST', '/rw/mastership/request').then(() => { });
839
+ }
840
+ /** Release mastership on ALL domains at once (RWS 2.0). */
841
+ releaseMastershipAll() {
842
+ return this.req('POST', '/rw/mastership/release').then(() => { });
843
+ }
844
+ /**
845
+ * Request mastership on `domain` and receive a numeric ID token. Use the ID
846
+ * with `releaseMastershipWithId()` from a different session — useful when a
847
+ * client needs mastership to outlive the cookie that acquired it (e.g. a
848
+ * webapp that periodically reconnects). Token-based release is the only way
849
+ * to free a stuck mastership after session loss without a controller restart.
850
+ */
851
+ async requestMastershipWithId(domain) {
852
+ const xhtml = await this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/request-with-id`);
853
+ const id = new XhtmlParser(xhtml).get('mastership-id');
854
+ if (!id) {
855
+ throw new Error('RWS2 request-with-id: no mastership-id in response');
856
+ }
857
+ return Number(id);
858
+ }
859
+ /**
860
+ * Release mastership previously acquired via `requestMastershipWithId()`.
861
+ * Body parameter is `mastershipid` (no dash — controller-specific naming
862
+ * confirmed via 400 "Invalid value" probing; the dash variant returns the
863
+ * same error code as a missing value).
864
+ */
865
+ releaseMastershipWithId(domain, id) {
866
+ return this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/release-with-id`, { mastershipid: String(id) }).then(() => { });
867
+ }
868
+ /**
869
+ * Reset the edit-mastership watchdog (RobotWare 7.8+). The controller has a
870
+ * heartbeat timer (default 2000 ms, configurable via `SYS/MASTER_BOOL/HeartBeat`);
871
+ * if the holding client doesn't ping during execution, motors go off and execution
872
+ * stops. Call this periodically (every ~1s) when holding mastership during a long
873
+ * RAPID run. No-op on RW6.x and on configurations with `Select=false`.
874
+ */
875
+ resetMastershipWatchdog() {
876
+ return this.req('POST', '/rw/mastership/watchdog').then(() => { });
877
+ }
878
+ /** Read mastership status for one domain — returns 'nomaster' | 'remote' | 'local' | similar. */
879
+ async getMastershipStatus(domain) {
880
+ const p = new XhtmlParser(await this.req('GET', `/rw/mastership/${this.rws2Domain(domain)}`));
881
+ const d = p.getState('msh-resource');
882
+ return { mastership: d['mastership'] ?? 'unknown', uid: d['uid'], application: d['application'] };
883
+ }
884
+ /** List all mastership domains the controller exposes (typically `['edit', 'motion']`). */
885
+ async listMastershipDomains() {
886
+ const p = new XhtmlParser(await this.req('GET', '/rw/mastership'));
887
+ return p.getAllStates('msh-resource-li').map(d => d['_title']).filter(Boolean);
888
+ }
889
+ // ─── Devices `/rw/devices` ──────────────────────────────────────────────────
890
+ /**
891
+ * List the top-level device groupings (typically HW_DEVICES, SW_RESOURCES).
892
+ * This is the entry point for the controller's hardware inventory tree.
893
+ * Drill into each group with `getDeviceTree(group)`.
894
+ */
895
+ async listSystemDevices() {
896
+ const p = new XhtmlParser(await this.req('GET', '/rw/devices'));
897
+ return p.getAllStates('dev-id-li').map(d => ({
898
+ id: d['_title'] ?? '',
899
+ name: d['name'] ?? '',
900
+ }));
901
+ }
902
+ /** Drill into a device group (e.g. 'HW_DEVICES'). Returns sub-tree as raw XHTML map. */
903
+ async getDeviceTree(group) {
904
+ return this.req('GET', `/rw/devices/${encodeURIComponent(group)}`);
905
+ }
906
+ /**
907
+ * List ALL configured I/O devices across every network in one call.
908
+ * (`listDevices(network)` is the per-network variant — both are fine; this
909
+ * one's handy when you want a flat overview without enumerating networks first.)
910
+ */
911
+ async listAllIoDevices() {
912
+ const p = new XhtmlParser(await this.req('GET', '/rw/iosystem/devices'));
913
+ return p.getAllStates('ios-device-li').map(d => {
914
+ const title = d['_title'] ?? '';
915
+ const network = title.split('/')[0] ?? '';
916
+ return {
917
+ name: d['name'] ?? '',
918
+ network,
919
+ lstate: d['lstate'] ?? '',
920
+ pstate: d['pstate'] ?? '',
921
+ address: d['address'] ?? '',
922
+ };
923
+ });
924
+ }
925
+ // ─── Forward Kinematics ─────────────────────────────────────────────────────
926
+ /**
927
+ * Forward kinematics: compute Cartesian pose from joint angles.
928
+ * Mirror of `calcJointsFromCartesian()` (which is inverse kinematics).
929
+ *
930
+ * Note: like IK, virtual controllers without the PC Interface (616-1) option
931
+ * generally reject this — the response comes back HTTP 200 but the body
932
+ * contains a retcode error link instead of the result. Real hardware with
933
+ * PC Interface licensed returns a valid pose.
934
+ */
935
+ async calcCartesianFromJoints(joints, mechunit = 'ROB_1', tool = 'tool0', wobj = 'wobj0') {
936
+ const body = new URLSearchParams({
937
+ curr_joints: `[${joints.rax_1},${joints.rax_2},${joints.rax_3},${joints.rax_4},${joints.rax_5},${joints.rax_6}]`,
938
+ curr_ext_joints: '[9E9,9E9,9E9,9E9,9E9,9E9]',
939
+ tool, wobj,
940
+ }).toString();
941
+ const xhtml = await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}?action=CalcRobTFromJoints`, undefined, body);
942
+ const p = new XhtmlParser(xhtml);
943
+ if (p.getError()) {
944
+ throw new Error(`FK rejected: ${p.getError()?.msg ?? 'unknown'} (likely missing PC Interface 616-1 license)`);
945
+ }
946
+ // RWS 2.0 sometimes returns HTTP 200 with the error embedded as
947
+ // `<a href="…/retcode?code=N" rel="error"/>` — no <span class="code"> block.
948
+ // Match either attribute order (href-first or rel-first).
949
+ const errLink = xhtml.match(/<a [^>]*retcode\?code=(-?\d+)[^>]*rel="error"|<a [^>]*rel="error"[^>]*retcode\?code=(-?\d+)/);
950
+ if (errLink) {
951
+ const code = errLink[1] ?? errLink[2];
952
+ throw new Error(`FK rejected: controller return code ${code} (likely missing PC Interface 616-1 license, or pose unreachable)`);
953
+ }
954
+ const d = p.getState('ms-robtarget') || p.getState('ms-cartesian');
955
+ const x = +d['x'];
956
+ if (Number.isNaN(x)) {
957
+ throw new Error(`FK returned no valid pose data (response had no <li class="ms-robtarget|ms-cartesian">; check controller logs)`);
958
+ }
959
+ return {
960
+ x, y: +d['y'], z: +d['z'],
961
+ q1: +d['q1'], q2: +d['q2'], q3: +d['q3'], q4: +d['q4'],
962
+ };
963
+ }
964
+ // ─── Vision `/rw/vision` ────────────────────────────────────────────────────
965
+ async listVisionSystems() {
966
+ try {
967
+ const p = new XhtmlParser(await this.req('GET', '/rw/vision'));
968
+ return p.getAllStates('vision-system-li').map(s => ({
969
+ name: s['_title'] ?? s['name'] ?? '',
970
+ status: s['status'],
971
+ }));
972
+ }
973
+ catch {
974
+ return [];
975
+ }
976
+ }
977
+ async getVisionSystemInfo(name) {
978
+ const p = new XhtmlParser(await this.req('GET', `/rw/vision/${encodeURIComponent(name)}`));
979
+ return p.getState('vision-system');
980
+ }
981
+ async listVisionJobs(system) {
982
+ const p = new XhtmlParser(await this.req('GET', `/rw/vision/${encodeURIComponent(system)}/jobs`));
983
+ return p.getAllStates('vision-job-li').map(j => ({
984
+ name: j['name'] ?? j['_title'] ?? '',
985
+ active: j['active'] === 'true',
986
+ }));
987
+ }
988
+ async triggerVisionJob(system, job) {
989
+ await this.req('POST', `/rw/vision/${encodeURIComponent(system)}/jobs/${encodeURIComponent(job)}/trigger`);
990
+ }
991
+ // ─── Safety controller `/ctrl/safety` ──────────────────────────────────────
992
+ async getSafetyStatus() {
993
+ try {
994
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/safety'));
995
+ const d = p.getState('ctrl-safety') || p.getState('ctrl-safety-info');
996
+ return { state: d['state'] ?? 'unknown', details: d };
997
+ }
998
+ catch {
999
+ return { state: 'unavailable' };
1000
+ }
1001
+ }
1002
+ async listSafetyZones() {
1003
+ try {
1004
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/safety/zones'));
1005
+ return p.getAllStates('ctrl-safety-zone-li');
1006
+ }
1007
+ catch {
1008
+ return [];
1009
+ }
1010
+ }
1011
+ async runCyclicBrakeCheck() {
1012
+ await this.req('POST', '/ctrl/safety/cyclic-brake-check');
1013
+ }
1014
+ // ─── Virtual time `/ctrl/virtualtime` ─────────────────────────────────────
1015
+ async getVirtualTime() {
1016
+ // Live-verified: /ctrl/virtualtime is a directory of 4 sub-resources (vttime, vtspeed, vtstate, vttimeslice).
1017
+ // Fetch each and assemble the result.
1018
+ // Live-verified field names (RobotWare 7.21):
1019
+ // /vttime → class="ctrl-vttime" → span "vtcounter" (microseconds since boot)
1020
+ // /vtstate → class="ctrl-vtstate" → span "vtcurrstate" ("running"/"stopped")
1021
+ // /vtspeed → class="ctrl-vtspeed" → span "vtcurrspeed" (1.0=real, 10=10x)
1022
+ const fetch = async (sub) => {
1023
+ try {
1024
+ const p = new XhtmlParser(await this.req('GET', `/ctrl/virtualtime/${sub}`));
1025
+ return p.getState(`ctrl-${sub}`) || {};
1026
+ }
1027
+ catch {
1028
+ return {};
1029
+ }
1030
+ };
1031
+ const [time, state, speed] = await Promise.all([
1032
+ fetch('vttime'),
1033
+ fetch('vtstate'),
1034
+ fetch('vtspeed'),
1035
+ ]);
1036
+ return {
1037
+ time: Number(time['vtcounter'] ?? time['time'] ?? 0),
1038
+ running: (state['vtcurrstate'] ?? state['state'] ?? '').toLowerCase() === 'running',
1039
+ speed: speed['vtcurrspeed'] !== undefined ? +speed['vtcurrspeed'] : undefined,
1040
+ };
1041
+ }
1042
+ async setVirtualTimeRunning(running) {
1043
+ await this.req('POST', '/ctrl/virtualtime/vtstate', { vtcurrstate: running ? 'running' : 'stopped' });
1044
+ }
1045
+ async setVirtualTimeScale(scale) {
1046
+ await this.req('POST', '/ctrl/virtualtime/vtspeed', { vtcurrspeed: String(scale) });
1047
+ }
1048
+ // ─── Certificate store `/ctrl/certstore` ──────────────────────────────────
1049
+ async listCertificates() {
1050
+ try {
1051
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/certstore'));
1052
+ return p.getAllStates('ctrl-cert-li').map(c => ({
1053
+ name: c['name'] ?? c['_title'] ?? '',
1054
+ subject: c['subject'],
1055
+ expires: c['expires'] ?? c['valid-to'],
1056
+ }));
1057
+ }
1058
+ catch {
1059
+ return [];
1060
+ }
1061
+ }
1062
+ async uploadCertificate(name, pem) {
1063
+ await this.req('POST', `/ctrl/certstore/${encodeURIComponent(name)}`, undefined, pem, 'application/x-pem-file');
1064
+ }
1065
+ async removeCertificate(name) {
1066
+ await this.req('DELETE', `/ctrl/certstore/${encodeURIComponent(name)}`);
1067
+ }
1068
+ // ─── Registry `/ctrl/registry` ────────────────────────────────────────────
1069
+ async getRegistry() {
1070
+ try {
1071
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/registry'));
1072
+ return p.getState('ctrl-registry');
1073
+ }
1074
+ catch {
1075
+ return {};
1076
+ }
1077
+ }
1078
+ // ─── Compress `/ctrl/compress` ────────────────────────────────────────────
1079
+ async compressPath(source, destination) {
1080
+ await this.req('POST', '/ctrl/compress', { source, destination });
1081
+ }
1082
+ // ─── File service — list volumes ──────────────────────────────────────────
1083
+ async listFileVolumes() {
1084
+ try {
1085
+ const p = new XhtmlParser(await this.req('GET', '/fileservice'));
1086
+ return p.getAllStates('fs-volume').map(v => v['_title'] ?? v['name']).filter(Boolean);
1087
+ }
1088
+ catch {
1089
+ // Fallback: known standard volumes
1090
+ return ['HOME', 'BACKUP', 'DATA', 'ADDINDATA', 'PRODUCTS', 'RAMDISK', 'TEMP'];
1091
+ }
1092
+ }
1093
+ // ─── PP control & RAPID debugger backbone ─────────────────────────────────
1094
+ async setProgramPointer(task, params) {
1095
+ const body = { routine: params.routine };
1096
+ if (params.module) {
1097
+ body['module'] = params.module;
1098
+ }
1099
+ if (params.row !== undefined) {
1100
+ body['begin-position-row'] = String(params.row);
1101
+ }
1102
+ if (params.col !== undefined) {
1103
+ body['begin-position-col'] = String(params.col);
1104
+ }
1105
+ await this.req('POST', `/rw/rapid/tasks/${task}/pcp/routine`, body);
1106
+ }
1107
+ async setPPToCursor(task, module, row, col) {
1108
+ await this.req('POST', `/rw/rapid/tasks/${task}/pcp/cursor`, {
1109
+ module,
1110
+ 'begin-position-row': String(row),
1111
+ 'begin-position-col': String(col),
1112
+ });
1113
+ }
1114
+ async stepRapid(task, mode) {
1115
+ const stepMode = mode === 'into' ? 'step-in' : mode === 'over' ? 'step-over' : 'step-out';
1116
+ await this.req('POST', `/rw/rapid/tasks/${task}/step`, { 'step-mode': stepMode });
1117
+ }
1118
+ async holdToRun(task, action) {
1119
+ await this.req('POST', `/rw/rapid/tasks/${task}/holdtorun`, { action });
1120
+ }
1121
+ async listBreakpoints(task) {
1122
+ try {
1123
+ // 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`));
1125
+ return p.getAllStates('rap-breakpoint-li').map(b => ({
1126
+ module: b['modulename'] ?? b['modulemame'] ?? b['module'] ?? '',
1127
+ row: +(b['begin-position-row'] ?? '0'),
1128
+ col: b['begin-position-col'] ? +b['begin-position-col'] : undefined,
1129
+ }));
1130
+ }
1131
+ catch {
1132
+ return [];
1133
+ }
1134
+ }
1135
+ async setBreakpoint(task, module, row, col) {
1136
+ const body = { module, 'begin-position-row': String(row) };
1137
+ if (col !== undefined) {
1138
+ body['begin-position-col'] = String(col);
1139
+ }
1140
+ await this.req('POST', `/rw/rapid/tasks/${task}/program/breakpoints`, body);
1141
+ }
1142
+ async removeBreakpoint(task, module, row, col) {
1143
+ const params = new URLSearchParams({ module, 'begin-position-row': String(row) });
1144
+ if (col !== undefined) {
1145
+ params.set('begin-position-col', String(col));
1146
+ }
1147
+ await this.req('DELETE', `/rw/rapid/tasks/${task}/breakpoint?${params.toString()}`);
1148
+ }
1149
+ // ─── Mechunit detailed endpoints ────────────────────────────────────────────
1150
+ async getMechunitBaseFrame(mechunit = 'ROB_1') {
1151
+ // Live-verified class: ms-mechunit-baseframe (not ms-baseframe)
1152
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/baseframe`));
1153
+ const d = p.getState('ms-mechunit-baseframe') || p.getState('ms-baseframe');
1154
+ return {
1155
+ x: +d['x'], y: +d['y'], z: +d['z'],
1156
+ q1: +d['q1'], q2: +d['q2'], q3: +d['q3'], q4: +d['q4'],
1157
+ };
1158
+ }
1159
+ async setMechunitBaseFrame(mechunit, frame) {
1160
+ await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}/baseframe`, {
1161
+ x: String(frame.x), y: String(frame.y), z: String(frame.z),
1162
+ q1: String(frame.q1), q2: String(frame.q2), q3: String(frame.q3), q4: String(frame.q4),
1163
+ });
1164
+ }
1165
+ async getMechunitAxes(mechunit = 'ROB_1') {
1166
+ // Live-verified: /axes returns a count + sub-resource links (axes/1..N).
1167
+ // Fetch each axis individually and assemble the result.
1168
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes`));
1169
+ const total = p.getState('ms-mechunit-axes');
1170
+ const axisCount = +(total['axes'] ?? 0);
1171
+ if (axisCount === 0) {
1172
+ return [];
1173
+ }
1174
+ const axes = [];
1175
+ for (let i = 1; i <= axisCount; i++) {
1176
+ try {
1177
+ const ap = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes/${i}`));
1178
+ const ad = ap.getState('ms-mechunit-axis') || ap.getState('ms-axis');
1179
+ axes.push({ axis: String(i), ...ad });
1180
+ }
1181
+ catch {
1182
+ axes.push({ axis: String(i), error: 'unreachable' });
1183
+ }
1184
+ }
1185
+ return axes;
1186
+ }
1187
+ async getMechunitPjoints(mechunit = 'ROB_1') {
1188
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/pjoints`));
1189
+ const d = p.getState('ms-pjoints');
1190
+ const out = {};
1191
+ for (const [k, v] of Object.entries(d)) {
1192
+ if (!k.startsWith('_')) {
1193
+ out[k] = +v;
1194
+ }
1195
+ }
1196
+ return out;
1197
+ }
1198
+ async getMechunitInfo(mechunit = 'ROB_1') {
1199
+ const p = new XhtmlParser(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
1200
+ return p.getState('ms-mechunit');
1201
+ }
1202
+ // ─── Module detailed endpoints ──────────────────────────────────────────────
1203
+ 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);
1208
+ }
1209
+ async getModuleInfo(task, moduleName) {
1210
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/modules/${encodeURIComponent(moduleName)}`));
1211
+ return p.getState('rap-module-info-li') || p.getState('rap-module-info');
1212
+ }
1213
+ async listModuleSymbols(task, moduleName) {
1214
+ const symbols = await this.searchRapidSymbols({ task, blockurl: `RAPID/${task}/${moduleName}`, recursive: false });
1215
+ return symbols.map(s => ({ name: s.name, type: s.symtyp, dattyp: s.dattyp }));
1216
+ }
1217
+ // ─── Per-task additional endpoints ──────────────────────────────────────────
1218
+ async getTaskStructuralChangeCount(task) {
1219
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/structural-changecount`));
1220
+ return Number(p.get('change-count') ?? p.get('structural-changecount') ?? 0);
1221
+ }
1222
+ async getTaskMotion(task) {
1223
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/motion`));
1224
+ return p.getState('rap-task-motion') || {};
1225
+ }
1226
+ async getTaskActivationRecord(task) {
1227
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/activation-record`));
1228
+ return p.getState('rap-activation-record') || {};
1229
+ }
1230
+ async getTaskProgramInfo(task) {
1231
+ // Endpoint returns 204 (no content) when no program is loaded — caller handles this.
1232
+ const xml = await this.req('GET', `/rw/rapid/tasks/${task}/program`);
1233
+ if (!xml) {
1234
+ return {};
1235
+ }
1236
+ return new XhtmlParser(xml).getState('rap-program-info') || {};
1237
+ }
1238
+ // ─── WebSocket subscriptions ──────────────────────────────────────────────────
1239
+ /**
1240
+ * Maps a SubscriptionResource to the RWS 2.0 path;stateParam string.
1241
+ * Semicolons must NOT be URL-encoded — the controller requires them literal.
1242
+ */
1243
+ static rws2ResourcePath(r) {
1244
+ if (typeof r === 'string') {
1245
+ const map = {
1246
+ controllerstate: '/rw/panel/ctrl-state;ctrlstate',
1247
+ operationmode: '/rw/panel/opmode;opmode',
1248
+ speedratio: '/rw/panel/speedratio;speedratio',
1249
+ execution: '/rw/rapid/execution;ctrlexecstate',
1250
+ coldetstate: '/rw/panel/coldetstate;coldetstate',
1251
+ uiinstr: '/rw/rapid/uiinstr;uievent',
1252
+ };
1253
+ return map[r] ?? null;
1254
+ }
1255
+ switch (r.type) {
1256
+ case 'execycle': return '/rw/rapid/execution;rapidexeccycle';
1257
+ case 'elog': return `/rw/elog/${r.domain}`;
1258
+ case 'signal': return `/rw/iosystem/signals/${r.name};lvalue`;
1259
+ case 'persvar': return `/rw/rapid/symbol/data/RAPID/${r.name};value`;
1260
+ case 'taskchange': return `/rw/rapid/tasks/${r.task};taskchange`;
1261
+ default: return null;
1262
+ }
1263
+ }
1264
+ /**
1265
+ * Map a resource URL path back to its friendly name for handleSubscriptionEvent.
1266
+ * Works with both /rw/panel/ctrlstate (RWS 1.0) and /rw/panel/ctrl-state (RWS 2.0).
1267
+ */
1268
+ static resourcePathToName(path) {
1269
+ if (/\/(ctrlstate|ctrl-state)/.test(path)) {
1270
+ return 'controllerstate';
1271
+ }
1272
+ if (/\/opmode/.test(path)) {
1273
+ return 'operationmode';
1274
+ }
1275
+ if (/\/speedratio/.test(path)) {
1276
+ return 'speedratio';
1277
+ }
1278
+ if (/\/execution/.test(path) && !/execycle/.test(path)) {
1279
+ return 'execution';
1280
+ }
1281
+ if (/\/coldetstate/.test(path)) {
1282
+ return 'coldetstate';
1283
+ }
1284
+ if (/\/elog\//.test(path)) {
1285
+ return 'elog';
1286
+ }
1287
+ return path; // fallback: keep full path
1288
+ }
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) => {
1305
+ const url = new URL('/subscription', this.baseUrl);
1306
+ const encoded = Buffer.from(bodyStr);
1307
+ const options = {
1308
+ method: 'POST',
1309
+ hostname: url.hostname,
1310
+ port: url.port ? +url.port : (this.isHttps ? 443 : 80),
1311
+ path: '/subscription',
1312
+ headers: {
1313
+ Authorization: this.authHeader,
1314
+ Accept: 'application/xhtml+xml;v=2.0',
1315
+ 'Content-Type': 'application/x-www-form-urlencoded;v=2.0',
1316
+ 'Content-Length': String(encoded.length),
1317
+ },
1318
+ ...(this.isHttps ? { agent: this.httpsAgent } : {}),
1319
+ };
1320
+ const transport = this.isHttps ? https : http;
1321
+ const req = transport.request(options, res => {
1322
+ const chunks = [];
1323
+ res.on('data', (c) => chunks.push(c));
1324
+ res.on('end', () => {
1325
+ if (res.statusCode !== 201) {
1326
+ reject(new Error(`RWS2 subscribe POST returned ${res.statusCode}`));
1327
+ return;
1328
+ }
1329
+ // Location header contains the WebSocket URL (wss://host/poll/{id})
1330
+ const location = (res.headers['location'] ?? '');
1331
+ let wsUrl;
1332
+ let deleteUrl;
1333
+ if (location.startsWith('wss://') || location.startsWith('ws://')) {
1334
+ wsUrl = location;
1335
+ deleteUrl = location.replace(/^wss:/, 'https:').replace(/^ws:/, 'http:');
1336
+ }
1337
+ else {
1338
+ // 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:');
1343
+ }
1344
+ // Capture session cookies — WebSocket needs Cookie header, NOT Authorization
1345
+ const setCookies = (res.headers['set-cookie'] ?? []);
1346
+ const cookieStr = setCookies.map((c) => c.split(';')[0]).join('; ');
1347
+ if (!wsUrl) {
1348
+ reject(new Error('RWS2 subscribe: no WebSocket URL'));
1349
+ return;
1350
+ }
1351
+ resolve({ wsUrl, deleteUrl, cookieStr });
1352
+ });
1353
+ });
1354
+ req.on('error', reject);
1355
+ req.write(encoded);
1356
+ req.end();
1357
+ });
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.)
1363
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1364
+ const wsMod = await import('ws');
1365
+ 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)
1382
+ // 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);
1389
+ ws.terminate();
1390
+ this.reqDelete(deleteUrl).catch(() => { });
1391
+ reject(new Error(`RWS2 WebSocket upgrade rejected (HTTP ${res.statusCode}): ${body}`));
1392
+ });
1393
+ });
1394
+ ws.on('error', (err) => {
1395
+ clearTimeout(timer);
1396
+ this.reqDelete(deleteUrl).catch(() => { });
1397
+ reject(err);
1398
+ });
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') {
1410
+ return;
1411
+ }
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;
1420
+ }
1421
+ handler({
1422
+ resource: RwsClient2.resourcePathToName(hrefM[1]),
1423
+ value: spanM[1].trim(),
1424
+ timestamp: new Date(),
1425
+ });
1426
+ }
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
1433
+ return async () => {
1434
+ clearInterval(pingTimer);
1435
+ ws.close();
1436
+ await this.reqDelete(deleteUrl).catch(() => { });
1437
+ };
1438
+ }
1439
+ // ─── Remote Mastership Privilege (RMMP) ────────────────────────────────────────
1440
+ // ABB safety: RWS users cannot send "modify" operations (jog, RAPID variable writes,
1441
+ // etc.) until they have RMMP. Requesting it triggers a FlexPendant popup that an
1442
+ // interactive operator must approve. After approval, the privilege persists for
1443
+ // the session.
1444
+ /**
1445
+ * Effective RMMP privilege held by THIS session.
1446
+ * The controller's /users/rmmp returns whoever currently holds the privilege —
1447
+ * we have to check `rmmpheldbyme` to know whether it's us or some other user.
1448
+ * Returns 'none' if another user holds it (we'd need to re-request for our own session).
1449
+ */
1450
+ async getRmmpPrivilege() {
1451
+ const xml = await this.req('GET', '/users/rmmp');
1452
+ const p = new XhtmlParser(xml);
1453
+ const priv = p.get('privilege') ?? 'none';
1454
+ const heldByMe = (p.get('rmmpheldbyme') ?? 'false').toLowerCase() === 'true';
1455
+ if (priv === 'none') {
1456
+ return 'none';
1457
+ }
1458
+ if (priv.startsWith('pending')) {
1459
+ return priv;
1460
+ }
1461
+ return heldByMe ? priv : 'none';
1462
+ }
1463
+ /** Request 'modify' privilege. Triggers a FlexPendant approval popup. */
1464
+ async requestRmmp(level = 'modify') {
1465
+ await this.req('POST', '/users/rmmp', { privilege: level });
1466
+ }
1467
+ // ─── Jogging ─────────────────────────────────────────────────────────────────
1468
+ /** Monotonic counter required by /rw/motionsystem/jog (controller rejects same value twice). */
1469
+ jogCcount = 0;
1470
+ async jog(params) {
1471
+ const { mode, axes, speed } = params;
1472
+ const mechunit = params.mechunit ?? 'ROB_1';
1473
+ this.jogCcount++;
1474
+ const body = [
1475
+ `jogmode=${mode}`,
1476
+ `mechunit=${mechunit}`,
1477
+ ...axes.map((v, i) => `axis${i + 1}=${v}`),
1478
+ `cjogspeed=${speed}`,
1479
+ `ccount=${this.jogCcount}`,
1480
+ ].join('&');
1481
+ await this.req('POST', '/rw/motionsystem/jog', undefined, body, 'application/x-www-form-urlencoded;v=2.0');
1482
+ }
1483
+ // ─── System detail endpoints ────────────────────────────────────────────────
1484
+ async getLicenseInfo() {
1485
+ const p = new XhtmlParser(await this.req('GET', '/rw/system/license'));
1486
+ return { entries: p.getAllStates('sys-license') };
1487
+ }
1488
+ async listProducts() {
1489
+ const p = new XhtmlParser(await this.req('GET', '/rw/system/products'));
1490
+ // Live-verified class: sys-product-li (with -li suffix). Each product has a _title
1491
+ // (the product name e.g. "RobotControl") plus version and version-name spans.
1492
+ return p.getAllStates('sys-product-li').map(p => ({
1493
+ name: p['_title'] ?? '',
1494
+ version: p['version'] ?? '',
1495
+ versionName: p['version-name'] ?? '',
1496
+ }));
1497
+ }
1498
+ async getRobotType() {
1499
+ const p = new XhtmlParser(await this.req('GET', '/rw/system/robottype'));
1500
+ const d = p.getState('sys-robottype');
1501
+ // Live-verified: span class is 'robot-type' (with hyphen), not 'robottype'
1502
+ return { type: d['robot-type'] ?? d['robottype'] ?? d['type'] ?? '', variant: d['variant'] };
1503
+ }
1504
+ async getEnergyStats() {
1505
+ const p = new XhtmlParser(await this.req('GET', '/rw/system/energy'));
1506
+ // Live-verified class: sys-energy-state (not sys-energy)
1507
+ return p.getState('sys-energy-state');
1508
+ }
1509
+ // ─── Return-code lookup ─────────────────────────────────────────────────────
1510
+ async getReturnCode(code, lang = 'en') {
1511
+ try {
1512
+ const p = new XhtmlParser(await this.req('GET', `/rw/retcode?code=${code}&lang=${lang}`));
1513
+ const d = p.getState('rw-retcode') || p.getState('rw-retcode-li');
1514
+ if (!d['title'] && !d['desc']) {
1515
+ return null;
1516
+ }
1517
+ return { code, title: d['title'] ?? '', desc: d['desc'] ?? '' };
1518
+ }
1519
+ catch {
1520
+ return null;
1521
+ }
1522
+ }
1523
+ // ─── Controller detail endpoints ────────────────────────────────────────────
1524
+ async listControllerOptions() {
1525
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/options'));
1526
+ return p.getAllStates('ctrl-option').map(o => ({
1527
+ name: o['option'] ?? o['name'] ?? '',
1528
+ description: o['description'],
1529
+ }));
1530
+ }
1531
+ async listFeatures() {
1532
+ const p = new XhtmlParser(await this.req('GET', '/ctrl/features'));
1533
+ return p.getAllStates('ctrl-feature');
1534
+ }
1535
+ // ─── Motion detail endpoints ────────────────────────────────────────────────
1536
+ async getMotionChangeCount() {
1537
+ const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem?resource=change-count'));
1538
+ return Number(p.get('change-count') ?? 0);
1539
+ }
1540
+ async getMotionErrorState() {
1541
+ const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/errorstate'));
1542
+ const d = p.getState('ms-errorstate-li') || p.getState('ms-errorstate');
1543
+ return { state: d['err-state'] ?? d['state'] ?? 'unknown', details: d };
1544
+ }
1545
+ async getNonMotionExecution() {
1546
+ // Live-verified: class="ms-nonmotionexecution", span "mode" returns quoted "OFF" or "ON".
1547
+ const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/nonmotionexecution'));
1548
+ const v = (p.get('mode') ?? p.get('state') ?? 'OFF').replace(/"/g, '').toUpperCase();
1549
+ return v === 'ON';
1550
+ }
1551
+ async setNonMotionExecution(enabled) {
1552
+ await this.req('POST', '/rw/motionsystem/nonmotionexecution', { mode: enabled ? 'ON' : 'OFF' });
1553
+ }
1554
+ async getCollisionPredictionMode() {
1555
+ // Live-verified: class="ms-collision-prediction-mode" with span "collision-prediction-mode-enabled"
1556
+ // returning "true" / "false". Map back to ON/OFF for caller convenience.
1557
+ const p = new XhtmlParser(await this.req('GET', '/rw/motionsystem/collisionprediction'));
1558
+ const enabled = p.get('collision-prediction-mode-enabled') ?? p.get('mode') ?? 'false';
1559
+ return enabled.toLowerCase() === 'true' ? 'ON' : 'OFF';
1560
+ }
1561
+ async setCollisionPredictionMode(mode) {
1562
+ await this.req('POST', '/rw/motionsystem/collisionprediction', { mode });
1563
+ }
1564
+ // ─── Panel detail endpoints ─────────────────────────────────────────────────
1565
+ async getEnableRequest() {
1566
+ const p = new XhtmlParser(await this.req('GET', '/rw/panel/enreq'));
1567
+ const d = p.getState('pnl-enreq') || p.getState('pnl-enreq-li');
1568
+ return { state: d['state'] ?? d['enreq'] ?? 'unknown', raw: d };
1569
+ }
1570
+ // ─── RAPID detail endpoints ─────────────────────────────────────────────────
1571
+ async listAliasIO() {
1572
+ const p = new XhtmlParser(await this.req('GET', '/rw/rapid/aliasio'));
1573
+ return p.getAllStates('rap-aliasio-li').map(a => ({
1574
+ alias: a['name'] ?? a['alias'] ?? '',
1575
+ signal: a['signal'] ?? a['_title'] ?? '',
1576
+ }));
1577
+ }
1578
+ async getTaskSelection() {
1579
+ const p = new XhtmlParser(await this.req('GET', '/rw/rapid/taskselection'));
1580
+ const sel = p.getAllStates('rap-taskselection-li').map(t => t['name']).filter(Boolean);
1581
+ const all = p.getAllStates('rap-task-li').map(t => t['name']).filter(Boolean);
1582
+ return { selected: sel, available: all };
1583
+ }
1584
+ async setTaskSelection(tasks) {
1585
+ const body = tasks.map((t, i) => `task-${i + 1}=${encodeURIComponent(t)}`).join('&');
1586
+ await this.req('POST', '/rw/rapid/taskselection', undefined, body, 'application/x-www-form-urlencoded;v=2.0');
1587
+ }
1588
+ async getProgramPointer(task) {
1589
+ // Live-verified: class="pcp-info" with spans:
1590
+ // modulemame (sic — controller typo for modulename)
1591
+ // routinename
1592
+ // beginposition → "row,col" combined string
1593
+ // endposition → "row,col"
1594
+ // changecount, executiontype
1595
+ const p = new XhtmlParser(await this.req('GET', `/rw/rapid/tasks/${task}/pcp`));
1596
+ const d = p.getState('pcp-info') || p.getState('program-pointer-state') || p.getState('rap-pcp-li');
1597
+ const begin = (d['beginposition'] ?? '').split(',');
1598
+ return {
1599
+ module: d['modulename'] ?? d['modulemame'] ?? d['module'],
1600
+ routine: d['routinename'] ?? d['routine'],
1601
+ row: begin[0] ? +begin[0] : (d['begin-position-row'] ? +d['begin-position-row'] : undefined),
1602
+ col: begin[1] ? +begin[1] : (d['begin-position-col'] ? +d['begin-position-col'] : undefined),
1603
+ executionType: d['executiontype'],
1604
+ };
1605
+ }
1606
+ async getMotionPointer(task) {
1607
+ // Live-verified: /syncstate/motion-pointer returns class="rap-task-sync-state"
1608
+ // 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`));
1610
+ const d = p.getState('rap-task-sync-state');
1611
+ const stateVal = d['motion-pointer-state'] ?? '';
1612
+ return {
1613
+ module: d['modulename'] ?? d['modulemame'] ?? d['module'],
1614
+ routine: d['routinename'] ?? d['routine'],
1615
+ row: d['begin-position-row'] ? +d['begin-position-row'] : undefined,
1616
+ col: d['begin-position-col'] ? +d['begin-position-col'] : undefined,
1617
+ state: stateVal,
1618
+ };
1619
+ }
1620
+ // ─── Inverse kinematics ───────────────────────────────────────────────────────
1621
+ async calcJointsFromCartesian(pos, seedJoints, mechunit = 'ROB_1') {
1622
+ const seed = seedJoints
1623
+ ? `[${seedJoints.rax_1},${seedJoints.rax_2},${seedJoints.rax_3},${seedJoints.rax_4},${seedJoints.rax_5},${seedJoints.rax_6}]`
1624
+ : '[0,0,0,0,0,0]';
1625
+ const bodyStr = [
1626
+ `curr_position=[${pos.x},${pos.y},${pos.z}]`,
1627
+ `curr_orientation=[${pos.q1},${pos.q2},${pos.q3},${pos.q4}]`,
1628
+ `curr_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
1629
+ `old_rob_joints=${seed}`,
1630
+ `old_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
1631
+ `robot_fixed_object=false`,
1632
+ `tool_frame_position=[0,0,0]`,
1633
+ `tool_frame_orientation=[1,0,0,0]`,
1634
+ `wobj_frame_position=[0,0,0]`,
1635
+ `wobj_frame_orientation=[1,0,0,0]`,
1636
+ `robot_configuration=[0,0,0,0]`,
1637
+ `elog_at_error=false`,
1638
+ ].join('&');
1639
+ 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);
1641
+ const d = p.getState('ms-jointtarget');
1642
+ if (!d['rax_1']) {
1643
+ throw new Error('IK: no joint values in response');
1644
+ }
1645
+ return {
1646
+ rax_1: +d['rax_1'], rax_2: +d['rax_2'], rax_3: +d['rax_3'],
1647
+ rax_4: +d['rax_4'], rax_5: +d['rax_5'], rax_6: +d['rax_6'],
1648
+ };
1649
+ }
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
+ }
1672
+ //# sourceMappingURL=RwsClient2.js.map