abb-rws-client 0.7.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +17 -8
  3. package/dist/HalJsonParser.d.ts +55 -0
  4. package/dist/HalJsonParser.d.ts.map +1 -0
  5. package/dist/HalJsonParser.js +155 -0
  6. package/dist/HalJsonParser.js.map +1 -0
  7. package/dist/HttpSession.d.ts.map +1 -1
  8. package/dist/HttpSession.js +13 -2
  9. package/dist/HttpSession.js.map +1 -1
  10. package/dist/IRWSAdapter.d.ts +7 -1
  11. package/dist/IRWSAdapter.d.ts.map +1 -1
  12. package/dist/MdnsDiscovery.d.ts +57 -0
  13. package/dist/MdnsDiscovery.d.ts.map +1 -0
  14. package/dist/MdnsDiscovery.js +313 -0
  15. package/dist/MdnsDiscovery.js.map +1 -0
  16. package/dist/MultiRobotManager.d.ts +5 -2
  17. package/dist/MultiRobotManager.d.ts.map +1 -1
  18. package/dist/MultiRobotManager.js +8 -3
  19. package/dist/MultiRobotManager.js.map +1 -1
  20. package/dist/RWS1Adapter.d.ts +60 -2
  21. package/dist/RWS1Adapter.d.ts.map +1 -1
  22. package/dist/RWS1Adapter.js +152 -4
  23. package/dist/RWS1Adapter.js.map +1 -1
  24. package/dist/ResourceMapper.d.ts +4 -0
  25. package/dist/ResourceMapper.d.ts.map +1 -1
  26. package/dist/ResourceMapper.js +9 -1
  27. package/dist/ResourceMapper.js.map +1 -1
  28. package/dist/RobotManager.d.ts +72 -12
  29. package/dist/RobotManager.d.ts.map +1 -1
  30. package/dist/RobotManager.js +255 -50
  31. package/dist/RobotManager.js.map +1 -1
  32. package/dist/RwsClient2.d.ts +150 -10
  33. package/dist/RwsClient2.d.ts.map +1 -1
  34. package/dist/RwsClient2.js +599 -236
  35. package/dist/RwsClient2.js.map +1 -1
  36. package/dist/WsSubscriber.d.ts +31 -5
  37. package/dist/WsSubscriber.d.ts.map +1 -1
  38. package/dist/WsSubscriber.js +104 -50
  39. package/dist/WsSubscriber.js.map +1 -1
  40. package/dist/detect.d.ts +12 -3
  41. package/dist/detect.d.ts.map +1 -1
  42. package/dist/detect.js +69 -25
  43. package/dist/detect.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/types.js +3 -3
  49. package/dist/types.js.map +1 -1
  50. package/examples/05-remote-control-rmmp.mjs +10 -12
  51. package/examples/06-pull-module-source.mjs +13 -20
  52. package/package.json +62 -60
  53. package/src/HalJsonParser.ts +137 -0
  54. package/src/HttpSession.ts +460 -0
  55. package/src/IRWSAdapter.ts +422 -0
  56. package/src/Logger.ts +54 -0
  57. package/src/MdnsDiscovery.ts +336 -0
  58. package/src/MultiRobotManager.ts +159 -0
  59. package/src/RWS1Adapter.ts +1018 -0
  60. package/src/RWS2Adapter.ts +19 -0
  61. package/src/ResourceMapper.ts +517 -0
  62. package/src/ResponseParser.ts +710 -0
  63. package/src/RobotManager.ts +1705 -0
  64. package/src/RwsClient.ts +1150 -0
  65. package/src/RwsClient2.ts +2214 -0
  66. package/src/WsSubscriber.ts +350 -0
  67. package/src/XhtmlParser.ts +53 -0
  68. package/src/detect.ts +261 -0
  69. package/src/index.ts +83 -0
  70. package/src/types.ts +336 -0
@@ -0,0 +1,2214 @@
1
+ import * as https from 'https';
2
+ import * as http from 'http';
3
+ import { XhtmlParser } from './XhtmlParser.js';
4
+ import { HalJsonParser } from './HalJsonParser.js';
5
+ import { Logger } from './Logger.js';
6
+ import { RwsError, type RwsErrorCode } from './types.js';
7
+ import type {
8
+ ControllerState, OperationMode, ExecutionState, ExecutionCycle,
9
+ ExecutionInfo, CollisionDetectionState, RapidTask, JointTarget,
10
+ CartesianFull, RobTarget, SystemInfo, ControllerIdentity, ControllerClock,
11
+ ElogMessage, Signal, IoNetwork, IoDevice, FileEntry,
12
+ RapidSymbolProperties, RapidSymbolInfo, RapidSymbolSearchParams,
13
+ UiInstruction, RestartMode, MastershipDomain,
14
+ SubscriptionResource, SubscriptionEvent,
15
+ } from './types.js';
16
+
17
+ /**
18
+ * RWS 2.0 protocol client for ABB OmniCore controllers (RobotWare 7.x).
19
+ *
20
+ * Companion to `RwsClient` (RWS 1.0 / IRC5 / RobotWare 6.x). If you don't know
21
+ * which protocol your controller uses, prefer `createClient(host)` from this
22
+ * package — it probes the auth challenge and returns the right client.
23
+ *
24
+ * Key differences vs RWS 1.0 (all confirmed by live virtual-controller probing):
25
+ * - HTTP Basic auth instead of Digest
26
+ * - Path-based actions: /rw/rapid/execution/stop (not ?action=stop)
27
+ * - GETs are negotiated as HAL JSON (Accept: application/hal+json;v=2.0 —
28
+ * live-verified 2026-07-09 on RW7.21 for every GET family) with an automatic
29
+ * per-instance fallback to application/xhtml+xml;v=2.0 for older RW7
30
+ * releases; form-POST responses and subscription events are XHTML-only
31
+ * - Mastership domains: 'edit' replaces both 'cfg' and 'rapid'
32
+ * - FileService home: 'HOME' not '$HOME'
33
+ * - Self-signed TLS on all shipping controllers → verification is OFF by default;
34
+ * pass `{ rejectUnauthorized: true }` to keep it on (e.g. controllers with a
35
+ * properly installed certificate).
36
+ */
37
+ export class RwsClient2 {
38
+ private lastReqTime = 0;
39
+ private static readonly MIN_MS = 55;
40
+ private readonly authHeader: string;
41
+ private readonly httpsAgent: https.Agent;
42
+ private readonly httpAgent: http.Agent;
43
+ private readonly isHttps: boolean;
44
+ /** Per-request timeout in ms (constructor `opts.timeout`, default 10000). */
45
+ private readonly timeoutMs: number;
46
+ /** When true, TLS certificate verification stays ON everywhere (requests, subscription POST, WebSocket). */
47
+ private readonly rejectUnauthorized: boolean;
48
+
49
+ /** Session cookie set by the controller on first auth — REQUIRED to avoid creating
50
+ * a new session per request (controller's session pool fills in seconds otherwise). */
51
+ private sessionCookie: string | null = null;
52
+
53
+ /** Signal name → {network, device} — populated by listAllSignals for writeSignal lookups */
54
+ private readonly sigCoords = new Map<string, { n: string; d: string }>();
55
+
56
+ constructor(
57
+ private readonly baseUrl: string,
58
+ username: string,
59
+ password: string,
60
+ opts: { timeout?: number; rejectUnauthorized?: boolean } = {},
61
+ ) {
62
+ this.authHeader = 'Basic ' + Buffer.from(`${username}:${password}`).toString('base64');
63
+ this.isHttps = baseUrl.startsWith('https');
64
+ this.timeoutMs = opts.timeout ?? 10000;
65
+ this.rejectUnauthorized = opts.rejectUnauthorized ?? false;
66
+ // keepAlive reuses the TCP connection so we don't churn sessions on every poll.
67
+ this.httpsAgent = new https.Agent({
68
+ keepAlive: true,
69
+ ...(this.rejectUnauthorized ? {} : { rejectUnauthorized: false }),
70
+ });
71
+ this.httpAgent = new http.Agent({ keepAlive: true });
72
+ }
73
+
74
+ // ─── HTTP transport ────────────────────────────────────────────────────────
75
+
76
+ /** Primary GET representation. Officially supported on RWS 2.0; live-verified
77
+ * 2026-07-09 on OmniCore VC RW7.21 for every GET endpoint family in this client. */
78
+ private static readonly ACCEPT_HAL = 'application/hal+json;v=2.0';
79
+ /** Representation for writes, fileservice, subscriptions, and the fallback GET path. */
80
+ private static readonly ACCEPT_XHTML = 'application/xhtml+xml;v=2.0';
81
+
82
+ /** Set once a controller rejects hal+json (HTTP 406 or a non-JSON reply to a
83
+ * hal+json GET) — older RW7 releases predate HAL JSON. All subsequent GETs on
84
+ * this instance then go straight to XHTML instead of re-negotiating each time. */
85
+ private preferXhtml = false;
86
+
87
+ /** GET paths that must keep the XHTML Accept: fileservice serves raw file bytes
88
+ * (a content-type-based negotiation retry would double every file read, and the
89
+ * service rejects some Accept values), and /logout's body is ignored anyway. */
90
+ private static isXhtmlOnlyPath(path: string): boolean {
91
+ return path.startsWith('/fileservice') || path === '/logout';
92
+ }
93
+
94
+ /** Picks the parser for a response body: HAL JSON (primary GET representation)
95
+ * or XHTML (fallback GETs, form-POST responses). Both expose the same reads. */
96
+ private static parse(body: string): XhtmlParser | HalJsonParser {
97
+ return HalJsonParser.looksLikeJson(body) ? new HalJsonParser(body) : new XhtmlParser(body);
98
+ }
99
+
100
+ /** Error block from either representation (JSON status.code/msg or XHTML spans). */
101
+ private static extractError(body: string): { code: string; msg: string } | null {
102
+ return RwsClient2.parse(body).getError();
103
+ }
104
+
105
+ /**
106
+ * Core HTTP request. acceptExtra lists additional success status codes beyond 200/204.
107
+ * Used by subscribe() to accept HTTP 201 (Created) from POST /subscription.
108
+ * acceptOverride pins the Accept header for callers that must not negotiate
109
+ * (e.g. getDeviceTree, which promises a raw XHTML document).
110
+ */
111
+ private async req(
112
+ method: string,
113
+ path: string,
114
+ body?: Record<string, string>,
115
+ rawBody?: string,
116
+ rawContentType?: string,
117
+ acceptExtra: number[] = [],
118
+ acceptOverride?: string,
119
+ ): Promise<string> {
120
+ const wait = RwsClient2.MIN_MS - (Date.now() - this.lastReqTime);
121
+ if (wait > 0) { await new Promise(r => setTimeout(r, wait)); }
122
+ this.lastReqTime = Date.now();
123
+
124
+ const url = new URL(path, this.baseUrl);
125
+ const bodyStr = rawBody ?? (body ? new URLSearchParams(body).toString() : undefined);
126
+
127
+ // RWS 2.0 requires Content-Type on all POST/PUT/DELETE requests, even with no body
128
+ // (mastership and a few other endpoints return HTTP 406 without it).
129
+ const writingMethod = method === 'POST' || method === 'PUT' || method === 'DELETE';
130
+ // GETs negotiate HAL JSON; writes stay XHTML (form-POST responses are XHTML-only).
131
+ const wantsHal = method === 'GET' && !this.preferXhtml
132
+ && !acceptOverride && !RwsClient2.isXhtmlOnlyPath(path);
133
+ const accept = acceptOverride
134
+ ?? (wantsHal ? RwsClient2.ACCEPT_HAL : RwsClient2.ACCEPT_XHTML);
135
+ const options: http.RequestOptions & { agent?: https.Agent | http.Agent; rejectUnauthorized?: boolean } = {
136
+ method,
137
+ hostname: url.hostname,
138
+ port: url.port ? +url.port : (this.isHttps ? 443 : 80),
139
+ path: url.pathname + url.search,
140
+ headers: {
141
+ Authorization: this.authHeader,
142
+ Accept: accept,
143
+ ...(this.sessionCookie ? { Cookie: this.sessionCookie } : {}),
144
+ ...(writingMethod ? {
145
+ 'Content-Type': rawContentType ?? 'application/x-www-form-urlencoded;v=2.0',
146
+ 'Content-Length': String(bodyStr ? Buffer.byteLength(bodyStr) : 0),
147
+ } : {}),
148
+ },
149
+ agent: this.isHttps ? this.httpsAgent : this.httpAgent,
150
+ // Must ALSO be set per-request, not only on the agent: hosts that replace the
151
+ // agent (VS Code's extension host patches http/https and swaps custom agents for
152
+ // non-localhost targets) would otherwise re-enable TLS verification and fail on
153
+ // the self-signed certs ABB controllers ship. Live-reported on a real OmniCore RC
154
+ // (abb-rws-vscode issue #2, 2026-05-18); localhost VCs never hit this because the
155
+ // extension host doesn't intercept localhost traffic.
156
+ ...(this.isHttps && !this.rejectUnauthorized ? { rejectUnauthorized: false } : {}),
157
+ };
158
+
159
+ const startedAt = Date.now();
160
+ Logger.trace?.('http.req', `RWS2 ${method} ${path}`, {
161
+ protocol: 'rws2', method, path,
162
+ bodyPreview: bodyStr ? bodyStr.slice(0, 200) : undefined,
163
+ });
164
+
165
+ return new Promise((resolve, reject) => {
166
+ const transport = this.isHttps ? https : http;
167
+ const req = (transport as typeof https).request(options as https.RequestOptions, res => {
168
+ // Capture session cookie on first response (controller assigns it on first auth).
169
+ // Without this we leak one session per request → controller pool fills in seconds.
170
+ const setCookies = res.headers['set-cookie'];
171
+ if (setCookies && setCookies.length > 0 && !this.sessionCookie) {
172
+ this.sessionCookie = setCookies.map(c => c.split(';')[0]).join('; ');
173
+ }
174
+
175
+ const chunks: Buffer[] = [];
176
+ res.on('data', (c: Buffer) => chunks.push(c));
177
+ res.on('end', () => {
178
+ const raw = Buffer.concat(chunks).toString('utf8');
179
+ const durationMs = Date.now() - startedAt;
180
+ const status = res.statusCode ?? 0;
181
+ if (status === 204) {
182
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → 204`, { protocol: 'rws2', method, path, status, durationMs });
183
+ resolve(''); return;
184
+ }
185
+ // HAL JSON negotiation fallback: a controller predating hal+json either
186
+ // rejects the Accept outright (406) or ignores it and answers XHTML.
187
+ // Retry this one request as XHTML and remember the preference so every
188
+ // later GET on this instance skips the failed negotiation.
189
+ if (wantsHal) {
190
+ const contentType = String(res.headers['content-type'] ?? '');
191
+ if (status === 406 || (status < 400 && !/json/i.test(contentType))) {
192
+ this.preferXhtml = true;
193
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status} (hal+json not served — falling back to XHTML for this client)`, {
194
+ protocol: 'rws2', method, path, status, durationMs, contentType,
195
+ });
196
+ resolve(this.req(method, path, body, rawBody, rawContentType, acceptExtra));
197
+ return;
198
+ }
199
+ }
200
+ if (acceptExtra.includes(status)) {
201
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status}`, { protocol: 'rws2', method, path, status, durationMs, bodyPreview: raw.slice(0, 200) });
202
+ resolve(raw); return;
203
+ }
204
+ if (status >= 400) {
205
+ const err = RwsClient2.extractError(raw);
206
+ 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) });
207
+ const code: RwsErrorCode =
208
+ status === 401 ? 'AUTH_FAILED' :
209
+ status === 503 ? 'CONTROLLER_BUSY' :
210
+ status === 429 ? 'RATE_LIMITED' : 'UNKNOWN';
211
+ reject(new RwsError(
212
+ `RWS2 ${method} ${path}: HTTP ${status}` +
213
+ (err ? ` — ${err.msg}` : ''),
214
+ code, status, err?.msg
215
+ ));
216
+ return;
217
+ }
218
+ Logger.trace?.('http.res', `RWS2 ${method} ${path} → ${status} (${raw.length}b)`, { protocol: 'rws2', method, path, status, durationMs, bodyLen: raw.length });
219
+ resolve(raw);
220
+ });
221
+ });
222
+ req.on('error', (e) => {
223
+ Logger.trace?.('http.err', `RWS2 ${method} ${path} → network error`, { protocol: 'rws2', method, path, error: String(e), durationMs: Date.now() - startedAt });
224
+ reject(new RwsError(e instanceof Error ? e.message : String(e), 'NETWORK_ERROR'));
225
+ });
226
+ req.setTimeout(this.timeoutMs, () => {
227
+ req.destroy();
228
+ Logger.trace?.('http.err', `RWS2 ${method} ${path} → timeout`, { protocol: 'rws2', method, path, durationMs: Date.now() - startedAt });
229
+ reject(new RwsError(`RWS2 timeout: ${path}`, 'NETWORK_ERROR'));
230
+ });
231
+ if (bodyStr) { req.write(bodyStr); }
232
+ req.end();
233
+ });
234
+ }
235
+
236
+ // ─── Connection ────────────────────────────────────────────────────────────
237
+
238
+ async connect(): Promise<void> { await this.req('GET', '/rw/system'); }
239
+
240
+ async disconnect(): Promise<void> {
241
+ // /logout invalidates the session server-side (frees the slot in the controller's pool).
242
+ await this.req('GET', '/logout').catch(() => {});
243
+ this.sessionCookie = null;
244
+ // Drop pooled keep-alive sockets so the next connect() starts clean.
245
+ this.httpsAgent.destroy();
246
+ this.httpAgent.destroy();
247
+ }
248
+
249
+ getSessionCookie(): string | null { return this.sessionCookie; }
250
+
251
+ // ─── Panel ─────────────────────────────────────────────────────────────────
252
+
253
+ async getControllerState(): Promise<ControllerState> {
254
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/ctrl-state'));
255
+ return (p.getState('pnl-ctrlstate')['ctrlstate'] ?? 'init') as ControllerState;
256
+ }
257
+
258
+ setControllerState(state: 'motoron' | 'motoroff'): Promise<void> {
259
+ return this.req('POST', '/rw/panel/ctrl-state', { 'ctrl-state': state }).then(() => {});
260
+ }
261
+
262
+ async getOperationMode(): Promise<OperationMode> {
263
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/opmode'));
264
+ return (p.getState('pnl-opmode')['opmode'] ?? 'MANR') as OperationMode;
265
+ }
266
+
267
+ async getSpeedRatio(): Promise<number> {
268
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/speedratio'));
269
+ return Number(p.getState('pnl-speedratio')['speedratio'] ?? 100);
270
+ }
271
+
272
+ /**
273
+ * Set the speed ratio (0-100). Live-verified format on OmniCore VC RW7.21
274
+ * via scripts/probe-speedratio.js (2026-05-07):
275
+ * ✓ POST /rw/panel/speedratio?action=setspeedratio body speed-ratio=N
276
+ * (RWS 1.0 wire format — OmniCore kept the legacy path)
277
+ * Requires `edit` mastership: 403 "user does not have required
278
+ * mastership" without it.
279
+ * ✗ POST /rw/panel/speedratio body speedratio=N → 400 "Invalid input form data"
280
+ * ✗ POST /rw/panel/speedratio/set → 404 (path doesn't exist)
281
+ *
282
+ * Acquires `edit` mastership internally and releases it after.
283
+ */
284
+ async setSpeedRatio(ratio: number): Promise<void> {
285
+ const v = Math.round(Math.max(0, Math.min(100, ratio)));
286
+ await this.requestMastership('rapid'); // 'rapid' is renamed to 'edit' internally
287
+ try {
288
+ await this.req('POST', '/rw/panel/speedratio?action=setspeedratio', { 'speed-ratio': String(v) });
289
+ } finally {
290
+ await this.releaseMastership('rapid').catch(() => {});
291
+ }
292
+ }
293
+
294
+ async getCollisionDetectionState(): Promise<CollisionDetectionState> {
295
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/coldetstate'));
296
+ return (p.getState('pnl-coldetstate')['coldetstate'] ?? 'INIT') as CollisionDetectionState;
297
+ }
298
+
299
+ lockOperationMode(pin: string, permanent = false): Promise<void> {
300
+ // POST /rw/panel/opmode/lock with pin and permanent flag
301
+ return this.req('POST', '/rw/panel/opmode/lock', {
302
+ pin,
303
+ permanent: permanent ? '1' : '0',
304
+ }).then(() => {});
305
+ }
306
+
307
+ unlockOperationMode(): Promise<void> {
308
+ return this.req('POST', '/rw/panel/opmode/unlock').then(() => {});
309
+ }
310
+
311
+ /**
312
+ * Switch the controller's operation mode. **Virtual controllers only** —
313
+ * real hardware respects the FlexPendant key switch.
314
+ *
315
+ * Endpoint + wire format — ALL live-verified on OmniCore VC RW7.x via
316
+ * scripts/probe-opmode-write.js (2026-05-07):
317
+ * ✓ POST /rw/panel/opmode body opmode=auto → AUTO (200 OK)
318
+ * ✓ POST /rw/panel/opmode body opmode=man → MANR (200 OK)
319
+ * ✓ POST /rw/panel/opmode body opmode=manf → MANF (200 OK) — NOTE: `manf`,
320
+ * NOT `manfs` as RWS 1.0 uses. RWS 2.0 dropped the 's'.
321
+ * ✗ POST /rw/panel/opmode/set → 404 (path doesn't exist)
322
+ * ✗ POST /rw/panel/opmode body opmode=AUTO → 400 invalid value
323
+ * ✗ POST /rw/panel/opmode body opmode=manr → 400 invalid value
324
+ *
325
+ * The wire value is lowercase and uses the RWS 1.0 abbreviations *except*
326
+ * for MANF (`manf` on RWS 2.0 vs `manfs` on RWS 1.0). And NEITHER matches
327
+ * the GET-response casing (`AUTO`/`MANR`/`MANF`). This asymmetry is one
328
+ * of the documented protocol quirks of RWS 2.0.
329
+ *
330
+ * Side note: the controller pops up a confirmation dialog on the FlexPendant
331
+ * after the call returns 200 OK; the operator must approve before the mode
332
+ * actually flips. There is no API path to bypass this — UAS-grant changes
333
+ * are FlexPendant-only by design.
334
+ */
335
+ setOperationMode(mode: 'AUTO' | 'MANR' | 'MANF'): Promise<void> {
336
+ const wire = mode === 'AUTO' ? 'auto' : mode === 'MANR' ? 'man' : 'manf';
337
+ return this.req('POST', '/rw/panel/opmode', { opmode: wire }).then(() => {});
338
+ }
339
+
340
+ // ─── RAPID execution ────────────────────────────────────────────────────────
341
+
342
+ async getRapidExecutionState(): Promise<ExecutionState> {
343
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/execution'));
344
+ return (p.getState('rap-execution')['ctrlexecstate'] ?? 'stopped') as ExecutionState;
345
+ }
346
+
347
+ async getRapidExecutionInfo(): Promise<ExecutionInfo> {
348
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/execution'));
349
+ // Live: <li class="rap-execution"><span class="ctrlexecstate">stopped</span><span class="cycle">forever</span>
350
+ const d = p.getState('rap-execution');
351
+ return {
352
+ state: (d['ctrlexecstate'] ?? 'stopped') as ExecutionState,
353
+ cycle: d['cycle'] ?? 'asis',
354
+ };
355
+ }
356
+
357
+ startRapid(): Promise<void> {
358
+ return this.req('POST', '/rw/rapid/execution/start', {
359
+ regain: 'continue', execmode: 'continue', cycle: 'asis',
360
+ condition: 'none', stopatbp: 'disabled', alltaskbytsp: 'false',
361
+ }).then(() => {});
362
+ }
363
+
364
+ stopRapid(): Promise<void> {
365
+ return this.req('POST', '/rw/rapid/execution/stop', { stopmode: 'stop' }).then(() => {});
366
+ }
367
+
368
+ resetRapid(): Promise<void> {
369
+ return this.req('POST', '/rw/rapid/execution/resetpp').then(() => {});
370
+ }
371
+
372
+ setExecutionCycle(cycle: ExecutionCycle): Promise<void> {
373
+ return this.req('POST', '/rw/rapid/execution/cycle', { cycle }).then(() => {});
374
+ }
375
+
376
+ async getRapidTasks(): Promise<RapidTask[]> {
377
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/tasks'));
378
+ return p.getAllStates('rap-task-li').map(t => ({
379
+ name: t['name'] ?? '',
380
+ type: t['type'] ?? 'normal',
381
+ taskstate: t['taskstate'] ?? '',
382
+ excstate: (t['excstate'] === 'running' ? 'running' : 'stopped') as ExecutionState,
383
+ active: t['active'] === 'On' || t['active'] === 'true',
384
+ motiontask: t['motiontask'] === 'TRUE' || t['motiontask'] === 'True',
385
+ }));
386
+ }
387
+
388
+ async activateRapidTask(task: string): Promise<void> {
389
+ await this.requestMastership('rapid');
390
+ try {
391
+ await this.req('POST', '/rw/rapid/tasks/activate', { task });
392
+ } finally {
393
+ await this.releaseMastership('rapid').catch(() => {});
394
+ }
395
+ }
396
+
397
+ async deactivateRapidTask(task: string): Promise<void> {
398
+ await this.requestMastership('rapid');
399
+ try {
400
+ await this.req('POST', '/rw/rapid/tasks/deactivate', { task });
401
+ } finally {
402
+ await this.releaseMastership('rapid').catch(() => {});
403
+ }
404
+ }
405
+
406
+ async activateAllRapidTasks(): Promise<void> {
407
+ // Get task list then activate each
408
+ const tasks = await this.getRapidTasks();
409
+ await this.requestMastership('rapid');
410
+ try {
411
+ for (const t of tasks) {
412
+ await this.req('POST', '/rw/rapid/tasks/activate', { task: t.name }).catch(() => {});
413
+ }
414
+ } finally {
415
+ await this.releaseMastership('rapid').catch(() => {});
416
+ }
417
+ }
418
+
419
+ async deactivateAllRapidTasks(): Promise<void> {
420
+ const tasks = await this.getRapidTasks();
421
+ await this.requestMastership('rapid');
422
+ try {
423
+ for (const t of tasks) {
424
+ await this.req('POST', '/rw/rapid/tasks/deactivate', { task: t.name }).catch(() => {});
425
+ }
426
+ } finally {
427
+ await this.releaseMastership('rapid').catch(() => {});
428
+ }
429
+ }
430
+
431
+ // ─── RAPID modules & variables ──────────────────────────────────────────────
432
+
433
+ async listModules(task: string): Promise<string[]> {
434
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
435
+ return p.getAllStates('rap-module-info-li').map(m => m['name']).filter(Boolean) as string[];
436
+ }
437
+
438
+ /**
439
+ * Returns each loaded module's name + type (SysMod | ProgMod | …).
440
+ * Single round-trip — same endpoint as `listModules` but exposes more fields.
441
+ */
442
+ async listModulesDetailed(task: string): Promise<Array<{ name: string; type: string }>> {
443
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/modules`));
444
+ return p.getAllStates('rap-module-info-li')
445
+ .map(m => ({ name: m['name'] ?? '', type: m['type'] ?? '' }))
446
+ .filter(m => m.name);
447
+ }
448
+
449
+ async loadModule(task: string, path: string, replace = false): Promise<void> {
450
+ // RWS 2.0 module-load endpoint: POST /rw/rapid/tasks/{task}/loadmod with `modulepath`.
451
+ // (The /program/load endpoint is for full multi-module .pgf programs and uses a different
452
+ // "virtual root" path scheme that doesn't accept user-uploaded HOME/* files.)
453
+ //
454
+ // The path needs to be in fileservice form WITHOUT the leading `$` — translate
455
+ // `$HOME/...` → `HOME/...` so the same code works for callers passing either format.
456
+ const modulePath = path.replace(/^\$HOME\//, 'HOME/').replace(/^\$/, '');
457
+ const body: Record<string, string> = { modulepath: modulePath };
458
+ if (replace) { body['replace'] = 'true'; }
459
+ await this.req('POST', `/rw/rapid/tasks/${task}/loadmod`, body);
460
+ }
461
+
462
+ unloadModule(task: string, name: string): Promise<void> {
463
+ // RWS 2.0 unload is path-based action: POST /rw/rapid/tasks/{task}/unloadmod
464
+ // (DELETE on the module URL returns 405; only POST + body works.)
465
+ return this.req('POST', `/rw/rapid/tasks/${task}/unloadmod`, { module: name }).then(() => {});
466
+ }
467
+
468
+ async getRapidVariable(task: string, module: string, symbol: string): Promise<string> {
469
+ // RWS 2.0 symbol API: suffix-style — /rw/rapid/symbol/{symburl}/data
470
+ // (RWS 1.0 puts /data at the front: /rw/rapid/symbol/data/{symburl})
471
+ const p = RwsClient2.parse(
472
+ await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/data`)
473
+ );
474
+ return p.get('value') ?? '';
475
+ }
476
+
477
+ setRapidVariable(task: string, module: string, symbol: string, value: string): Promise<void> {
478
+ return this.req('POST', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/data`, { value }).then(() => {});
479
+ }
480
+
481
+ async validateRapidValue(task: string, value: string, datatype: string): Promise<boolean> {
482
+ // RWS 2.0: endpoint path differs — use per-task validate
483
+ try {
484
+ await this.req('POST', `/rw/rapid/symbol/RAPID/${task}/data?action=validate`, {
485
+ value, dattyp: datatype,
486
+ });
487
+ return true;
488
+ } catch {
489
+ return false;
490
+ }
491
+ }
492
+
493
+ async getRapidSymbolProperties(task: string, module: string, symbol: string): Promise<RapidSymbolProperties> {
494
+ const p = RwsClient2.parse(
495
+ await this.req('GET', `/rw/rapid/symbol/RAPID/${task}/${module}/${symbol}/properties`)
496
+ );
497
+ const d = p.getState('rap-sympropvar') || p.getState('rap-sympropvar-li') || p.getState('rap-symbol-properties');
498
+ return {
499
+ symburl: d['symburl'] ?? `RAPID/${task}/${module}/${symbol}`,
500
+ symtyp: d['symtyp'] ?? '',
501
+ named: d['named'] === 'true',
502
+ dattyp: d['dattyp'] ?? '',
503
+ ndim: Number(d['ndim'] ?? 0),
504
+ dim: d['dim'] ?? '',
505
+ heap: d['heap'] === 'true',
506
+ linked: d['linked'] === 'true',
507
+ local: d['local'] === 'true',
508
+ ro: d['rdonly'] === 'true' || d['ro'] === 'true',
509
+ taskvar: d['taskvar'] === 'true',
510
+ storage: d['storage'] ?? '',
511
+ typurl: d['typurl'] ?? '',
512
+ };
513
+ }
514
+
515
+ async searchRapidSymbols(params: RapidSymbolSearchParams): Promise<RapidSymbolInfo[]> {
516
+ // RWS 2.0 /rw/rapid/symbols/search expects view=block + blockurl + symtyp=any
517
+ // (NOT a `task` field — that returns 400 "Invalid parameter").
518
+ // It returns one <li> per match. The `class` of the <li> tells you the kind:
519
+ // rap-sympropvar-li → variable (VAR)
520
+ // rap-syproppers-li → persistent (PERS)
521
+ // rap-sympropconst-li → constant (CONST)
522
+ // rap-sympropproc-li → procedure (PROC)
523
+ // rap-sympropfun-li → function (FUNC)
524
+ // rap-sympropmod-li → module
525
+ // Earlier versions only parsed vars — missing all the routines.
526
+ const body: Record<string, string> = {};
527
+ if (params.view) { body['view'] = params.view; }
528
+ if (params.vartyp) { body['vartyp'] = params.vartyp; }
529
+ if (params.symtyp) { body['symtyp'] = params.symtyp; }
530
+ if (params.dattyp) { body['dattyp'] = params.dattyp; }
531
+ if (params.regexp) { body['regexp'] = params.regexp; }
532
+ if (params.recursive !== undefined) { body['recursive'] = String(params.recursive); }
533
+ if (params.blockurl) { body['blockurl'] = params.blockurl; }
534
+ // Sensible defaults so callers can pass just `{ blockurl }`
535
+ if (!body['view']) { body['view'] = 'block'; }
536
+ if (!body['symtyp']) { body['symtyp'] = 'any'; }
537
+ if (!body['recursive']) { body['recursive']= 'TRUE'; }
538
+
539
+ const xhtml = await this.req('POST', '/rw/rapid/symbols/search', body);
540
+ const liClasses = [
541
+ 'rap-sympropvar-li',
542
+ 'rap-syproppers-li',
543
+ 'rap-sympropconst-li',
544
+ 'rap-sympropproc-li',
545
+ 'rap-sympropfun-li',
546
+ 'rap-sympropmod-li',
547
+ 'rap-symproptrap-li',
548
+ ];
549
+ const out: RapidSymbolInfo[] = [];
550
+ for (const cls of liClasses) {
551
+ const p = RwsClient2.parse(xhtml);
552
+ for (const s of p.getAllStates(cls)) {
553
+ out.push({
554
+ symburl: s['symburl'] ?? '',
555
+ name: s['name'] ?? '',
556
+ symtyp: s['symtyp'] ?? '',
557
+ dattyp: s['dattyp'] ?? '',
558
+ ndim: Number(s['ndim'] ?? 0),
559
+ local: s['local'] === 'true',
560
+ ro: s['rdonly'] === 'true',
561
+ taskvar: s['taskvar'] === 'true',
562
+ });
563
+ }
564
+ }
565
+ return out;
566
+ }
567
+
568
+ async getActiveUiInstruction(): Promise<UiInstruction | null> {
569
+ try {
570
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/uiinstr/active'));
571
+ const d = p.getState('rap-uiinstr-li') || p.getState('rap-uiinstr');
572
+ if (!d['instr']) { return null; }
573
+ return { instr: d['instr'], event: d['event'] ?? '', stack: d['stack'] ?? '', execlv: d['execlv'] ?? '', msg: d['msg'] ?? '' };
574
+ } catch { return null; }
575
+ }
576
+
577
+ setUiInstructionParam(stackurl: string, uiparam: string, value: string): Promise<void> {
578
+ // RWS 2.0: POST /rw/rapid/uiinstr/active/param/{stackurl}/{uiparam}
579
+ return this.req(
580
+ 'POST',
581
+ `/rw/rapid/uiinstr/active/param/${encodeURIComponent(stackurl)}/${encodeURIComponent(uiparam)}`,
582
+ { value }
583
+ ).then(() => {});
584
+ }
585
+
586
+ // ─── Motion ─────────────────────────────────────────────────────────────────
587
+
588
+ async getJointPositions(mechunit = 'ROB_1'): Promise<JointTarget> {
589
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/jointtarget`));
590
+ const d = p.getState('ms-jointtarget');
591
+ return {
592
+ rax_1: +d['rax_1'], rax_2: +d['rax_2'], rax_3: +d['rax_3'],
593
+ rax_4: +d['rax_4'], rax_5: +d['rax_5'], rax_6: +d['rax_6'],
594
+ };
595
+ }
596
+
597
+ async getCartesianFull(mechunit = 'ROB_1'): Promise<CartesianFull> {
598
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/cartesian`));
599
+ // Live: cf1/cf4/cf6/cfx in RWS 2.0 map to j1/j4/j6/jx in CartesianFull type
600
+ const d = p.getState('ms-mechunit-cartesian');
601
+ return {
602
+ x: +d['x'], y: +d['y'], z: +d['z'],
603
+ q1: +d['q1'], q2: +d['q2'], q3: +d['q3'], q4: +d['q4'],
604
+ j1: +d['cf1'], j4: +d['cf4'], j6: +d['cf6'], jx: +d['cfx'],
605
+ };
606
+ }
607
+
608
+ async listMechunits(): Promise<string[]> {
609
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/mechunits'));
610
+ // Live: <li class="ms-mechunit-li" title="ROB_1">
611
+ return p.getAllStates('ms-mechunit-li')
612
+ .map(m => m['_title'])
613
+ .filter(Boolean) as string[];
614
+ }
615
+
616
+ // ─── System info ─────────────────────────────────────────────────────────────
617
+
618
+ async getSystemInfo(): Promise<SystemInfo> {
619
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system'));
620
+ const d = p.getState('sys-system');
621
+ // Type-name drift between representations (live-verified 2026-07-09, RW7.21):
622
+ // XHTML lists options as class="sys-option"; HAL JSON nests them under the
623
+ // sys-options-li resource as _type="sys-options". Collect both.
624
+ const opts = [...p.getAllStates('sys-option'), ...p.getAllStates('sys-options')]
625
+ .map(o => o['option']).filter(Boolean) as string[];
626
+ return { name: d['name'] ?? '', rwVersion: d['rwversion'] ?? '', sysid: d['sysid'] ?? '', startTime: d['starttm'] ?? '', options: opts };
627
+ }
628
+
629
+ async getControllerIdentity(): Promise<ControllerIdentity> {
630
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/identity'));
631
+ const d = p.getState('ctrl-identity-info');
632
+ return { name: d['ctrl-name'] ?? '', id: '', type: d['ctrl-type'] ?? '', mac: '' };
633
+ }
634
+
635
+ async getControllerClock(): Promise<ControllerClock> {
636
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/clock'));
637
+ return { datetime: p.getState('ctrl-clock-info')['datetime'] ?? '' };
638
+ }
639
+
640
+ setControllerClock(year: number, month: number, day: number, hour: number, min: number, sec: number): Promise<void> {
641
+ // PUT /ctrl/clock — field names confirmed from RwsClient ResourceMapper
642
+ return this.req('PUT', '/ctrl/clock', {
643
+ 'sys-clock-year': String(year),
644
+ 'sys-clock-month': String(month),
645
+ 'sys-clock-day': String(day),
646
+ 'sys-clock-hour': String(hour),
647
+ 'sys-clock-min': String(min),
648
+ 'sys-clock-sec': String(sec),
649
+ }).then(() => {});
650
+ }
651
+
652
+ restartController(mode: RestartMode = 'restart'): Promise<void> {
653
+ return this.req('POST', '/ctrl/restart', { 'restart-mode': mode }).then(() => {});
654
+ }
655
+
656
+ // ─── Event log ───────────────────────────────────────────────────────────────
657
+
658
+ async getEventLog(domain = 0): Promise<ElogMessage[]> {
659
+ // lang=en required to get title/desc/causes/actions (confirmed by live probe)
660
+ const p = RwsClient2.parse(await this.req('GET', `/rw/elog/${domain}?lang=en`));
661
+ return p.getAllStates('elog-message-li').map(m => {
662
+ const parts = (m['_title'] ?? '').split('/');
663
+ return {
664
+ seqnum: Number(parts[parts.length - 1] ?? 0),
665
+ code: Number(m['code'] ?? 0),
666
+ msgtype: Number(m['msgtype'] ?? 1) as 1 | 2 | 3,
667
+ timestamp: m['tstamp'] ?? '',
668
+ srcName: m['src-name'] ?? '',
669
+ title: m['title'] ?? `Event ${m['code']}`,
670
+ desc: m['desc'] ?? '',
671
+ causes: m['causes'] ?? '',
672
+ consequences: m['conseqs'] ?? '',
673
+ actions: m['actions'] ?? '',
674
+ };
675
+ });
676
+ }
677
+
678
+ clearEventLog(domain = 0): Promise<void> {
679
+ return this.req('POST', `/rw/elog/${domain}/clear`).then(() => {});
680
+ }
681
+
682
+ clearAllEventLogs(): Promise<void> {
683
+ // Live confirmed: POST /rw/elog/clearall → 204
684
+ return this.req('POST', '/rw/elog/clearall').then(() => {});
685
+ }
686
+
687
+ // ─── I/O signals ─────────────────────────────────────────────────────────────
688
+
689
+ async listAllSignals(start = 0, limit = 200): Promise<Signal[]> {
690
+ const p = RwsClient2.parse(await this.req('GET', `/rw/iosystem/signals?start=${start}&limit=${limit}`));
691
+ return p.getAllStates('ios-signal-li').map(s => {
692
+ const name = s['name'] ?? s['_title']?.split('/').pop() ?? '';
693
+ const parts = (s['_title'] ?? '').split('/');
694
+ if (parts.length >= 3) { this.sigCoords.set(name, { n: parts[0], d: parts[1] }); }
695
+ return { name, value: s['lvalue'] ?? '0', type: (s['type'] ?? 'DI') as Signal['type'], lvalue: s['lvalue'] ?? '0' };
696
+ });
697
+ }
698
+
699
+ async readSignal(network: string, device: string, name: string): Promise<Signal> {
700
+ const p = RwsClient2.parse(await this.req('GET', `/rw/iosystem/signals/${network}/${device}/${name}`));
701
+ const d = p.getState('ios-signal-li');
702
+ return { name: d['name'] ?? name, value: d['lvalue'] ?? '0', type: (d['type'] ?? 'DI') as Signal['type'], lvalue: d['lvalue'] ?? '0' };
703
+ }
704
+
705
+ writeSignal(network: string, device: string, name: string, value: string): Promise<void> {
706
+ let n = network, d = device;
707
+ if (!n || !d) {
708
+ const c = this.sigCoords.get(name);
709
+ if (!c) {
710
+ // Without coordinates the URL would degenerate to /signals///{name}/set-value.
711
+ return Promise.reject(new RwsError(
712
+ `writeSignal: network/device unknown for signal "${name}" — pass them explicitly or call listAllSignals() first`,
713
+ 'UNKNOWN',
714
+ ));
715
+ }
716
+ n = c.n; d = c.d;
717
+ }
718
+ return this.req('POST', `/rw/iosystem/signals/${n}/${d}/${name}/set-value`, { lvalue: value }).then(() => {});
719
+ }
720
+
721
+ async listNetworks(): Promise<IoNetwork[]> {
722
+ const p = RwsClient2.parse(await this.req('GET', '/rw/iosystem/networks'));
723
+ // Live: <li class="ios-network-li" title="IntegratedIONetwork">
724
+ // <span class="name">IntegratedIONetwork</span><span class="pstate">running</span><span class="lstate">started</span>
725
+ return p.getAllStates('ios-network-li').map(n => ({
726
+ name: n['name'] ?? n['_title'] ?? '',
727
+ pstate: n['pstate'] ?? '',
728
+ lstate: n['lstate'] ?? '',
729
+ }));
730
+ }
731
+
732
+ async listDevices(network: string): Promise<IoDevice[]> {
733
+ const p = RwsClient2.parse(await this.req('GET', `/rw/iosystem/devices?network=${encodeURIComponent(network)}`));
734
+ // Live: <li class="ios-device-li" title="IntBus/EPanel">
735
+ // <span class="name">EPanel</span><span class="lstate">enabled</span><span class="pstate">running</span><span class="address"></span>
736
+ return p.getAllStates('ios-device-li').map(d => ({
737
+ name: d['name'] ?? d['_title']?.split('/').pop() ?? '',
738
+ network,
739
+ lstate: d['lstate'] ?? '',
740
+ pstate: d['pstate'] ?? '',
741
+ address: d['address'] ?? '',
742
+ }));
743
+ }
744
+
745
+ // ─── File system ──────────────────────────────────────────────────────────────
746
+
747
+ private rws2Path(path: string): string {
748
+ // Percent-encode per segment so names with spaces, '#', '%', etc. survive
749
+ // URL parsing ('#' would otherwise be treated as a fragment and truncate the path).
750
+ return path.replace(/\$HOME/g, 'HOME')
751
+ .split('/').map(encodeURIComponent).join('/');
752
+ }
753
+
754
+ async listDirectory(path: string): Promise<FileEntry[]> {
755
+ const p = RwsClient2.parse(await this.req('GET', `/fileservice/${this.rws2Path(path)}`));
756
+ const dirs = p.getAllStates('fs-dir').map(d => ({ name: d['_title'] ?? '', type: 'dir' as const, modified: d['fs-mdate'] }));
757
+ const files = p.getAllStates('fs-file').map(f => ({ name: f['_title'] ?? '', type: 'file' as const, size: f['fs-size'] ? +f['fs-size'] : undefined, created: f['fs-cdate'], modified: f['fs-mdate'], readonly: f['fs-readonly'] === 'true' }));
758
+ return [...dirs, ...files];
759
+ }
760
+
761
+ readFile(path: string): Promise<string> { return this.req('GET', `/fileservice/${this.rws2Path(path)}`); }
762
+
763
+ uploadFile(path: string, content: string): Promise<void> {
764
+ // RWS 2.0 requires the versioned content type: 'text/plain;v=2.0' or
765
+ // 'application/octet-stream;v=2.0'. Plain 'text/plain' returns HTTP 415.
766
+ return this.req('PUT', `/fileservice/${this.rws2Path(path)}`, undefined, content, 'text/plain;v=2.0').then(() => {});
767
+ }
768
+
769
+ deleteFile(path: string): Promise<void> {
770
+ return this.req('DELETE', `/fileservice/${this.rws2Path(path)}`).then(() => {});
771
+ }
772
+
773
+ /**
774
+ * Create a directory under `parentPath`. Live-verified RWS 2.0 API:
775
+ * POST /fileservice/{parent}/create
776
+ * body: fs-newname={dirName}
777
+ *
778
+ * The earlier shape (`/fileservice/{parent}/{dirName}/create` with no body)
779
+ * returned 404 because the controller treated `{parent}/{dirName}` as the
780
+ * parent and looked for an already-existing `{dirName}` segment.
781
+ */
782
+ createDirectory(parentPath: string, dirName: string): Promise<void> {
783
+ return this.req('POST', `/fileservice/${this.rws2Path(parentPath)}/create`, { 'fs-newname': dirName }).then(() => {});
784
+ }
785
+
786
+ copyFile(sourcePath: string, destPath: string): Promise<void> {
787
+ return this.req('POST', `/fileservice/${this.rws2Path(sourcePath)}/copy`, { destination: destPath }).then(() => {});
788
+ }
789
+
790
+ // ─── Configuration database `/rw/cfg` ───────────────────────────────────────
791
+
792
+ async listCfgDomains(): Promise<string[]> {
793
+ const p = RwsClient2.parse(await this.req('GET', '/rw/cfg'));
794
+ return p.getAllStates('cfg-domain-li').map(d => d['_title'] ?? d['name']).filter(Boolean) as string[];
795
+ }
796
+
797
+ /**
798
+ * Next-page path from a paginated list response, resolved relative to the
799
+ * parent of the current request path (matches the controller's relative
800
+ * hrefs; live-verified on the XHTML `rel="next"` links and, 2026-07-09 on
801
+ * RW7.21, on the HAL `_links.next.href` form). Both representations XML-escape
802
+ * ampersands in the href — even inside JSON strings — hence the unescape.
803
+ * Returns '' when there is no further page.
804
+ */
805
+ private static nextPagePath(responseBody: string, currentPath: string): string {
806
+ const rel = HalJsonParser.looksLikeJson(responseBody)
807
+ ? new HalJsonParser(responseBody).nextHref()
808
+ : responseBody.match(/<a\s+href="([^"]+)"\s+rel="next"/)?.[1];
809
+ if (!rel) { return ''; }
810
+ return currentPath.replace(/[^/]*$/, '') + rel.replace(/&amp;/g, '&');
811
+ }
812
+
813
+ async listCfgTypes(domain: string): Promise<string[]> {
814
+ // Live-verified class: cfg-dt-li (datatype-li). Paginated — controller returns 70/page.
815
+ // Pagination quirk: the `rel="next"` href is relative to the response's <base href>
816
+ // which is /rw/cfg/, NOT to /rw/. Resolve relative to the current request's parent path.
817
+ const types: string[] = [];
818
+ let path = `/rw/cfg/${domain}`;
819
+ let pages = 0;
820
+ while (path && pages < 50) {
821
+ const html = await this.req('GET', path);
822
+ const p = RwsClient2.parse(html);
823
+ types.push(...p.getAllStates('cfg-dt-li').map(t => t['_title'] ?? t['name']).filter(Boolean) as string[]);
824
+ path = RwsClient2.nextPagePath(html, path);
825
+ pages++;
826
+ }
827
+ return types;
828
+ }
829
+
830
+ async listCfgInstances(domain: string, type: string): Promise<string[]> {
831
+ // Live-verified: instances live under /{domain}/{type}/instances (with /instances/ suffix).
832
+ // Each is class="cfg-dt-instance-li" with the instance name as the title attribute.
833
+ // Paginated: controller returns 70/page with `rel="next"` link.
834
+ // Note: a few "types" returned by listCfgTypes are placeholders (e.g. SYS/SYSTEM_NAME)
835
+ // that error with HTTP 400 "Invalid type id" — return [] silently for those.
836
+ const instances: string[] = [];
837
+ let path = `/rw/cfg/${domain}/${type}/instances`;
838
+ let pages = 0;
839
+ while (path && pages < 50) {
840
+ let html: string;
841
+ try { html = await this.req('GET', path); }
842
+ catch { return instances; } // invalid type or no permission — silent empty
843
+ const p = RwsClient2.parse(html);
844
+ instances.push(...p.getAllStates('cfg-dt-instance-li').map(i => i['_title'] ?? '').filter(Boolean));
845
+ path = RwsClient2.nextPagePath(html, path);
846
+ pages++;
847
+ }
848
+ return instances;
849
+ }
850
+
851
+ async getCfgInstance(domain: string, type: string, instance: string): Promise<Record<string, string>> {
852
+ // Live-verified: /{domain}/{type}/instances/{instance}
853
+ // Returns an outer cfg-dt-instance li with NESTED cfg-ia-t li elements.
854
+ // Each attribute: <li class="cfg-ia-t" title="ATTR_NAME"><span class="value">VALUE</span></li>
855
+ const html = await this.req('GET', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`);
856
+ const p = RwsClient2.parse(html);
857
+ const attribs = p.getAllStates('cfg-ia-t');
858
+ const result: Record<string, string> = {};
859
+ for (const attr of attribs) {
860
+ const name = attr['_title'];
861
+ const value = attr['value'] ?? '';
862
+ if (name) { result[name] = value; }
863
+ }
864
+ return result;
865
+ }
866
+
867
+ /**
868
+ * Update attributes on an existing configuration instance. Requires 'edit'
869
+ * mastership (callers hold it; RobotManager wraps these with mastership).
870
+ *
871
+ * Live-verified 2026-07-09 on OmniCore VC RW7.21 via probe-cfg-rws2.mjs:
872
+ * ✓ POST /rw/cfg/{domain}/{type}/instances/{instance}
873
+ * body: each attribute in BRACKET representation `Attr=[value,1]` joined
874
+ * by '&', values literal (not percent-encoded), Content-Type
875
+ * application/x-www-form-urlencoded;v=2.0 → 204. Partial attribute sets
876
+ * are accepted; unknown attribute names → 400 "Error set attribute".
877
+ * ✗ POST /rw/cfg/{domain}/{type}/{instance} (no /instances/) → 404
878
+ */
879
+ async setCfgInstance(domain: string, type: string, instance: string, attrs: Record<string, string>): Promise<void> {
880
+ const body = Object.entries(attrs).map(([k, v]) => `${k}=[${v},1]`).join('&');
881
+ await this.req(
882
+ 'POST',
883
+ `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`,
884
+ undefined,
885
+ body,
886
+ 'application/x-www-form-urlencoded;v=2.0',
887
+ );
888
+ }
889
+
890
+ /**
891
+ * Create a new configuration instance, then apply `attrs`. Requires 'edit'
892
+ * mastership. Live-verified 2026-07-09 on OmniCore VC RW7.21:
893
+ * ✓ POST /rw/cfg/{domain}/{type}/instances/create-default body name={instance} → 201,
894
+ * followed by the setCfgInstance shape above for the attribute values.
895
+ * ✗ POST /rw/cfg/{domain}/{type}/{instance}/create → 404 (endpoint doesn't exist)
896
+ */
897
+ async createCfgInstance(domain: string, type: string, instance: string, attrs: Record<string, string>): Promise<void> {
898
+ await this.req('POST', `/rw/cfg/${domain}/${type}/instances/create-default`,
899
+ undefined, `name=${instance}`, 'application/x-www-form-urlencoded;v=2.0');
900
+ if (Object.keys(attrs).length > 0) {
901
+ await this.setCfgInstance(domain, type, instance, attrs);
902
+ }
903
+ }
904
+
905
+ /**
906
+ * Delete a configuration instance. Requires 'edit' mastership.
907
+ * Live-verified 2026-07-09 on OmniCore VC RW7.21:
908
+ * ✓ DELETE /rw/cfg/{domain}/{type}/instances/{instance} → 204 (readback → 404)
909
+ */
910
+ async removeCfgInstance(domain: string, type: string, instance: string): Promise<void> {
911
+ await this.req('DELETE', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}`);
912
+ }
913
+
914
+ async loadCfgFile(filepath: string, action: 'add' | 'replace' | 'add-with-reset' = 'replace'): Promise<void> {
915
+ await this.req('POST', '/rw/cfg', { 'action-type': action, filepath });
916
+ }
917
+
918
+ async saveCfgFile(domain: string, filepath: string): Promise<void> {
919
+ await this.req('POST', `/rw/cfg/${domain}/save`, { filepath });
920
+ }
921
+
922
+ // ─── Backup / Restore `/ctrl/backup` ────────────────────────────────────────
923
+
924
+ async listBackups(): Promise<Array<{ name: string; created?: string; size?: number }>> {
925
+ // Backups live under /fileservice/BACKUP — list that volume
926
+ try {
927
+ const p = RwsClient2.parse(await this.req('GET', '/fileservice/BACKUP'));
928
+ return p.getAllStates('fs-dir').map(d => ({
929
+ name: d['_title'] ?? '',
930
+ created: d['fs-cdate'],
931
+ }));
932
+ } catch { return []; }
933
+ }
934
+
935
+ async createBackup(name: string): Promise<void> {
936
+ await this.req('POST', '/ctrl/backup/create', { 'backup': `BACKUP/${name}` });
937
+ }
938
+
939
+ async restoreBackup(name: string): Promise<void> {
940
+ await this.req('POST', '/ctrl/backup/restore', { 'backup': `BACKUP/${name}` });
941
+ }
942
+
943
+ async getBackupStatus(): Promise<{ active: boolean; progress?: number; phase?: string }> {
944
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/backup'));
945
+ const d = p.getState('ctrl-backup-info-li') || p.getState('ctrl-backup-info');
946
+ const phase = d['progress-state'] ?? d['phase'] ?? '';
947
+ return {
948
+ active: phase !== '' && phase !== 'idle' && phase !== 'finished',
949
+ progress: d['progress'] ? +d['progress'] : undefined,
950
+ phase,
951
+ };
952
+ }
953
+
954
+ // ─── Tool / WObj management ─────────────────────────────────────────────────
955
+ // RWS exposes these via the mechunit's tool-name / wobj-name attributes;
956
+ // setting requires updating the active task's tooldata/wobjdata RAPID symbols.
957
+
958
+ async getActiveTool(mechunit = 'ROB_1'): Promise<{ name: string; data?: Record<string, string> }> {
959
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
960
+ const d = p.getState('ms-mechunit');
961
+ return { name: d['tool-name'] ?? 'tool0' };
962
+ }
963
+
964
+ async getActiveWobj(mechunit = 'ROB_1'): Promise<{ name: string; data?: Record<string, string> }> {
965
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
966
+ const d = p.getState('ms-mechunit');
967
+ return { name: d['wobj-name'] ?? 'wobj0' };
968
+ }
969
+
970
+ async getActivePayload(mechunit = 'ROB_1'): Promise<{ name: string; data?: Record<string, string> }> {
971
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
972
+ const d = p.getState('ms-mechunit');
973
+ return { name: d['total-payload-name'] ?? d['payload-name'] ?? 'load0' };
974
+ }
975
+
976
+ async setActiveTool(mechunit: string, toolName: string): Promise<void> {
977
+ await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}`, { 'tool': toolName });
978
+ }
979
+
980
+ async setActiveWobj(mechunit: string, wobjName: string): Promise<void> {
981
+ await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}`, { 'wobj': wobjName });
982
+ }
983
+
984
+ // ─── Service routine / PROC call ────────────────────────────────────────────
985
+
986
+ async callServiceRoutine(task: string, routineName: string, args: Record<string, string> = {}): Promise<void> {
987
+ await this.req('POST', `/rw/rapid/tasks/${task}/serviceroutine`, { routine: routineName, ...args });
988
+ }
989
+
990
+ // ─── DIPC `/rw/dipc` ───────────────────────────────────────────────────────
991
+
992
+ async listDipcQueues(): Promise<Array<{ name: string; size?: number }>> {
993
+ const p = RwsClient2.parse(await this.req('GET', '/rw/dipc'));
994
+ return p.getAllStates('dipc-queue-li').map(q => ({
995
+ name: q['queue-name'] ?? q['_title'] ?? '',
996
+ size: q['queue-size'] ? +q['queue-size'] : undefined,
997
+ }));
998
+ }
999
+
1000
+ async createDipcQueue(name: string, options: { maxsize?: number; maxmessages?: number } = {}): Promise<void> {
1001
+ const body: Record<string, string> = { 'dipc-queue-name': name };
1002
+ if (options.maxsize) { body['dipc-max-size'] = String(options.maxsize); }
1003
+ if (options.maxmessages) { body['dipc-max-number-of-messages'] = String(options.maxmessages); }
1004
+ await this.req('POST', '/rw/dipc', body);
1005
+ }
1006
+
1007
+ async sendDipcMessage(queue: string, payload: string, type: 'string' | 'num' | 'dnum' | 'bool' = 'string'): Promise<void> {
1008
+ await this.req('POST', `/rw/dipc/${encodeURIComponent(queue)}`, {
1009
+ 'dipc-src-queue-name': queue,
1010
+ 'dipc-cmd': '111', // SEND
1011
+ 'dipc-data': payload,
1012
+ 'dipc-msgtype': type === 'string' ? '0' : type === 'num' ? '1' : type === 'dnum' ? '2' : '3',
1013
+ });
1014
+ }
1015
+
1016
+ async readDipcMessage(queue: string, timeoutMs = 0): Promise<{ payload: string; type: string } | null> {
1017
+ try {
1018
+ const p = RwsClient2.parse(await this.req('POST', `/rw/dipc/${encodeURIComponent(queue)}/read`, {
1019
+ 'dipc-timeout': String(timeoutMs),
1020
+ }));
1021
+ const d = p.getState('dipc-message');
1022
+ if (!d['dipc-data']) { return null; }
1023
+ return { payload: d['dipc-data'], type: d['dipc-msgtype'] ?? 'string' };
1024
+ } catch { return null; }
1025
+ }
1026
+
1027
+ async removeDipcQueue(name: string): Promise<void> {
1028
+ await this.req('DELETE', `/rw/dipc/${encodeURIComponent(name)}`);
1029
+ }
1030
+
1031
+ // ─── Mastership ───────────────────────────────────────────────────────────────
1032
+
1033
+ private rws2Domain(domain: MastershipDomain): string {
1034
+ // RWS 2.0 renames: 'rapid' and 'cfg' both become 'edit' (confirmed: /rapid/request → 404)
1035
+ return (domain === 'rapid' || domain === 'cfg') ? 'edit' : domain;
1036
+ }
1037
+
1038
+ requestMastership(domain: MastershipDomain): Promise<void> {
1039
+ return this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/request`).then(() => {});
1040
+ }
1041
+
1042
+ releaseMastership(domain: MastershipDomain): Promise<void> {
1043
+ return this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/release`).then(() => {});
1044
+ }
1045
+
1046
+ /** Request mastership on ALL domains at once (RWS 2.0). Cheaper than calling per-domain. */
1047
+ requestMastershipAll(): Promise<void> {
1048
+ return this.req('POST', '/rw/mastership/request').then(() => {});
1049
+ }
1050
+
1051
+ /** Release mastership on ALL domains at once (RWS 2.0). */
1052
+ releaseMastershipAll(): Promise<void> {
1053
+ return this.req('POST', '/rw/mastership/release').then(() => {});
1054
+ }
1055
+
1056
+ /**
1057
+ * Request mastership on `domain` and receive a numeric ID token. Use the ID
1058
+ * with `releaseMastershipWithId()` from a different session — useful when a
1059
+ * client needs mastership to outlive the cookie that acquired it (e.g. a
1060
+ * webapp that periodically reconnects). Token-based release is the only way
1061
+ * to free a stuck mastership after session loss without a controller restart.
1062
+ */
1063
+ async requestMastershipWithId(domain: MastershipDomain): Promise<number> {
1064
+ const xhtml = await this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/request-with-id`);
1065
+ const id = RwsClient2.parse(xhtml).get('mastership-id');
1066
+ if (!id) { throw new Error('RWS2 request-with-id: no mastership-id in response'); }
1067
+ return Number(id);
1068
+ }
1069
+
1070
+ /**
1071
+ * Release mastership previously acquired via `requestMastershipWithId()`.
1072
+ * Body parameter is `mastershipid` (no dash — controller-specific naming
1073
+ * confirmed via 400 "Invalid value" probing; the dash variant returns the
1074
+ * same error code as a missing value).
1075
+ */
1076
+ releaseMastershipWithId(domain: MastershipDomain, id: number): Promise<void> {
1077
+ return this.req('POST', `/rw/mastership/${this.rws2Domain(domain)}/release-with-id`,
1078
+ { mastershipid: String(id) }).then(() => {});
1079
+ }
1080
+
1081
+ /**
1082
+ * Reset the edit-mastership watchdog (RobotWare 7.8+). The controller has a
1083
+ * heartbeat timer (default 2000 ms, configurable via `SYS/MASTER_BOOL/HeartBeat`);
1084
+ * if the holding client doesn't ping during execution, motors go off and execution
1085
+ * stops. Call this periodically (every ~1s) when holding mastership during a long
1086
+ * RAPID run. No-op on RW6.x and on configurations with `Select=false`.
1087
+ */
1088
+ resetMastershipWatchdog(): Promise<void> {
1089
+ return this.req('POST', '/rw/mastership/watchdog').then(() => {});
1090
+ }
1091
+
1092
+ /** Read mastership status for one domain — returns 'nomaster' | 'remote' | 'local' | similar. */
1093
+ async getMastershipStatus(domain: MastershipDomain): Promise<{ mastership: string; uid?: string; application?: string }> {
1094
+ const p = RwsClient2.parse(await this.req('GET', `/rw/mastership/${this.rws2Domain(domain)}`));
1095
+ const d = p.getState('msh-resource');
1096
+ return { mastership: d['mastership'] ?? 'unknown', uid: d['uid'], application: d['application'] };
1097
+ }
1098
+
1099
+ /** List all mastership domains the controller exposes (typically `['edit', 'motion']`). */
1100
+ async listMastershipDomains(): Promise<string[]> {
1101
+ const p = RwsClient2.parse(await this.req('GET', '/rw/mastership'));
1102
+ return p.getAllStates('msh-resource-li').map(d => d['_title']).filter(Boolean) as string[];
1103
+ }
1104
+
1105
+ // ─── Devices `/rw/devices` ──────────────────────────────────────────────────
1106
+
1107
+ /**
1108
+ * List the top-level device groupings (typically HW_DEVICES, SW_RESOURCES).
1109
+ * This is the entry point for the controller's hardware inventory tree.
1110
+ * Drill into each group with `getDeviceTree(group)`.
1111
+ */
1112
+ async listSystemDevices(): Promise<Array<{ id: string; name: string }>> {
1113
+ const p = RwsClient2.parse(await this.req('GET', '/rw/devices'));
1114
+ return p.getAllStates('dev-id-li').map(d => ({
1115
+ id: d['_title'] ?? '',
1116
+ name: d['name'] ?? '',
1117
+ }));
1118
+ }
1119
+
1120
+ /** Drill into a device group (e.g. 'HW_DEVICES'). Returns sub-tree as raw XHTML map.
1121
+ * Accept is pinned to XHTML so the promised raw format never changes under
1122
+ * the HAL JSON negotiation. */
1123
+ async getDeviceTree(group: string): Promise<string> {
1124
+ return this.req('GET', `/rw/devices/${encodeURIComponent(group)}`,
1125
+ undefined, undefined, undefined, [], RwsClient2.ACCEPT_XHTML);
1126
+ }
1127
+
1128
+ /**
1129
+ * List ALL configured I/O devices across every network in one call.
1130
+ * (`listDevices(network)` is the per-network variant — both are fine; this
1131
+ * one's handy when you want a flat overview without enumerating networks first.)
1132
+ */
1133
+ async listAllIoDevices(): Promise<Array<{ name: string; network: string; lstate: string; pstate: string; address: string }>> {
1134
+ const p = RwsClient2.parse(await this.req('GET', '/rw/iosystem/devices'));
1135
+ return p.getAllStates('ios-device-li').map(d => {
1136
+ const title = d['_title'] ?? '';
1137
+ const network = title.split('/')[0] ?? '';
1138
+ return {
1139
+ name: d['name'] ?? '',
1140
+ network,
1141
+ lstate: d['lstate'] ?? '',
1142
+ pstate: d['pstate'] ?? '',
1143
+ address: d['address'] ?? '',
1144
+ };
1145
+ });
1146
+ }
1147
+
1148
+ // ─── Forward Kinematics ─────────────────────────────────────────────────────
1149
+
1150
+ /**
1151
+ * Forward kinematics: compute Cartesian pose from joint angles.
1152
+ * Mirror of `calcJointsFromCartesian()` (which is inverse kinematics).
1153
+ *
1154
+ * Note: like IK, virtual controllers without the PC Interface (616-1) option
1155
+ * generally reject this — the response comes back HTTP 200 but the body
1156
+ * contains a retcode error link instead of the result. Real hardware with
1157
+ * PC Interface licensed returns a valid pose.
1158
+ */
1159
+ async calcCartesianFromJoints(
1160
+ joints: JointTarget,
1161
+ mechunit = 'ROB_1',
1162
+ tool = 'tool0',
1163
+ wobj = 'wobj0',
1164
+ ): Promise<RobTarget> {
1165
+ const body = new URLSearchParams({
1166
+ curr_joints: `[${joints.rax_1},${joints.rax_2},${joints.rax_3},${joints.rax_4},${joints.rax_5},${joints.rax_6}]`,
1167
+ curr_ext_joints: '[9E9,9E9,9E9,9E9,9E9,9E9]',
1168
+ tool, wobj,
1169
+ }).toString();
1170
+ const xhtml = await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}?action=CalcRobTFromJoints`, undefined, body);
1171
+ const p = RwsClient2.parse(xhtml);
1172
+ if (p.getError()) {
1173
+ throw new Error(`FK rejected: ${p.getError()?.msg ?? 'unknown'} (likely missing PC Interface 616-1 license)`);
1174
+ }
1175
+ // RWS 2.0 sometimes returns HTTP 200 with the error embedded as
1176
+ // `<a href="…/retcode?code=N" rel="error"/>` — no <span class="code"> block.
1177
+ // Match either attribute order (href-first or rel-first).
1178
+ const errLink = xhtml.match(/<a [^>]*retcode\?code=(-?\d+)[^>]*rel="error"|<a [^>]*rel="error"[^>]*retcode\?code=(-?\d+)/);
1179
+ if (errLink) {
1180
+ const code = errLink[1] ?? errLink[2];
1181
+ throw new Error(`FK rejected: controller return code ${code} (likely missing PC Interface 616-1 license, or pose unreachable)`);
1182
+ }
1183
+ const d = p.getState('ms-robtarget') || p.getState('ms-cartesian');
1184
+ const x = +d['x'];
1185
+ if (Number.isNaN(x)) {
1186
+ throw new Error(`FK returned no valid pose data (response had no <li class="ms-robtarget|ms-cartesian">; check controller logs)`);
1187
+ }
1188
+ return {
1189
+ x, y: +d['y'], z: +d['z'],
1190
+ q1: +d['q1'], q2: +d['q2'], q3: +d['q3'], q4: +d['q4'],
1191
+ };
1192
+ }
1193
+
1194
+ // ─── Vision `/rw/vision` ────────────────────────────────────────────────────
1195
+
1196
+ async listVisionSystems(): Promise<Array<{ name: string; status?: string }>> {
1197
+ try {
1198
+ const p = RwsClient2.parse(await this.req('GET', '/rw/vision'));
1199
+ return p.getAllStates('vision-system-li').map(s => ({
1200
+ name: s['_title'] ?? s['name'] ?? '',
1201
+ status: s['status'],
1202
+ }));
1203
+ } catch { return []; }
1204
+ }
1205
+
1206
+ async getVisionSystemInfo(name: string): Promise<Record<string, string>> {
1207
+ const p = RwsClient2.parse(await this.req('GET', `/rw/vision/${encodeURIComponent(name)}`));
1208
+ return p.getState('vision-system');
1209
+ }
1210
+
1211
+ async listVisionJobs(system: string): Promise<Array<{ name: string; active?: boolean }>> {
1212
+ const p = RwsClient2.parse(await this.req('GET', `/rw/vision/${encodeURIComponent(system)}/jobs`));
1213
+ return p.getAllStates('vision-job-li').map(j => ({
1214
+ name: j['name'] ?? j['_title'] ?? '',
1215
+ active: j['active'] === 'true',
1216
+ }));
1217
+ }
1218
+
1219
+ async triggerVisionJob(system: string, job: string): Promise<void> {
1220
+ await this.req('POST', `/rw/vision/${encodeURIComponent(system)}/jobs/${encodeURIComponent(job)}/trigger`);
1221
+ }
1222
+
1223
+ // ─── Safety controller `/ctrl/safety` ──────────────────────────────────────
1224
+
1225
+ async getSafetyStatus(): Promise<{ state: string; details?: Record<string, string> }> {
1226
+ try {
1227
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/safety'));
1228
+ const d = p.getState('ctrl-safety') || p.getState('ctrl-safety-info');
1229
+ return { state: d['state'] ?? 'unknown', details: d };
1230
+ } catch { return { state: 'unavailable' }; }
1231
+ }
1232
+
1233
+ async listSafetyZones(): Promise<Array<Record<string, string>>> {
1234
+ try {
1235
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/safety/zones'));
1236
+ return p.getAllStates('ctrl-safety-zone-li');
1237
+ } catch { return []; }
1238
+ }
1239
+
1240
+ async runCyclicBrakeCheck(): Promise<void> {
1241
+ await this.req('POST', '/ctrl/safety/cyclic-brake-check');
1242
+ }
1243
+
1244
+ // ─── Virtual time `/ctrl/virtualtime` ─────────────────────────────────────
1245
+
1246
+ async getVirtualTime(): Promise<{ time: number; running: boolean; speed?: number; timeSlice?: number }> {
1247
+ // Live-verified: /ctrl/virtualtime is a directory of 4 sub-resources (vttime, vtspeed, vtstate, vttimeslice).
1248
+ // Fetch each and assemble the result.
1249
+ // Live-verified field names (RobotWare 7.21):
1250
+ // /vttime → class="ctrl-vttime" → span "vtcounter" (microseconds since boot)
1251
+ // /vtstate → class="ctrl-vtstate" → span "vtcurrstate" ("running"/"stopped")
1252
+ // /vtspeed → class="ctrl-vtspeed" → span "vtcurrspeed" (1.0=real, 10=10x)
1253
+ const fetch = async (sub: string) => {
1254
+ try {
1255
+ const p = RwsClient2.parse(await this.req('GET', `/ctrl/virtualtime/${sub}`));
1256
+ return p.getState(`ctrl-${sub}`) || {};
1257
+ } catch { return {}; }
1258
+ };
1259
+ const [time, state, speed] = await Promise.all([
1260
+ fetch('vttime'),
1261
+ fetch('vtstate'),
1262
+ fetch('vtspeed'),
1263
+ ]);
1264
+ return {
1265
+ time: Number(time['vtcounter'] ?? time['time'] ?? 0),
1266
+ running: (state['vtcurrstate'] ?? state['state'] ?? '').toLowerCase() === 'running',
1267
+ speed: speed['vtcurrspeed'] !== undefined ? +speed['vtcurrspeed'] : undefined,
1268
+ };
1269
+ }
1270
+
1271
+ async setVirtualTimeRunning(running: boolean): Promise<void> {
1272
+ await this.req('POST', '/ctrl/virtualtime/vtstate', { vtcurrstate: running ? 'running' : 'stopped' });
1273
+ }
1274
+
1275
+ async setVirtualTimeScale(scale: number): Promise<void> {
1276
+ await this.req('POST', '/ctrl/virtualtime/vtspeed', { vtcurrspeed: String(scale) });
1277
+ }
1278
+
1279
+ // ─── Certificate store `/ctrl/certstore` ──────────────────────────────────
1280
+
1281
+ async listCertificates(): Promise<Array<{ name: string; subject?: string; expires?: string }>> {
1282
+ try {
1283
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/certstore'));
1284
+ return p.getAllStates('ctrl-cert-li').map(c => ({
1285
+ name: c['name'] ?? c['_title'] ?? '',
1286
+ subject: c['subject'],
1287
+ expires: c['expires'] ?? c['valid-to'],
1288
+ }));
1289
+ } catch { return []; }
1290
+ }
1291
+
1292
+ async uploadCertificate(name: string, pem: string): Promise<void> {
1293
+ await this.req('POST', `/ctrl/certstore/${encodeURIComponent(name)}`, undefined, pem, 'application/x-pem-file');
1294
+ }
1295
+
1296
+ async removeCertificate(name: string): Promise<void> {
1297
+ await this.req('DELETE', `/ctrl/certstore/${encodeURIComponent(name)}`);
1298
+ }
1299
+
1300
+ // ─── Registry `/ctrl/registry` ────────────────────────────────────────────
1301
+
1302
+ async getRegistry(): Promise<Record<string, string>> {
1303
+ try {
1304
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/registry'));
1305
+ return p.getState('ctrl-registry');
1306
+ } catch { return {}; }
1307
+ }
1308
+
1309
+ // ─── Compress `/ctrl/compress` ────────────────────────────────────────────
1310
+
1311
+ async compressPath(source: string, destination: string): Promise<void> {
1312
+ await this.req('POST', '/ctrl/compress', { source, destination });
1313
+ }
1314
+
1315
+ // ─── File service — list volumes ──────────────────────────────────────────
1316
+
1317
+ async listFileVolumes(): Promise<string[]> {
1318
+ try {
1319
+ const p = RwsClient2.parse(await this.req('GET', '/fileservice'));
1320
+ return p.getAllStates('fs-volume').map(v => v['_title'] ?? v['name']).filter(Boolean) as string[];
1321
+ } catch {
1322
+ // Fallback: known standard volumes
1323
+ return ['HOME', 'BACKUP', 'DATA', 'ADDINDATA', 'PRODUCTS', 'RAMDISK', 'TEMP'];
1324
+ }
1325
+ }
1326
+
1327
+ // ─── PP control & RAPID debugger backbone ─────────────────────────────────
1328
+
1329
+ async setProgramPointer(task: string, params: { module?: string; routine: string; row?: number; col?: number }): Promise<void> {
1330
+ const body: Record<string, string> = { routine: params.routine };
1331
+ if (params.module) { body['module'] = params.module; }
1332
+ if (params.row !== undefined) { body['begin-position-row'] = String(params.row); }
1333
+ if (params.col !== undefined) { body['begin-position-col'] = String(params.col); }
1334
+ await this.req('POST', `/rw/rapid/tasks/${task}/pcp/routine`, body);
1335
+ }
1336
+
1337
+ async setPPToCursor(task: string, module: string, row: number, col: number): Promise<void> {
1338
+ await this.req('POST', `/rw/rapid/tasks/${task}/pcp/cursor`, {
1339
+ module,
1340
+ 'begin-position-row': String(row),
1341
+ 'begin-position-col': String(col),
1342
+ });
1343
+ }
1344
+
1345
+ async stepRapid(task: string, mode: 'into' | 'over' | 'out'): Promise<void> {
1346
+ const stepMode = mode === 'into' ? 'step-in' : mode === 'over' ? 'step-over' : 'step-out';
1347
+ await this.req('POST', `/rw/rapid/tasks/${task}/step`, { 'step-mode': stepMode });
1348
+ }
1349
+
1350
+ async holdToRun(task: string, action: 'press' | 'release'): Promise<void> {
1351
+ await this.req('POST', `/rw/rapid/tasks/${task}/holdtorun`, { action });
1352
+ }
1353
+
1354
+ async listBreakpoints(task: string): Promise<Array<{ module: string; row: number; col?: number }>> {
1355
+ try {
1356
+ // Live-verified: /rw/rapid/tasks/{task}/program/breakpoints (not /breakpoint at task root)
1357
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/program/breakpoints`));
1358
+ return p.getAllStates('rap-breakpoint-li').map(b => ({
1359
+ module: b['modulename'] ?? b['modulemame'] ?? b['module'] ?? '',
1360
+ row: +(b['begin-position-row'] ?? '0'),
1361
+ col: b['begin-position-col'] ? +b['begin-position-col'] : undefined,
1362
+ }));
1363
+ } catch { return []; }
1364
+ }
1365
+
1366
+ async setBreakpoint(task: string, module: string, row: number, col?: number): Promise<void> {
1367
+ const body: Record<string, string> = { module, 'begin-position-row': String(row) };
1368
+ if (col !== undefined) { body['begin-position-col'] = String(col); }
1369
+ await this.req('POST', `/rw/rapid/tasks/${task}/program/breakpoints`, body);
1370
+ }
1371
+
1372
+ async removeBreakpoint(task: string, module: string, row: number, col?: number): Promise<void> {
1373
+ const params = new URLSearchParams({ module, 'begin-position-row': String(row) });
1374
+ if (col !== undefined) { params.set('begin-position-col', String(col)); }
1375
+ await this.req('DELETE', `/rw/rapid/tasks/${task}/breakpoint?${params.toString()}`);
1376
+ }
1377
+
1378
+ // ─── Mechunit detailed endpoints ────────────────────────────────────────────
1379
+
1380
+ async getMechunitBaseFrame(mechunit = 'ROB_1'): Promise<{ x: number; y: number; z: number; q1: number; q2: number; q3: number; q4: number }> {
1381
+ // Live-verified class: ms-mechunit-baseframe (not ms-baseframe)
1382
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/baseframe`));
1383
+ const d = p.getState('ms-mechunit-baseframe') || p.getState('ms-baseframe');
1384
+ return {
1385
+ x: +d['x'], y: +d['y'], z: +d['z'],
1386
+ q1: +d['q1'], q2: +d['q2'], q3: +d['q3'], q4: +d['q4'],
1387
+ };
1388
+ }
1389
+
1390
+ async setMechunitBaseFrame(mechunit: string, frame: { x: number; y: number; z: number; q1: number; q2: number; q3: number; q4: number }): Promise<void> {
1391
+ await this.req('POST', `/rw/motionsystem/mechunits/${mechunit}/baseframe`, {
1392
+ x: String(frame.x), y: String(frame.y), z: String(frame.z),
1393
+ q1: String(frame.q1), q2: String(frame.q2), q3: String(frame.q3), q4: String(frame.q4),
1394
+ });
1395
+ }
1396
+
1397
+ async getMechunitAxes(mechunit = 'ROB_1'): Promise<Array<Record<string, string>>> {
1398
+ // Live-verified: /axes returns a count + sub-resource links (axes/1..N).
1399
+ // Fetch each axis individually and assemble the result.
1400
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes`));
1401
+ const total = p.getState('ms-mechunit-axes');
1402
+ const axisCount = +(total['axes'] ?? 0);
1403
+ if (axisCount === 0) { return []; }
1404
+
1405
+ const axes: Array<Record<string, string>> = [];
1406
+ for (let i = 1; i <= axisCount; i++) {
1407
+ try {
1408
+ const ap = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/axes/${i}`));
1409
+ const ad = ap.getState('ms-mechunit-axis') || ap.getState('ms-axis');
1410
+ axes.push({ axis: String(i), ...ad });
1411
+ } catch { axes.push({ axis: String(i), error: 'unreachable' }); }
1412
+ }
1413
+ return axes;
1414
+ }
1415
+
1416
+ async getMechunitPjoints(mechunit = 'ROB_1'): Promise<Record<string, number>> {
1417
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}/pjoints`));
1418
+ const d = p.getState('ms-pjoints');
1419
+ const out: Record<string, number> = {};
1420
+ for (const [k, v] of Object.entries(d)) { if (!k.startsWith('_')) { out[k] = +v; } }
1421
+ return out;
1422
+ }
1423
+
1424
+ async getMechunitInfo(mechunit = 'ROB_1'): Promise<Record<string, string>> {
1425
+ const p = RwsClient2.parse(await this.req('GET', `/rw/motionsystem/mechunits/${mechunit}`));
1426
+ return p.getState('ms-mechunit');
1427
+ }
1428
+
1429
+ // ─── Module detailed endpoints ──────────────────────────────────────────────
1430
+
1431
+ async getModuleSource(task: string, moduleName: string): Promise<string> {
1432
+ // Program memory is the source of truth — the save round-trip reads it
1433
+ // directly, so it is the PRIMARY path. A direct file read can return a
1434
+ // stale on-disk copy (module edited in memory, or a leftover HOME file
1435
+ // shadowing a module that was actually loaded from .pgf / RobotStudio),
1436
+ // and module metadata exposes no reliable backing path to trust: the
1437
+ // per-module GET only carries a bare `filename` span (live-verified
1438
+ // 2026-07-09 on OmniCore VC RW7.21 — no path/file-path field exists).
1439
+ try {
1440
+ return await this.readModuleViaSave(task, moduleName);
1441
+ } catch {
1442
+ // Save endpoint failed (permissions, disk, transient) — fall back to the
1443
+ // backing file named by metadata, or the conventional HOME location.
1444
+ const info = await this.getModuleInfo(task, moduleName).catch(() => ({} as Record<string, string>));
1445
+ const filepath = info['path'] ?? info['file-path']
1446
+ ?? (info['filename'] ? `HOME/${info['filename']}` : `$HOME/${moduleName}.mod`);
1447
+ return this.readFile(filepath);
1448
+ }
1449
+ }
1450
+
1451
+ /**
1452
+ * Read a module's source by round-tripping it through the TEMP volume.
1453
+ * Live-verified 2026-07-08 on OmniCore VC RW7.21:
1454
+ * POST /rw/rapid/tasks/{task}/modules/{module}/save body name=<tmp>&path=TEMP:
1455
+ * → 204, no mastership required. The controller ALWAYS appends '.modx' to
1456
+ * the given name (even for SysMod modules — never '.sysx'), so the name is
1457
+ * passed without extension. TEMP: avoids any risk of clobbering HOME files.
1458
+ */
1459
+ private async readModuleViaSave(task: string, moduleName: string): Promise<string> {
1460
+ const tmp = `${moduleName}_${Date.now().toString(36)}${Math.floor(Math.random() * 0xffff).toString(36)}`;
1461
+ await this.req(
1462
+ 'POST',
1463
+ `/rw/rapid/tasks/${task}/modules/${encodeURIComponent(moduleName)}/save`,
1464
+ undefined,
1465
+ `name=${tmp}&path=TEMP:`,
1466
+ );
1467
+ try {
1468
+ return await this.readFile(`TEMP/${tmp}.modx`);
1469
+ } finally {
1470
+ await this.deleteFile(`TEMP/${tmp}.modx`).catch(() => {});
1471
+ }
1472
+ }
1473
+
1474
+ async getModuleInfo(task: string, moduleName: string): Promise<Record<string, string>> {
1475
+ // Live-verified 2026-07-09 on OmniCore VC RW7.21: the per-module GET returns
1476
+ // <li class="rap-module" title="{task}/{module}"> with spans modname,
1477
+ // filename (bare name like 'BASE.sysx' — NO path) and attribute.
1478
+ // (rap-module-info-li is the class used by the module LIST endpoint.)
1479
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/modules/${encodeURIComponent(moduleName)}`));
1480
+ const d = p.getState('rap-module');
1481
+ if (Object.keys(d).length > 0) { return d; }
1482
+ return p.getState('rap-module-info-li') || p.getState('rap-module-info');
1483
+ }
1484
+
1485
+ async listModuleSymbols(task: string, moduleName: string): Promise<Array<{ name: string; type: string; dattyp?: string }>> {
1486
+ const symbols = await this.searchRapidSymbols({ task, blockurl: `RAPID/${task}/${moduleName}`, recursive: false });
1487
+ return symbols.map(s => ({ name: s.name, type: s.symtyp, dattyp: s.dattyp }));
1488
+ }
1489
+
1490
+ // ─── Per-task additional endpoints ──────────────────────────────────────────
1491
+
1492
+ async getTaskStructuralChangeCount(task: string): Promise<number> {
1493
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/structural-changecount`));
1494
+ return Number(p.get('change-count') ?? p.get('structural-changecount') ?? 0);
1495
+ }
1496
+
1497
+ async getTaskMotion(task: string): Promise<Record<string, string>> {
1498
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/motion`));
1499
+ return p.getState('rap-task-motion') || {};
1500
+ }
1501
+
1502
+ async getTaskActivationRecord(task: string): Promise<Record<string, string>> {
1503
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/activation-record`));
1504
+ return p.getState('rap-activation-record') || {};
1505
+ }
1506
+
1507
+ async getTaskProgramInfo(task: string): Promise<Record<string, string>> {
1508
+ // Endpoint returns 204 (no content) when no program is loaded — caller handles this.
1509
+ const xml = await this.req('GET', `/rw/rapid/tasks/${task}/program`);
1510
+ if (!xml) { return {}; }
1511
+ return RwsClient2.parse(xml).getState('rap-program-info') || {};
1512
+ }
1513
+
1514
+ // ─── WebSocket subscriptions ──────────────────────────────────────────────────
1515
+
1516
+ /**
1517
+ * Maps a SubscriptionResource to the RWS 2.0 path;stateParam string.
1518
+ * Semicolons must NOT be URL-encoded — the controller requires them literal.
1519
+ */
1520
+ private static rws2ResourcePath(r: SubscriptionResource): string | null {
1521
+ if (typeof r === 'string') {
1522
+ const map: Record<string, string> = {
1523
+ controllerstate: '/rw/panel/ctrl-state;ctrlstate',
1524
+ operationmode: '/rw/panel/opmode;opmode',
1525
+ speedratio: '/rw/panel/speedratio;speedratio',
1526
+ execution: '/rw/rapid/execution;ctrlexecstate',
1527
+ coldetstate: '/rw/panel/coldetstate;coldetstate',
1528
+ uiinstr: '/rw/rapid/uiinstr;uievent',
1529
+ };
1530
+ return map[r] ?? null;
1531
+ }
1532
+ switch (r.type) {
1533
+ case 'execycle': return '/rw/rapid/execution;rapidexeccycle';
1534
+ case 'elog': return `/rw/elog/${r.domain}`;
1535
+ case 'signal': return `/rw/iosystem/signals/${r.name};lvalue`;
1536
+ case 'persvar': return `/rw/rapid/symbol/data/RAPID/${r.name};value`;
1537
+ case 'taskchange': return `/rw/rapid/tasks/${r.task};taskchange`;
1538
+ default: return null;
1539
+ }
1540
+ }
1541
+
1542
+ /**
1543
+ * Map a resource URL path back to its friendly name for handleSubscriptionEvent.
1544
+ * Works with both /rw/panel/ctrlstate (RWS 1.0) and /rw/panel/ctrl-state (RWS 2.0).
1545
+ */
1546
+ static resourcePathToName(path: string): string {
1547
+ if (/\/(ctrlstate|ctrl-state)/.test(path)) { return 'controllerstate'; }
1548
+ if (/\/opmode/.test(path)) { return 'operationmode'; }
1549
+ if (/\/speedratio/.test(path)) { return 'speedratio'; }
1550
+ if (/\/execution/.test(path) && !/execycle/.test(path)) { return 'execution'; }
1551
+ if (/\/coldetstate/.test(path)) { return 'coldetstate'; }
1552
+ if (/\/elog\//.test(path)) { return 'elog'; }
1553
+ return path; // fallback: keep full path
1554
+ }
1555
+
1556
+ /** First reconnect delay after a dropped subscription WebSocket (doubles per attempt). */
1557
+ private static readonly WS_RECONNECT_BASE_MS = 500;
1558
+ /** Give up re-subscribing after this many consecutive failed attempts. */
1559
+ private static readonly WS_RECONNECT_MAX_ATTEMPTS = 6;
1560
+ /** How long to wait for the WebSocket upgrade to complete before treating the attempt as failed. */
1561
+ private static readonly WS_OPEN_TIMEOUT_MS = 8000;
1562
+
1563
+ /**
1564
+ * POST /subscription — accept HTTP 201 (Created).
1565
+ * Captures the Location header (authoritative WS URL) and the group resource
1566
+ * path (`/subscription/{id}` — the URL a DELETE must target to free the group).
1567
+ *
1568
+ * Rides the client's main HTTP session: live-verified 2026-07-09 on OmniCore
1569
+ * VC RW7.21 (probe-sub-session.mjs) — POST /subscription with the existing
1570
+ * session Cookie returns 201 with NO Set-Cookie (no new session minted) and
1571
+ * the WebSocket authenticates with that same cookie. Without the Cookie the
1572
+ * controller mints one session per subscribe, and reconnect loops would burn
1573
+ * through the 5-sessions-per-IP budget.
1574
+ */
1575
+ private createSubscription(bodyStr: string): Promise<{
1576
+ wsUrl: string; deleteUrl: string; cookieStr: string;
1577
+ }> {
1578
+ return new Promise((resolve, reject) => {
1579
+ const url = new URL('/subscription', this.baseUrl);
1580
+ const encoded = Buffer.from(bodyStr);
1581
+ const options: http.RequestOptions & { agent?: https.Agent; rejectUnauthorized?: boolean } = {
1582
+ method: 'POST',
1583
+ hostname: url.hostname,
1584
+ port: url.port ? +url.port : (this.isHttps ? 443 : 80),
1585
+ path: '/subscription',
1586
+ headers: {
1587
+ Authorization: this.authHeader,
1588
+ Accept: 'application/xhtml+xml;v=2.0',
1589
+ 'Content-Type': 'application/x-www-form-urlencoded;v=2.0',
1590
+ 'Content-Length': String(encoded.length),
1591
+ ...(this.sessionCookie ? { Cookie: this.sessionCookie } : {}),
1592
+ },
1593
+ // Per-request as well as on the agent — see req() for why (issue #2).
1594
+ ...(this.isHttps
1595
+ ? { agent: this.httpsAgent, ...(this.rejectUnauthorized ? {} : { rejectUnauthorized: false }) }
1596
+ : {}),
1597
+ };
1598
+ const transport = this.isHttps ? https : http;
1599
+ const req = (transport as typeof https).request(options as https.RequestOptions, res => {
1600
+ const chunks: Buffer[] = [];
1601
+ res.on('data', (c: Buffer) => chunks.push(c));
1602
+ res.on('end', () => {
1603
+ if (res.statusCode !== 201) {
1604
+ reject(new Error(`RWS2 subscribe POST returned ${res.statusCode}`));
1605
+ return;
1606
+ }
1607
+ const body = Buffer.concat(chunks).toString('utf8');
1608
+ // Location header contains the WebSocket URL (wss://host/poll/{id})
1609
+ const location = (res.headers['location'] ?? '') as string;
1610
+ let wsUrl: string;
1611
+ if (location.startsWith('wss://') || location.startsWith('ws://')) {
1612
+ wsUrl = location;
1613
+ } else {
1614
+ // Fallback: parse from XHTML body
1615
+ wsUrl = body.match(/href="(wss?:\/\/[^"]+)"/)?.[1] ?? '';
1616
+ }
1617
+ // Group resource for cleanup. Live-verified 2026-07-09 on OmniCore VC
1618
+ // RW7.21: DELETE /subscription/{id} → 200 and the group disappears;
1619
+ // DELETE on the /poll/{id} URL → 404 (it is NOT a deletable resource).
1620
+ // The 201 body carries <a href="subscription/{id}" rel="group"/>.
1621
+ const groupId =
1622
+ body.match(/href="[^"]*subscription\/([^"/]+)"[^>]*rel="group"/)?.[1]
1623
+ ?? body.match(/rel="group"[^>]*href="[^"]*subscription\/([^"/]+)"/)?.[1]
1624
+ ?? wsUrl.match(/\/poll\/([^/?#]+)/)?.[1]
1625
+ ?? '';
1626
+ const deleteUrl = groupId ? `/subscription/${groupId}` : '';
1627
+
1628
+ // Capture the session cookie if this POST minted one (first-ever request
1629
+ // on this client) — same capture rule as req(). The WebSocket authenticates
1630
+ // with Cookie, NOT Authorization.
1631
+ const setCookies = (res.headers['set-cookie'] ?? []) as string[];
1632
+ if (setCookies.length > 0 && !this.sessionCookie) {
1633
+ this.sessionCookie = setCookies.map((c: string) => c.split(';')[0]).join('; ');
1634
+ }
1635
+ const cookieStr = this.sessionCookie ?? '';
1636
+
1637
+ if (!wsUrl) { reject(new Error('RWS2 subscribe: no WebSocket URL')); return; }
1638
+ resolve({ wsUrl, deleteUrl, cookieStr });
1639
+ });
1640
+ });
1641
+ req.on('error', reject);
1642
+ req.write(encoded);
1643
+ req.end();
1644
+ });
1645
+ }
1646
+
1647
+ async subscribe(
1648
+ resources: SubscriptionResource[],
1649
+ handler: (event: SubscriptionEvent) => void,
1650
+ onLost?: () => void,
1651
+ ): Promise<() => Promise<void>> {
1652
+ // 1. Build subscription body
1653
+ const paths = resources.map(r => RwsClient2.rws2ResourcePath(r)).filter(Boolean) as string[];
1654
+ if (paths.length === 0) { return async () => {}; }
1655
+
1656
+ const parts = [`resources=${paths.length}`];
1657
+ paths.forEach((p, i) => {
1658
+ // Format: <idx>=<path;stateParam>&<idx>-p=<priority>
1659
+ // Semicolons must be LITERAL — do NOT encodeURIComponent
1660
+ parts.push(`${i + 1}=${p}&${i + 1}-p=1`);
1661
+ });
1662
+ const bodyStr = parts.join('&');
1663
+
1664
+ // We dynamically import 'ws' so callers who never subscribe don't pay for it.
1665
+ // (ESM-safe; the package is `"type": "module"`, so `require` is undefined.)
1666
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1667
+ const wsMod = await import('ws') as { default: { new(url: string, protocols: string[], opts: object): any } };
1668
+ const WsImpl = wsMod.default;
1669
+
1670
+ // Connection state shared between the reconnect logic and unsubscribe.
1671
+ const conn = {
1672
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1673
+ ws: null as any,
1674
+ deleteUrl: '',
1675
+ pingTimer: null as ReturnType<typeof setInterval> | null,
1676
+ reconnectTimer: null as ReturnType<typeof setTimeout> | null,
1677
+ closed: false,
1678
+ attempts: 0,
1679
+ lostNotified: false,
1680
+ };
1681
+
1682
+ // Best-effort removal of a subscription group (DELETE /subscription/{id}).
1683
+ // Groups live as long as the session that owns them, and the session is the
1684
+ // client's main one — orphaned groups would pile up on every reconnect.
1685
+ const dropGroup = (path: string): Promise<void> =>
1686
+ path ? this.req('DELETE', path).then(() => {}, () => {}) : Promise.resolve();
1687
+
1688
+ // The subscription rides the main HTTP session (see createSubscription), so a
1689
+ // dropped WebSocket does NOT invalidate the group — but its poll URL is spent.
1690
+ // Every (re)connect drops the previous group, then POSTs a fresh /subscription
1691
+ // on the same session; no extra sessions are ever minted.
1692
+ const open = async (): Promise<void> => {
1693
+ if (conn.deleteUrl) {
1694
+ await dropGroup(conn.deleteUrl);
1695
+ conn.deleteUrl = '';
1696
+ }
1697
+ const { wsUrl, deleteUrl, cookieStr } = await this.createSubscription(bodyStr);
1698
+ conn.deleteUrl = deleteUrl;
1699
+
1700
+ // 2. Open WebSocket and wait for confirmation it actually connected.
1701
+ // Auth: Cookie from subscription response (NOT Authorization header).
1702
+ // Subprotocol: "rws_subscription" — the RWS 2.0 name. Live-verified 2026-07-08
1703
+ // on OmniCore VC RW7.21: "robapi2_subscription" (the RWS 1.0 name) is rejected
1704
+ // with HTTP 400; "rws_subscription" upgrades with 101.
1705
+ const ws = new WsImpl(wsUrl, ['rws_subscription'], {
1706
+ ...(this.rejectUnauthorized ? {} : { rejectUnauthorized: false }),
1707
+ headers: { Cookie: cookieStr },
1708
+ });
1709
+
1710
+ // Wait for the WebSocket to open. If the controller rejects the upgrade,
1711
+ // we clean up and throw so the caller falls back to polling.
1712
+ await new Promise<void>((resolve, reject) => {
1713
+ let settled = false;
1714
+ const timer = setTimeout(() => {
1715
+ if (settled) { return; }
1716
+ settled = true;
1717
+ ws.terminate();
1718
+ dropGroup(deleteUrl);
1719
+ reject(new Error(`WebSocket connection timed out after ${RwsClient2.WS_OPEN_TIMEOUT_MS} ms`));
1720
+ }, RwsClient2.WS_OPEN_TIMEOUT_MS);
1721
+
1722
+ ws.on('open', () => {
1723
+ if (settled) { return; }
1724
+ settled = true;
1725
+ clearTimeout(timer);
1726
+ resolve();
1727
+ });
1728
+
1729
+ // unexpected-response fires when the HTTP upgrade is rejected (e.g. 400)
1730
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
1731
+ ws.on('unexpected-response', (_req: unknown, res: any) => {
1732
+ clearTimeout(timer);
1733
+ const chunks: Buffer[] = [];
1734
+ res.on('data', (c: Buffer) => chunks.push(c));
1735
+ res.on('end', () => {
1736
+ if (settled) { return; }
1737
+ settled = true;
1738
+ const body = (Buffer.concat(chunks).toString().trim() || '').slice(0, 120);
1739
+ ws.terminate();
1740
+ dropGroup(deleteUrl);
1741
+ reject(new Error(`RWS2 WebSocket upgrade rejected (HTTP ${res.statusCode}): ${body}`));
1742
+ });
1743
+ });
1744
+
1745
+ ws.on('error', (err: Error) => {
1746
+ if (settled) { return; }
1747
+ settled = true;
1748
+ clearTimeout(timer);
1749
+ dropGroup(deleteUrl);
1750
+ reject(err);
1751
+ });
1752
+ });
1753
+
1754
+ // Unsubscribed while the handshake was in flight — discard the connection.
1755
+ if (conn.closed) {
1756
+ ws.close();
1757
+ dropGroup(deleteUrl);
1758
+ return;
1759
+ }
1760
+ conn.ws = ws;
1761
+
1762
+ // 3. Ping every 25 s (controller closes if no activity within 30 s)
1763
+ conn.pingTimer = setInterval(() => {
1764
+ if ((ws as { readyState: number }).readyState === 1 /* OPEN */) { ws.send('PING'); }
1765
+ }, 25000);
1766
+
1767
+ // 4. Parse incoming events (same approach as abb-rws-client WsSubscriber)
1768
+ ws.on('message', (data: Buffer | string) => {
1769
+ const raw = data.toString();
1770
+ if (raw === 'PONG') { return; }
1771
+
1772
+ const liPat = /<li[^>]*>([\s\S]*?)<\/li>/gi;
1773
+ let m: RegExpExecArray | null;
1774
+ while ((m = liPat.exec(raw)) !== null) {
1775
+ const block = m[1];
1776
+ const hrefM = block.match(/<a[^>]*href="([^"]+)"/i);
1777
+ const spanM = block.match(/<span[^>]*>([\s\S]*?)<\/span>/i);
1778
+ if (!hrefM || !spanM) { continue; }
1779
+ handler({
1780
+ resource: RwsClient2.resourcePathToName(hrefM[1]),
1781
+ value: spanM[1].trim(),
1782
+ timestamp: new Date(),
1783
+ });
1784
+ }
1785
+ });
1786
+
1787
+ // Non-fatal error after open — the matching 'close' event drives cleanup/reconnect.
1788
+ ws.on('error', (err: Error) => {
1789
+ console.warn('[RWS2] WebSocket error:', err.message);
1790
+ });
1791
+
1792
+ ws.on('close', () => {
1793
+ if (conn.pingTimer) { clearInterval(conn.pingTimer); conn.pingTimer = null; }
1794
+ if (!conn.closed) { scheduleReconnect(); }
1795
+ });
1796
+ };
1797
+
1798
+ const scheduleReconnect = (): void => {
1799
+ // unsubscribe() clears the pending timer, but an open() already in
1800
+ // flight lands here through its .catch — without this guard it would
1801
+ // keep retrying (and eventually fire onLost) after the consumer left.
1802
+ if (conn.closed) { return; }
1803
+ if (conn.attempts >= RwsClient2.WS_RECONNECT_MAX_ATTEMPTS) {
1804
+ const msg = `RWS2 subscription lost — giving up after ${conn.attempts} reconnect attempts`;
1805
+ Logger.error(msg);
1806
+ console.error(`[RWS2] ${msg}`);
1807
+ void dropGroup(conn.deleteUrl);
1808
+ conn.deleteUrl = '';
1809
+ if (!conn.lostNotified) {
1810
+ conn.lostNotified = true;
1811
+ try { onLost?.(); } catch { /* consumer callback — never let it break us */ }
1812
+ }
1813
+ return;
1814
+ }
1815
+ const delay = RwsClient2.WS_RECONNECT_BASE_MS * 2 ** conn.attempts;
1816
+ conn.attempts++;
1817
+ Logger.trace?.('subscription', `RWS2 WebSocket dropped — reconnect attempt ${conn.attempts} in ${delay} ms`);
1818
+ conn.reconnectTimer = setTimeout(() => {
1819
+ open()
1820
+ .then(() => {
1821
+ if (conn.closed) {
1822
+ // unsubscribe() won the race against this reconnect — tear the
1823
+ // fresh socket/group down instead of leaving a zombie stream.
1824
+ conn.ws?.close();
1825
+ const url = conn.deleteUrl;
1826
+ conn.deleteUrl = '';
1827
+ void dropGroup(url);
1828
+ return;
1829
+ }
1830
+ conn.attempts = 0;
1831
+ })
1832
+ .catch(e => {
1833
+ Logger.warn(`RWS2 subscription reconnect failed: ${e instanceof Error ? e.message : String(e)}`);
1834
+ scheduleReconnect();
1835
+ });
1836
+ }, delay);
1837
+ };
1838
+
1839
+ await open();
1840
+
1841
+ // 5. Return unsubscribe — close WS and DELETE the subscription group
1842
+ return async () => {
1843
+ conn.closed = true;
1844
+ if (conn.reconnectTimer) { clearTimeout(conn.reconnectTimer); }
1845
+ if (conn.pingTimer) { clearInterval(conn.pingTimer); conn.pingTimer = null; }
1846
+ conn.ws?.close();
1847
+ await dropGroup(conn.deleteUrl);
1848
+ };
1849
+ }
1850
+
1851
+ // ─── Remote Mastership Privilege (RMMP) ────────────────────────────────────────
1852
+ // ABB safety: RWS users cannot send "modify" operations (jog, RAPID variable writes,
1853
+ // etc.) until they have RMMP. Requesting it triggers a FlexPendant popup that an
1854
+ // interactive operator must approve. After approval, the privilege persists for
1855
+ // the session.
1856
+
1857
+ /**
1858
+ * Effective RMMP privilege held by THIS session.
1859
+ * The controller's /users/rmmp returns whoever currently holds the privilege —
1860
+ * we have to check `rmmpheldbyme` to know whether it's us or some other user.
1861
+ * Returns 'none' if another user holds it (we'd need to re-request for our own session).
1862
+ */
1863
+ async getRmmpPrivilege(): Promise<string> {
1864
+ const xml = await this.req('GET', '/users/rmmp');
1865
+ const p = RwsClient2.parse(xml);
1866
+ const priv = p.get('privilege') ?? 'none';
1867
+ const heldByMe = (p.get('rmmpheldbyme') ?? 'false').toLowerCase() === 'true';
1868
+ if (priv === 'none') { return 'none'; }
1869
+ if (priv.startsWith('pending')) { return priv; }
1870
+ return heldByMe ? priv : 'none';
1871
+ }
1872
+
1873
+ /** Request 'modify' privilege. Triggers a FlexPendant approval popup. */
1874
+ async requestRmmp(level: 'modify' | 'exclusive' = 'modify'): Promise<void> {
1875
+ await this.req('POST', '/users/rmmp', { privilege: level });
1876
+ }
1877
+
1878
+ // ─── Jogging ─────────────────────────────────────────────────────────────────
1879
+
1880
+ /** Monotonic counter required by /rw/motionsystem/jog (controller rejects same value twice). */
1881
+ private jogCcount = 0;
1882
+
1883
+ async jog(params: {
1884
+ mode: 'Joint' | 'Cartesian';
1885
+ axes: [number, number, number, number, number, number];
1886
+ speed: number;
1887
+ mechunit?: string;
1888
+ }): Promise<void> {
1889
+ const { mode, axes, speed } = params;
1890
+ const mechunit = params.mechunit ?? 'ROB_1';
1891
+ this.jogCcount++;
1892
+
1893
+ const body = [
1894
+ `jogmode=${mode}`,
1895
+ `mechunit=${mechunit}`,
1896
+ ...axes.map((v, i) => `axis${i + 1}=${v}`),
1897
+ `cjogspeed=${speed}`,
1898
+ `ccount=${this.jogCcount}`,
1899
+ ].join('&');
1900
+
1901
+ await this.req(
1902
+ 'POST',
1903
+ '/rw/motionsystem/jog',
1904
+ undefined,
1905
+ body,
1906
+ 'application/x-www-form-urlencoded;v=2.0',
1907
+ );
1908
+ }
1909
+
1910
+ // ─── Simulation panel (virtual controllers only) ─────────────────────────────
1911
+ // RobotWare 7 VCs expose the panel hardware (e-stop chain, enabling device) and
1912
+ // a joint-teleport endpoint for simulation. Real controllers do not serve these
1913
+ // paths (404) — the FlexPendant hardware is the source of truth there — so every
1914
+ // method below translates a 404 into a clear "virtual controllers only" error.
1915
+ // All wire shapes live-verified 2026-07-09 on an OmniCore VC RW7.21.
1916
+
1917
+ /** Shared POST for the VC-only simulation endpoints. */
1918
+ private async simPost(
1919
+ label: string,
1920
+ path: string,
1921
+ body?: Record<string, string>,
1922
+ rawBody?: string,
1923
+ ): Promise<void> {
1924
+ try {
1925
+ await this.req('POST', path, body, rawBody);
1926
+ } catch (e) {
1927
+ if (e instanceof RwsError && e.httpStatus === 404) {
1928
+ throw new RwsError(
1929
+ `${label}: ${path} returned 404 — simulation endpoints exist only on RobotWare 7 virtual controllers (not on real hardware or RW6)`,
1930
+ 'UNKNOWN', 404, e.rwsDetail,
1931
+ );
1932
+ }
1933
+ throw e;
1934
+ }
1935
+ }
1936
+
1937
+ /**
1938
+ * Engage the (internal) emergency stop — controller state goes to
1939
+ * `emergencystop`. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1940
+ * POST /rw/panel/emergency-stop body `state=off` → 204.
1941
+ * The polarity is INVERTED from the ABB Swagger example: state=off OPENS the
1942
+ * safety chain (engages the stop), state=on closes it again. Fully reversible
1943
+ * on a VC via {@link simResetEmergencyStop} — no physical reset step exists
1944
+ * there (unlike real hardware, which latches until the button is released).
1945
+ */
1946
+ simEmergencyStop(): Promise<void> {
1947
+ return this.simPost('simEmergencyStop', '/rw/panel/emergency-stop', { state: 'off' });
1948
+ }
1949
+
1950
+ /** Release the simulated emergency stop (`state=on`) — controller returns to
1951
+ * `motoroff`. See {@link simEmergencyStop} for the polarity note. */
1952
+ simResetEmergencyStop(): Promise<void> {
1953
+ return this.simPost('simResetEmergencyStop', '/rw/panel/emergency-stop', { state: 'on' });
1954
+ }
1955
+
1956
+ /**
1957
+ * Engage the general stop (controller state → `guardstop`); pass `false` to
1958
+ * release it again (→ `motoroff`). Live-verified 2026-07-09 on OmniCore VC
1959
+ * RW7.21: POST /rw/panel/general-stop, `state=off` engages / `state=on`
1960
+ * releases (same inverted polarity as the e-stop endpoints).
1961
+ */
1962
+ simGeneralStop(engage = true): Promise<void> {
1963
+ return this.simPost('simGeneralStop', '/rw/panel/general-stop', { state: engage ? 'off' : 'on' });
1964
+ }
1965
+
1966
+ /**
1967
+ * Engage the automatic stop (controller state → `guardstop`); pass `false` to
1968
+ * release it. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1969
+ * POST /rw/panel/auto-stop, `state=off` engages / `state=on` releases.
1970
+ */
1971
+ simAutoStop(engage = true): Promise<void> {
1972
+ return this.simPost('simAutoStop', '/rw/panel/auto-stop', { state: engage ? 'off' : 'on' });
1973
+ }
1974
+
1975
+ /**
1976
+ * Press (`true`) or release (`false`) the simulated three-position enabling
1977
+ * device. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1978
+ * POST /rw/panel/enable-switch body `state=on|off` → 204.
1979
+ * This endpoint's polarity is direct (no inversion). In AUTO the controller
1980
+ * accepts the call as a no-op; driving motors on requires manual mode.
1981
+ */
1982
+ simEnableSwitch(on: boolean): Promise<void> {
1983
+ return this.simPost('simEnableSwitch', '/rw/panel/enable-switch', { state: on ? 'on' : 'off' });
1984
+ }
1985
+
1986
+ /**
1987
+ * Teleport a mechanical unit to absolute joint values (degrees) — the VC
1988
+ * equivalent of dragging the robot in RobotStudio; no motors, mastership, or
1989
+ * program stop needed. Live-verified 2026-07-09 on OmniCore VC RW7.21:
1990
+ * POST /rw/motionsystem/mechunits/{mechunit}/position
1991
+ * body `rob_joint=[j1,j2,j3,j4,j5,j6]&ext_joint=[e1,e2,e3,e4,e5,e6]` → 204
1992
+ * BOTH keys are required by the controller (omitting either → 400
1993
+ * "No rob_joint parameter"), which is why `extJoints` defaults to six zeros.
1994
+ * The readback (`getJointPositions`) may show sub-µdeg float rounding.
1995
+ * Caveat (live-verified 2026-07-09): while an operation-mode change is
1996
+ * pending (opmode AUTO_CH — FlexPendant acknowledge outstanding) the endpoint
1997
+ * answers 403 "Operation not allowed for user in current operation mode".
1998
+ */
1999
+ async teleportMechunit(mechunit: string, joints: number[], extJoints?: number[]): Promise<void> {
2000
+ if (joints.length !== 6 || (extJoints !== undefined && extJoints.length !== 6)) {
2001
+ throw new RwsError(
2002
+ 'teleportMechunit: exactly 6 robot joint values (and 6 external-axis values, if given) are required',
2003
+ 'UNKNOWN',
2004
+ );
2005
+ }
2006
+ const ext = extJoints ?? [0, 0, 0, 0, 0, 0];
2007
+ await this.simPost(
2008
+ 'teleportMechunit',
2009
+ `/rw/motionsystem/mechunits/${mechunit}/position`,
2010
+ undefined,
2011
+ `rob_joint=[${joints.join(',')}]&ext_joint=[${ext.join(',')}]`,
2012
+ );
2013
+ }
2014
+
2015
+ // ─── System detail endpoints ────────────────────────────────────────────────
2016
+
2017
+ async getLicenseInfo(): Promise<{ entries: Array<Record<string, string>> }> {
2018
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/license'));
2019
+ return { entries: p.getAllStates('sys-license') };
2020
+ }
2021
+
2022
+ async listProducts(): Promise<Array<Record<string, string>>> {
2023
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/products'));
2024
+ // Live-verified class: sys-product-li (with -li suffix). Each product has a _title
2025
+ // (the product name e.g. "RobotControl") plus version and version-name spans.
2026
+ return p.getAllStates('sys-product-li').map(p => ({
2027
+ name: p['_title'] ?? '',
2028
+ version: p['version'] ?? '',
2029
+ versionName: p['version-name'] ?? '',
2030
+ }));
2031
+ }
2032
+
2033
+ async getRobotType(): Promise<{ type: string; variant?: string }> {
2034
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/robottype'));
2035
+ const d = p.getState('sys-robottype');
2036
+ // Live-verified: span class is 'robot-type' (with hyphen), not 'robottype'
2037
+ return { type: d['robot-type'] ?? d['robottype'] ?? d['type'] ?? '', variant: d['variant'] };
2038
+ }
2039
+
2040
+ async getEnergyStats(): Promise<Record<string, string>> {
2041
+ const p = RwsClient2.parse(await this.req('GET', '/rw/system/energy'));
2042
+ // Live-verified class: sys-energy-state (not sys-energy)
2043
+ return p.getState('sys-energy-state');
2044
+ }
2045
+
2046
+ // ─── Return-code lookup ─────────────────────────────────────────────────────
2047
+
2048
+ async getReturnCode(code: number, lang = 'en'): Promise<{ code: number; title: string; desc: string } | null> {
2049
+ try {
2050
+ const p = RwsClient2.parse(await this.req('GET', `/rw/retcode?code=${code}&lang=${lang}`));
2051
+ const d = p.getState('rw-retcode') || p.getState('rw-retcode-li');
2052
+ if (!d['title'] && !d['desc']) { return null; }
2053
+ return { code, title: d['title'] ?? '', desc: d['desc'] ?? '' };
2054
+ } catch { return null; }
2055
+ }
2056
+
2057
+ // ─── Controller detail endpoints ────────────────────────────────────────────
2058
+
2059
+ async listControllerOptions(): Promise<Array<{ name: string; description?: string }>> {
2060
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/options'));
2061
+ return p.getAllStates('ctrl-option').map(o => ({
2062
+ name: o['option'] ?? o['name'] ?? '',
2063
+ description: o['description'],
2064
+ }));
2065
+ }
2066
+
2067
+ async listFeatures(): Promise<Array<Record<string, string>>> {
2068
+ const p = RwsClient2.parse(await this.req('GET', '/ctrl/features'));
2069
+ return p.getAllStates('ctrl-feature');
2070
+ }
2071
+
2072
+ // ─── Motion detail endpoints ────────────────────────────────────────────────
2073
+
2074
+ async getMotionChangeCount(): Promise<number> {
2075
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem?resource=change-count'));
2076
+ return Number(p.get('change-count') ?? 0);
2077
+ }
2078
+
2079
+ async getMotionErrorState(): Promise<{ state: string; details?: Record<string, string> }> {
2080
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/errorstate'));
2081
+ const d = p.getState('ms-errorstate-li') || p.getState('ms-errorstate');
2082
+ return { state: d['err-state'] ?? d['state'] ?? 'unknown', details: d };
2083
+ }
2084
+
2085
+ async getNonMotionExecution(): Promise<boolean> {
2086
+ // Live-verified: class="ms-nonmotionexecution", span "mode" returns quoted "OFF" or "ON".
2087
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/nonmotionexecution'));
2088
+ const v = (p.get('mode') ?? p.get('state') ?? 'OFF').replace(/"/g, '').toUpperCase();
2089
+ return v === 'ON';
2090
+ }
2091
+
2092
+ async setNonMotionExecution(enabled: boolean): Promise<void> {
2093
+ await this.req('POST', '/rw/motionsystem/nonmotionexecution', { mode: enabled ? 'ON' : 'OFF' });
2094
+ }
2095
+
2096
+ async getCollisionPredictionMode(): Promise<string> {
2097
+ // Live-verified: class="ms-collision-prediction-mode" with span "collision-prediction-mode-enabled"
2098
+ // returning "true" / "false". Map back to ON/OFF for caller convenience.
2099
+ const p = RwsClient2.parse(await this.req('GET', '/rw/motionsystem/collisionprediction'));
2100
+ const enabled = p.get('collision-prediction-mode-enabled') ?? p.get('mode') ?? 'false';
2101
+ return enabled.toLowerCase() === 'true' ? 'ON' : 'OFF';
2102
+ }
2103
+
2104
+ async setCollisionPredictionMode(mode: string): Promise<void> {
2105
+ await this.req('POST', '/rw/motionsystem/collisionprediction', { mode });
2106
+ }
2107
+
2108
+ // ─── Panel detail endpoints ─────────────────────────────────────────────────
2109
+
2110
+ async getEnableRequest(): Promise<{ state: string; raw: Record<string, string> }> {
2111
+ const p = RwsClient2.parse(await this.req('GET', '/rw/panel/enreq'));
2112
+ const d = p.getState('pnl-enreq') || p.getState('pnl-enreq-li');
2113
+ return { state: d['state'] ?? d['enreq'] ?? 'unknown', raw: d };
2114
+ }
2115
+
2116
+ // ─── RAPID detail endpoints ─────────────────────────────────────────────────
2117
+
2118
+ async listAliasIO(): Promise<Array<{ alias: string; signal: string }>> {
2119
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/aliasio'));
2120
+ return p.getAllStates('rap-aliasio-li').map(a => ({
2121
+ alias: a['name'] ?? a['alias'] ?? '',
2122
+ signal: a['signal'] ?? a['_title'] ?? '',
2123
+ }));
2124
+ }
2125
+
2126
+ async getTaskSelection(): Promise<{ selected: string[]; available: string[] }> {
2127
+ const p = RwsClient2.parse(await this.req('GET', '/rw/rapid/taskselection'));
2128
+ const sel = p.getAllStates('rap-taskselection-li').map(t => t['name']).filter(Boolean) as string[];
2129
+ const all = p.getAllStates('rap-task-li').map(t => t['name']).filter(Boolean) as string[];
2130
+ return { selected: sel, available: all };
2131
+ }
2132
+
2133
+ async setTaskSelection(tasks: string[]): Promise<void> {
2134
+ const body = tasks.map((t, i) => `task-${i + 1}=${encodeURIComponent(t)}`).join('&');
2135
+ await this.req('POST', '/rw/rapid/taskselection', undefined, body, 'application/x-www-form-urlencoded;v=2.0');
2136
+ }
2137
+
2138
+ async getProgramPointer(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number; executionType?: string }> {
2139
+ // Live-verified: class="pcp-info" with spans:
2140
+ // modulemame (sic — controller typo for modulename)
2141
+ // routinename
2142
+ // beginposition → "row,col" combined string
2143
+ // endposition → "row,col"
2144
+ // changecount, executiontype
2145
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/pcp`));
2146
+ const d = p.getState('pcp-info') || p.getState('program-pointer-state') || p.getState('rap-pcp-li');
2147
+ const begin = (d['beginposition'] ?? '').split(',');
2148
+ return {
2149
+ module: d['modulename'] ?? d['modulemame'] ?? d['module'],
2150
+ routine: d['routinename'] ?? d['routine'],
2151
+ row: begin[0] ? +begin[0] : (d['begin-position-row'] ? +d['begin-position-row'] : undefined),
2152
+ col: begin[1] ? +begin[1] : (d['begin-position-col'] ? +d['begin-position-col'] : undefined),
2153
+ executionType: d['executiontype'],
2154
+ };
2155
+ }
2156
+
2157
+ async getMotionPointer(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number; state?: string }> {
2158
+ // Live-verified: /syncstate/motion-pointer returns class="rap-task-sync-state"
2159
+ // with a single span class="motion-pointer-state" containing 'Off' or position info.
2160
+ const p = RwsClient2.parse(await this.req('GET', `/rw/rapid/tasks/${task}/syncstate/motion-pointer`));
2161
+ const d = p.getState('rap-task-sync-state');
2162
+ const stateVal = d['motion-pointer-state'] ?? '';
2163
+ return {
2164
+ module: d['modulename'] ?? d['modulemame'] ?? d['module'],
2165
+ routine: d['routinename'] ?? d['routine'],
2166
+ row: d['begin-position-row'] ? +d['begin-position-row'] : undefined,
2167
+ col: d['begin-position-col'] ? +d['begin-position-col'] : undefined,
2168
+ state: stateVal,
2169
+ };
2170
+ }
2171
+
2172
+ // ─── Inverse kinematics ───────────────────────────────────────────────────────
2173
+
2174
+ async calcJointsFromCartesian(
2175
+ pos: RobTarget,
2176
+ seedJoints?: JointTarget,
2177
+ mechunit = 'ROB_1',
2178
+ ): Promise<JointTarget> {
2179
+ const seed = seedJoints
2180
+ ? `[${seedJoints.rax_1},${seedJoints.rax_2},${seedJoints.rax_3},${seedJoints.rax_4},${seedJoints.rax_5},${seedJoints.rax_6}]`
2181
+ : '[0,0,0,0,0,0]';
2182
+
2183
+ const bodyStr = [
2184
+ `curr_position=[${pos.x},${pos.y},${pos.z}]`,
2185
+ `curr_orientation=[${pos.q1},${pos.q2},${pos.q3},${pos.q4}]`,
2186
+ `curr_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
2187
+ `old_rob_joints=${seed}`,
2188
+ `old_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
2189
+ `robot_fixed_object=false`,
2190
+ `tool_frame_position=[0,0,0]`,
2191
+ `tool_frame_orientation=[1,0,0,0]`,
2192
+ `wobj_frame_position=[0,0,0]`,
2193
+ `wobj_frame_orientation=[1,0,0,0]`,
2194
+ `robot_configuration=[0,0,0,0]`,
2195
+ `elog_at_error=false`,
2196
+ ].join('&');
2197
+
2198
+ const html = await this.req(
2199
+ 'POST',
2200
+ `/rw/motionsystem/mechunits/${mechunit}/joints-from-cartesian`,
2201
+ undefined,
2202
+ bodyStr,
2203
+ 'application/x-www-form-urlencoded;v=2.0',
2204
+ );
2205
+ const p = RwsClient2.parse(html);
2206
+ const d = p.getState('ms-jointtarget');
2207
+ if (!d['rax_1']) { throw new Error('IK: no joint values in response'); }
2208
+ return {
2209
+ rax_1: +d['rax_1'], rax_2: +d['rax_2'], rax_3: +d['rax_3'],
2210
+ rax_4: +d['rax_4'], rax_5: +d['rax_5'], rax_6: +d['rax_6'],
2211
+ };
2212
+ }
2213
+
2214
+ }