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,1018 @@
1
+ import { RwsClient } from './RwsClient.js';
2
+ import type {
3
+ ExecutionCycle, JointTarget, RobTarget, RapidSymbolSearchParams,
4
+ RestartMode, MastershipDomain, SubscriptionResource, SubscriptionEvent,
5
+ } from './types.js';
6
+ import * as http from 'http';
7
+ import * as crypto from 'crypto';
8
+ import type { IRWSAdapter } from './IRWSAdapter.js';
9
+
10
+ interface RWS1Credentials {
11
+ host: string;
12
+ port: number;
13
+ username: string;
14
+ password: string;
15
+ }
16
+
17
+ /**
18
+ * RWS 1.0 adapter — thin wrapper around RwsClient (abb-rws-client).
19
+ * Every method delegates directly; zero logic change from pre-adapter behavior.
20
+ * Targets ABB IRC5 controllers running RobotWare 6.x.
21
+ */
22
+ export class RWS1Adapter implements IRWSAdapter {
23
+ constructor(
24
+ private readonly client: RwsClient,
25
+ private readonly creds?: RWS1Credentials,
26
+ ) {}
27
+
28
+ // ── Connection ──────────────────────────────────────────────────────────
29
+ connect() { return this.client.connect(); }
30
+ disconnect() { return this.client.disconnect(); }
31
+ getSessionCookie() { return this.client.getSessionCookie(); }
32
+
33
+ // ── Panel ───────────────────────────────────────────────────────────────
34
+ getControllerState() { return this.client.getControllerState(); }
35
+ setControllerState(s: 'motoron' | 'motoroff') { return this.client.setControllerState(s); }
36
+ getOperationMode() { return this.client.getOperationMode(); }
37
+ getSpeedRatio() { return this.client.getSpeedRatio(); }
38
+ setSpeedRatio(r: number) { return this.client.setSpeedRatio(r); }
39
+ getCollisionDetectionState() { return this.client.getCollisionDetectionState(); }
40
+ lockOperationMode(pin: string, p?: boolean) { return this.client.lockOperationMode(pin, p); }
41
+ unlockOperationMode() { return this.client.unlockOperationMode(); }
42
+ setOperationMode(mode: 'AUTO' | 'MANR' | 'MANF') { return this.client.setOperationMode(mode); }
43
+
44
+ // ── RAPID execution ─────────────────────────────────────────────────────
45
+ getRapidExecutionState() { return this.client.getRapidExecutionState(); }
46
+ getRapidExecutionInfo() { return this.client.getRapidExecutionInfo(); }
47
+ startRapid() { return this.client.startRapid(); }
48
+ stopRapid() { return this.client.stopRapid(); }
49
+ resetRapid() { return this.client.resetRapid(); }
50
+ setExecutionCycle(c: ExecutionCycle){ return this.client.setExecutionCycle(c); }
51
+ getRapidTasks() { return this.client.getRapidTasks(); }
52
+ activateRapidTask(t: string) { return this.client.activateRapidTask(t); }
53
+ deactivateRapidTask(t: string) { return this.client.deactivateRapidTask(t); }
54
+ activateAllRapidTasks() { return this.client.activateAllRapidTasks(); }
55
+ deactivateAllRapidTasks() { return this.client.deactivateAllRapidTasks(); }
56
+
57
+ // ── RAPID modules & variables ───────────────────────────────────────────
58
+ listModules(task: string) { return this.client.listModules(task); }
59
+
60
+ /** RWS 1.0 module-list also exposes name + type (SysMod / ProgMod) per entry. */
61
+ async listModulesDetailed(task: string): Promise<Array<{ name: string; type: string }>> {
62
+ const r = await this.rws1Get(`/rw/rapid/modules?task=${encodeURIComponent(task)}`);
63
+ return r.states
64
+ .map(s => ({ name: (s as Record<string,string>)['name'] ?? '', type: (s as Record<string,string>)['type'] ?? '' }))
65
+ .filter(m => m.name);
66
+ }
67
+ loadModule(task: string, path: string, r?: boolean) { return this.client.loadModule(task, path, r); }
68
+ unloadModule(task: string, name: string) { return this.client.unloadModule(task, name); }
69
+ getRapidVariable(t: string, m: string, s: string) { return this.client.getRapidVariable(t, m, s); }
70
+ setRapidVariable(t: string, m: string, s: string, v: string) { return this.client.setRapidVariable(t, m, s, v); }
71
+ validateRapidValue(t: string, v: string, d: string) { return this.client.validateRapidValue(t, v, d); }
72
+ getRapidSymbolProperties(t: string, m: string, s: string) { return this.client.getRapidSymbolProperties(t, m, s); }
73
+ searchRapidSymbols(p: RapidSymbolSearchParams) { return this.client.searchRapidSymbols(p); }
74
+ getActiveUiInstruction() { return this.client.getActiveUiInstruction(); }
75
+ setUiInstructionParam(su: string, up: string, v: string) { return this.client.setUiInstructionParam(su, up, v); }
76
+
77
+ // ── Motion ──────────────────────────────────────────────────────────────
78
+ getJointPositions(u?: string) { return this.client.getJointPositions(u); }
79
+ getCartesianFull(u?: string) { return this.client.getCartesianFull(u); }
80
+ /** List mechanical units from the controller (positioners/track units show up beyond ROB_1). */
81
+ async listMechunits(): Promise<string[]> {
82
+ const r = await this.rws1Get('/rw/motionsystem/mechunits');
83
+ const units = r.states
84
+ .map(s => ((s as Record<string, string>)['_title'] ?? (s as Record<string, string>)['name']))
85
+ .filter(Boolean) as string[];
86
+ // A controller always has at least one mechunit — an empty list means the
87
+ // response shape drifted; fall back to the standard unit rather than none.
88
+ return units.length > 0 ? units : ['ROB_1'];
89
+ }
90
+
91
+ // ── System info ─────────────────────────────────────────────────────────
92
+ getSystemInfo() { return this.client.getSystemInfo(); }
93
+ getControllerIdentity(){ return this.client.getControllerIdentity(); }
94
+ getControllerClock() { return this.client.getControllerClock(); }
95
+ setControllerClock(Y: number, Mo: number, D: number, H: number, Mi: number, S: number) {
96
+ return this.client.setControllerClock(Y, Mo, D, H, Mi, S);
97
+ }
98
+ restartController(m: RestartMode) { return this.client.restartController(m); }
99
+
100
+ // ── Event log ───────────────────────────────────────────────────────────
101
+ getEventLog(d?: number, l?: string) { return this.client.getEventLog(d, l); }
102
+ clearEventLog(d?: number) { return this.client.clearEventLog(d); }
103
+ clearAllEventLogs() { return this.client.clearAllEventLogs(); }
104
+
105
+ // ── I/O ────────────────────────────────────────────────────────────────
106
+ listAllSignals(s?: number, l?: number) { return this.client.listAllSignals(s, l); }
107
+ readSignal(n: string, d: string, name: string) { return this.client.readSignal(n, d, name); }
108
+ writeSignal(n: string, d: string, name: string, v: string) { return this.client.writeSignal(n, d, name, v); }
109
+ listNetworks() { return this.client.listNetworks(); }
110
+ listDevices(network: string) { return this.client.listDevices(network); }
111
+
112
+ // ── File system ─────────────────────────────────────────────────────────
113
+ listDirectory(path: string) { return this.client.listDirectory(path); }
114
+ readFile(path: string) { return this.client.readFile(path); }
115
+ uploadFile(path: string, content: string) { return this.client.uploadModule(path, content); }
116
+ deleteFile(path: string) { return this.client.deleteFile(path); }
117
+ createDirectory(parent: string, dir: string) { return this.client.createDirectory(parent, dir); }
118
+ copyFile(src: string, dst: string) { return this.client.copyFile(src, dst); }
119
+
120
+ // ── Mastership ──────────────────────────────────────────────────────────
121
+ requestMastership(d: MastershipDomain) { return this.client.requestMastership(d); }
122
+ releaseMastership(d: MastershipDomain) { return this.client.releaseMastership(d); }
123
+
124
+ /** Request mastership on all domains (cfg + motion + rapid) at once. */
125
+ async requestMastershipAll(): Promise<void> {
126
+ await this.rws1Post('/rw/mastership?action=request', '');
127
+ }
128
+ /** Release mastership on all domains at once. */
129
+ async releaseMastershipAll(): Promise<void> {
130
+ await this.rws1Post('/rw/mastership?action=release', '');
131
+ }
132
+ /**
133
+ * RWS 1.0 doesn't expose `request-with-id` / `release-with-id` — those are
134
+ * RWS 2.0 / RobotWare 7+ additions. The `?` in the IRWSAdapter signature
135
+ * means we don't have to implement on this side; calls just throw.
136
+ */
137
+ /**
138
+ * RWS 1.0 doesn't expose a watchdog endpoint — heartbeat is RWS 2.0 only.
139
+ * The optional method on IRWSAdapter is left undefined here so callers can
140
+ * feature-detect (`if ('resetMastershipWatchdog' in adapter)`).
141
+ */
142
+ /** Read mastership status for one domain. */
143
+ async getMastershipStatus(d: MastershipDomain): Promise<{ mastership: string; uid?: string; application?: string }> {
144
+ const r = await this.rws1Get(`/rw/mastership/${d}`);
145
+ const s = r.state as { mastership?: string; uid?: string; application?: string } | null;
146
+ return { mastership: s?.mastership ?? 'unknown', uid: s?.uid, application: s?.application };
147
+ }
148
+ /** List mastership domains (RWS 1.0: ['cfg', 'motion', 'rapid']). */
149
+ async listMastershipDomains(): Promise<string[]> {
150
+ const r = await this.rws1Get('/rw/mastership');
151
+ return r.states.map(s => (s as Record<string, string>)['_title']).filter(Boolean);
152
+ }
153
+
154
+ // ── Devices ────────────────────────────────────────────────────────────
155
+ async listSystemDevices(): Promise<Array<{ id: string; name: string }>> {
156
+ // RWS 1.0 nests the dev-id-li array inside _state[0].devices (different from the
157
+ // RWS 2.0 XHTML layout where each <li class="dev-id-li"> is a top-level child).
158
+ const r = await this.rws1Get('/rw/devices');
159
+ const devices = (r.state as { devices?: Array<Record<string, string>> } | null)?.devices ?? [];
160
+ return devices.map(d => ({
161
+ id: d['_title'] ?? '',
162
+ name: d['name'] ?? '',
163
+ }));
164
+ }
165
+ async getDeviceTree(group: string): Promise<string> {
166
+ const res = await this.client.request('GET', `/rw/devices/${encodeURIComponent(group)}?json=1`);
167
+ return res.body;
168
+ }
169
+ async listAllIoDevices(): Promise<Array<{ name: string; network: string; lstate: string; pstate: string; address: string }>> {
170
+ const r = await this.rws1Get('/rw/iosystem/devices');
171
+ return r.states.map(state => {
172
+ const s = state as Record<string, string>;
173
+ const title = s['_title'] ?? '';
174
+ return {
175
+ name: s['name'] ?? '',
176
+ network: title.split('/')[0] ?? '',
177
+ lstate: s['lstate'] ?? '',
178
+ pstate: s['pstate'] ?? '',
179
+ address: s['address'] ?? '',
180
+ };
181
+ });
182
+ }
183
+
184
+ // ── Forward kinematics ──────────────────────────────────────────────────
185
+ /**
186
+ * Forward kinematics on RWS 1.0. Same VC-license caveat as IK.
187
+ */
188
+ async calcCartesianFromJoints(
189
+ joints: JointTarget,
190
+ mechunit = 'ROB_1',
191
+ tool = 'tool0',
192
+ wobj = 'wobj0',
193
+ ): Promise<RobTarget> {
194
+ if (!this.creds) { throw new Error('FK requires credentials in adapter constructor'); }
195
+ const { host, port, username, password } = this.creds;
196
+ const body = [
197
+ `curr_joints=[${joints.rax_1},${joints.rax_2},${joints.rax_3},${joints.rax_4},${joints.rax_5},${joints.rax_6}]`,
198
+ `curr_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
199
+ `tool=${tool}`,
200
+ `wobj=${wobj}`,
201
+ ].join('&');
202
+ const path = `/rw/motionsystem/mechunits/${mechunit}?action=CalcRobTFromJoints&json=1`;
203
+ const result = await this.digestPost(host, port, path, body, username, password) as { _embedded?: { _state?: Array<Record<string, string>> } };
204
+ const state = result._embedded?._state?.[0];
205
+ if (!state) { throw new Error('FK: no result in response'); }
206
+ return {
207
+ x: +state.x, y: +state.y, z: +state.z,
208
+ q1: +state.q1, q2: +state.q2, q3: +state.q3, q4: +state.q4,
209
+ };
210
+ }
211
+
212
+ subscribe(resources: SubscriptionResource[], handler: (event: SubscriptionEvent) => void, _onLost?: () => void) {
213
+ // _onLost accepted for IRWSAdapter parity; RWS 1.0's WsSubscriber handles
214
+ // its own reconnects and has no terminal give-up signal to forward yet.
215
+ return this.client.subscribe(resources, handler);
216
+ }
217
+
218
+ // ── Jogging ─────────────────────────────────────────────────────────────
219
+
220
+ /** Monotonic counter required by RWS jog endpoint (rejects duplicate ccount values). */
221
+ private jogCcount = 0;
222
+
223
+ async jog(params: {
224
+ mode: 'Joint' | 'Cartesian';
225
+ axes: [number, number, number, number, number, number];
226
+ speed: number;
227
+ mechunit?: string;
228
+ }): Promise<void> {
229
+ if (!this.creds) {
230
+ throw new Error('Jog requires credentials — reconnect to enable');
231
+ }
232
+ const { mode, axes, speed } = params;
233
+ const mechunit = params.mechunit ?? 'ROB_1';
234
+ this.jogCcount++;
235
+
236
+ const bodyStr = [
237
+ `jogmode=${mode}`,
238
+ `mechunit=${mechunit}`,
239
+ ...axes.map((v, i) => `axis${i + 1}=${v}`),
240
+ `cjogspeed=${speed}`,
241
+ `ccount=${this.jogCcount}`,
242
+ ].join('&');
243
+
244
+ const { host, port, username, password } = this.creds;
245
+ const path = `/rw/motionsystem?action=jog&json=1`;
246
+ const result = await this.digestPost(host, port, path, bodyStr, username, password);
247
+ // Successful jog has no useful body — only check for error status.
248
+ const status = (result._embedded as { status?: { msg?: string } } | undefined)?.status;
249
+ if (status?.msg && status.msg.length > 0 && /error|fail/i.test(status.msg)) {
250
+ throw new Error(status.msg);
251
+ }
252
+ }
253
+
254
+ // ── RWS 1.0 helper — typed wrapper around client.request() with JSON parsing ──
255
+
256
+ /**
257
+ * Generic GET that returns `_embedded._state[0]` (single resource) or [] (list).
258
+ * Most RWS 1.0 endpoints with `?json=1` return this HAL-like envelope.
259
+ * Returns empty result for HTTP 204 (no content) — common on /ctrl/options etc.
260
+ */
261
+ private async rws1Get(path: string): Promise<{ status: number; state: Record<string, unknown> | null; states: Array<Record<string, unknown>>; raw: unknown }> {
262
+ const url = path + (path.includes('?') ? '&' : '?') + 'json=1';
263
+ const res = await this.client.request('GET', url);
264
+ if (res.status === 204 || !res.body) {
265
+ return { status: res.status, state: null, states: [], raw: null };
266
+ }
267
+ if (res.status >= 400) {
268
+ let msg = `HTTP ${res.status}`;
269
+ try { msg = JSON.parse(res.body)._embedded?.status?.msg ?? msg; } catch { /* ok */ }
270
+ throw new Error(msg);
271
+ }
272
+ let parsed: { _embedded?: { _state?: Array<Record<string, unknown>> } } = {};
273
+ try { parsed = JSON.parse(res.body); } catch { /* non-JSON ok */ }
274
+ const states = parsed._embedded?._state ?? [];
275
+ return { status: res.status, state: states[0] ?? null, states, raw: parsed };
276
+ }
277
+
278
+ /** Generic POST that throws on >=400, returns the parsed JSON body. */
279
+ private async rws1Post(path: string, body?: string): Promise<unknown> {
280
+ const url = path + (path.includes('?') ? '&' : '?') + 'json=1';
281
+ const res = await this.client.request('POST', url, body);
282
+ if (res.status >= 400) {
283
+ let msg = `HTTP ${res.status}`;
284
+ try { msg = JSON.parse(res.body)._embedded?.status?.msg ?? msg; } catch { /* ok */ }
285
+ throw new Error(msg);
286
+ }
287
+ try { return JSON.parse(res.body); } catch { return null; }
288
+ }
289
+
290
+ // ── System detail ───────────────────────────────────────────────────────
291
+
292
+ async getRobotType(): Promise<{ type: string; variant?: string }> {
293
+ const r = await this.rws1Get('/rw/system/robottype');
294
+ const s = r.state as { 'robot-type'?: string; type?: string; variant?: string } | null;
295
+ return { type: s?.['robot-type'] ?? s?.type ?? '', variant: s?.variant };
296
+ }
297
+
298
+ async getLicenseInfo(): Promise<{ entries: Array<Record<string, string>> }> {
299
+ // RWS 1.0 path is singular `/license`. Doc 6.8 has it as plural `/licenses`
300
+ // but live IRC5 returns 404 for that — singular works.
301
+ const r = await this.rws1Get('/rw/system/license');
302
+ return { entries: r.states as Array<Record<string, string>> };
303
+ }
304
+
305
+ async listProducts(): Promise<Array<Record<string, string>>> {
306
+ const r = await this.rws1Get('/rw/system/products');
307
+ return r.states as Array<Record<string, string>>;
308
+ }
309
+
310
+ async getEnergyStats(): Promise<Record<string, string>> {
311
+ try {
312
+ const r = await this.rws1Get('/rw/system/energy');
313
+ return (r.state as Record<string, string>) ?? {};
314
+ } catch { return {}; }
315
+ }
316
+
317
+ // ── Return code lookup ─────────────────────────────────────────────────
318
+
319
+ async getReturnCode(code: number, lang = 'en'): Promise<{ code: number; title: string; desc: string } | null> {
320
+ try {
321
+ const r = await this.rws1Get(`/rw/retcode?code=${code}&lang=${lang}`);
322
+ const s = r.state as { title?: string; desc?: string } | null;
323
+ if (!s) { return null; }
324
+ return { code, title: s.title ?? '', desc: s.desc ?? '' };
325
+ } catch { return null; }
326
+ }
327
+
328
+ // ── Controller detail ──────────────────────────────────────────────────
329
+
330
+ async listControllerOptions(): Promise<Array<{ name: string; description?: string }>> {
331
+ try {
332
+ const r = await this.rws1Get('/ctrl/options');
333
+ return r.states.map(o => ({
334
+ name: (o.option ?? o.name ?? '') as string,
335
+ description: o.description as string | undefined,
336
+ }));
337
+ } catch { return []; }
338
+ }
339
+
340
+ // ── Motion detail ──────────────────────────────────────────────────────
341
+
342
+ async getMotionChangeCount(): Promise<number> {
343
+ const r = await this.rws1Get('/rw/motionsystem');
344
+ const s = r.state as { 'change-count'?: string } | null;
345
+ return Number(s?.['change-count'] ?? 0);
346
+ }
347
+
348
+ async getMotionErrorState(): Promise<{ state: string; details?: Record<string, string> }> {
349
+ const r = await this.rws1Get('/rw/motionsystem/errorstate');
350
+ const s = r.state as Record<string, string> | null;
351
+ return { state: s?.['err-state'] ?? s?.state ?? 'unknown', details: s ?? undefined };
352
+ }
353
+
354
+ async getNonMotionExecution(): Promise<boolean> {
355
+ const r = await this.rws1Get('/rw/motionsystem/nonmotionexecution');
356
+ const s = r.state as { mode?: string } | null;
357
+ return (s?.mode ?? '').toUpperCase() === 'ON';
358
+ }
359
+
360
+ async setNonMotionExecution(enabled: boolean): Promise<void> {
361
+ await this.rws1Post('/rw/motionsystem/nonmotionexecution?action=set', `mode=${enabled ? 'ON' : 'OFF'}`);
362
+ }
363
+
364
+ async getMechunitInfo(mechunit = 'ROB_1'): Promise<Record<string, string>> {
365
+ const r = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}`);
366
+ return (r.state as Record<string, string>) ?? {};
367
+ }
368
+
369
+ async getMechunitBaseFrame(mechunit = 'ROB_1'): Promise<{ x: number; y: number; z: number; q1: number; q2: number; q3: number; q4: number }> {
370
+ const r = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}/baseframe`);
371
+ const s = (r.state as Record<string, string>) ?? {};
372
+ return { x: +s.x, y: +s.y, z: +s.z, q1: +s.q1, q2: +s.q2, q3: +s.q3, q4: +s.q4 };
373
+ }
374
+
375
+ async getMechunitAxes(mechunit = 'ROB_1'): Promise<Array<Record<string, string>>> {
376
+ // RWS 1.0 returns 2 entries: an axis-count summary and a sub-resource link list.
377
+ // Fetch each axis individually to get its real data.
378
+ const r = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}/axes`);
379
+ const summary = r.states.find(s => s._type === 'ms-mechunit-axes');
380
+ const count = +((summary as { axes?: string } | undefined)?.axes ?? '0');
381
+ if (count === 0) { return []; }
382
+ const axes: Array<Record<string, string>> = [];
383
+ for (let i = 1; i <= count; i++) {
384
+ try {
385
+ const ar = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}/axes/${i}`);
386
+ axes.push({ axis: String(i), ...((ar.state as Record<string, string>) ?? {}) });
387
+ } catch { axes.push({ axis: String(i), error: 'unreachable' }); }
388
+ }
389
+ return axes;
390
+ }
391
+
392
+ async getActiveTool(mechunit = 'ROB_1'): Promise<{ name: string; data?: Record<string, string> }> {
393
+ const r = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}`);
394
+ const s = (r.state as Record<string, string>) ?? {};
395
+ return { name: s['tool-name'] ?? 'tool0' };
396
+ }
397
+
398
+ async getActiveWobj(mechunit = 'ROB_1'): Promise<{ name: string; data?: Record<string, string> }> {
399
+ const r = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}`);
400
+ const s = (r.state as Record<string, string>) ?? {};
401
+ return { name: s['wobj-name'] ?? 'wobj0' };
402
+ }
403
+
404
+ async getActivePayload(mechunit = 'ROB_1'): Promise<{ name: string; data?: Record<string, string> }> {
405
+ const r = await this.rws1Get(`/rw/motionsystem/mechunits/${mechunit}`);
406
+ const s = (r.state as Record<string, string>) ?? {};
407
+ return { name: s['total-payload-name'] ?? s['payload-name'] ?? 'load0' };
408
+ }
409
+
410
+ // ── RAPID detail ───────────────────────────────────────────────────────
411
+
412
+ async listAliasIO(): Promise<Array<{ alias: string; signal: string }>> {
413
+ try {
414
+ const r = await this.rws1Get('/rw/rapid/aliasio');
415
+ return r.states.map(a => ({
416
+ alias: (a.name ?? a.alias ?? '') as string,
417
+ signal: (a.signal ?? a._title ?? '') as string,
418
+ }));
419
+ } catch { return []; }
420
+ }
421
+
422
+ async getProgramPointer(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number }> {
423
+ try {
424
+ const r = await this.rws1Get(`/rw/rapid/tasks/${task}/pcp`);
425
+ const s = (r.state as Record<string, string>) ?? {};
426
+ const begin = (s.beginposition ?? '').split(',');
427
+ return {
428
+ module: s.modulename ?? s.modulemame ?? s.module,
429
+ routine: s.routinename ?? s.routine,
430
+ row: begin[0] ? +begin[0] : undefined,
431
+ col: begin[1] ? +begin[1] : undefined,
432
+ };
433
+ } catch { return {}; }
434
+ }
435
+
436
+ async getMotionPointer(task: string): Promise<{ module?: string; routine?: string; row?: number; col?: number }> {
437
+ // RWS 1.0 path is /rw/rapid/tasks/{task}/motion (per official doc 6.7)
438
+ try {
439
+ const r = await this.rws1Get(`/rw/rapid/tasks/${task}/motion`);
440
+ const s = (r.state as Record<string, string>) ?? {};
441
+ return {
442
+ module: s.modulename ?? s.modulemame ?? s.module,
443
+ routine: s.routinename ?? s.routine,
444
+ };
445
+ } catch { return {}; }
446
+ }
447
+
448
+ // ── CFG database ───────────────────────────────────────────────────────
449
+
450
+ async listCfgDomains(): Promise<string[]> {
451
+ const r = await this.rws1Get('/rw/cfg');
452
+ return r.states.map(d => (d._title ?? d.name) as string).filter(Boolean);
453
+ }
454
+
455
+ async listCfgTypes(domain: string): Promise<string[]> {
456
+ const types: string[] = [];
457
+ let path = `/rw/cfg/${domain}`;
458
+ let pages = 0;
459
+ while (path && pages < 50) {
460
+ const r = await this.rws1Get(path);
461
+ const ts = r.states.map(t => (t._title ?? t.name) as string).filter(Boolean);
462
+ types.push(...ts);
463
+ // RWS 1.0 pagination: `_links.next.href` in the response
464
+ const links = (r.raw as { _links?: { next?: { href?: string } } } | undefined)?._links;
465
+ const next = links?.next?.href;
466
+ if (next && pages < 49) {
467
+ path = '/rw/cfg/' + next.replace(/^\/+/, '').replace(/^cfg\//, '').replace(/&amp;/g, '&').replace(/[?&]json=1/, '');
468
+ } else { path = ''; }
469
+ pages++;
470
+ }
471
+ return types;
472
+ }
473
+
474
+ async listCfgInstances(domain: string, type: string): Promise<string[]> {
475
+ try {
476
+ const r = await this.rws1Get(`/rw/cfg/${domain}/${type}/instances`);
477
+ return r.states.map(i => (i._title ?? i.name) as string).filter(Boolean);
478
+ } catch { return []; }
479
+ }
480
+
481
+ async getCfgInstance(domain: string, type: string, instance: string): Promise<Record<string, string>> {
482
+ // RWS 1.0 inlines all attribute data in the instance-list response. The single-instance
483
+ // GET also works at `/instances/{name}`. Use the list call (one HTTP request) and find
484
+ // by _title — also handles instance names with spaces/special chars correctly.
485
+ const r = await this.rws1Get(`/rw/cfg/${domain}/${type}/instances`);
486
+ const target = r.states.find(s => s._title === instance);
487
+ if (!target) { return {}; }
488
+
489
+ const out: Record<string, string> = {};
490
+
491
+ // Attributes can come in two shapes on RWS 1.0:
492
+ // 1. Inline `attrib` array of { _title: name, value }
493
+ // 2. Direct keyed properties on the state object
494
+ const attribs = (target as { attrib?: Array<{ _title?: string; value?: string }> }).attrib;
495
+ if (Array.isArray(attribs)) {
496
+ for (const a of attribs) {
497
+ if (a._title) { out[a._title] = String(a.value ?? ''); }
498
+ }
499
+ }
500
+ // Always include direct properties (rdonly, instanceid, etc.) — useful metadata.
501
+ for (const [k, v] of Object.entries(target)) {
502
+ if (k.startsWith('_') || k === 'attrib') { continue; }
503
+ if (typeof v === 'string') { out[k] = v; }
504
+ }
505
+ return out;
506
+ }
507
+
508
+ /**
509
+ * Update attributes on an existing configuration instance.
510
+ * Live-verified 2026-07-09 on IRC5 VC RW6.16 via probe-cfg-rws1.mjs:
511
+ * ✓ POST /rw/cfg/{domain}/{type}/instances/{instance}?action=set
512
+ * body: PLAIN form values `Attr=value&…` (percent-encoded) → 204,
513
+ * partial attribute sets accepted.
514
+ * Acquires cfg-domain mastership around the write. (The VC accepts cfg
515
+ * writes without it, but real controllers arbitrate cfg access through
516
+ * mastership — and taking it when free is harmless.)
517
+ */
518
+ async setCfgInstance(domain: string, type: string, instance: string, attrs: Record<string, string>): Promise<void> {
519
+ await this.client.requestMastership('cfg');
520
+ try {
521
+ await this.postCfgSet(domain, type, instance, attrs);
522
+ } finally {
523
+ await this.client.releaseMastership('cfg').catch(() => {});
524
+ }
525
+ }
526
+
527
+ /**
528
+ * Create a new configuration instance, then apply `attrs`.
529
+ * Live-verified 2026-07-09 on IRC5 VC RW6.16:
530
+ * ✓ POST /rw/cfg/{domain}/{type}/instances?action=create-default
531
+ * body name={instance} → 201 (duplicate name → 400), then the
532
+ * ?action=set shape above for the attribute values.
533
+ * Acquires cfg-domain mastership once around the create+set pair.
534
+ */
535
+ async createCfgInstance(domain: string, type: string, instance: string, attrs: Record<string, string>): Promise<void> {
536
+ await this.client.requestMastership('cfg');
537
+ try {
538
+ await this.rws1Post(
539
+ `/rw/cfg/${domain}/${type}/instances?action=create-default`,
540
+ `name=${encodeURIComponent(instance)}`,
541
+ );
542
+ if (Object.keys(attrs).length > 0) {
543
+ await this.postCfgSet(domain, type, instance, attrs);
544
+ }
545
+ } finally {
546
+ await this.client.releaseMastership('cfg').catch(() => {});
547
+ }
548
+ }
549
+
550
+ /**
551
+ * Delete a configuration instance.
552
+ * Live-verified 2026-07-09 on IRC5 VC RW6.16:
553
+ * ✓ DELETE /rw/cfg/{domain}/{type}/instances/{instance} → 204
554
+ * (reading the instance back afterwards → 400 "unknown instance",
555
+ * unlike RWS 2.0 which answers 404).
556
+ */
557
+ async removeCfgInstance(domain: string, type: string, instance: string): Promise<void> {
558
+ await this.client.requestMastership('cfg');
559
+ try {
560
+ const res = await this.client.request('DELETE', `/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}?json=1`);
561
+ if (res.status >= 400) {
562
+ let msg = `HTTP ${res.status}`;
563
+ try { msg = JSON.parse(res.body)._embedded?.status?.msg ?? msg; } catch { /* ok */ }
564
+ throw new Error(msg);
565
+ }
566
+ } finally {
567
+ await this.client.releaseMastership('cfg').catch(() => {});
568
+ }
569
+ }
570
+
571
+ /** Shared ?action=set POST — plain `Attr=value` form pairs (RWS 1.0 wire shape). */
572
+ private async postCfgSet(domain: string, type: string, instance: string, attrs: Record<string, string>): Promise<void> {
573
+ const body = Object.entries(attrs).map(([k, v]) => `${k}=${encodeURIComponent(v)}`).join('&');
574
+ await this.rws1Post(`/rw/cfg/${domain}/${type}/instances/${encodeURIComponent(instance)}?action=set`, body);
575
+ }
576
+
577
+ // ── Backup ─────────────────────────────────────────────────────────────
578
+
579
+ async listBackups(): Promise<Array<{ name: string; created?: string; size?: number }>> {
580
+ try {
581
+ const entries = await this.client.listDirectory('$BACKUP');
582
+ return entries.filter(e => e.type === 'dir').map(e => ({
583
+ name: e.name,
584
+ created: e.created,
585
+ }));
586
+ } catch { return []; }
587
+ }
588
+
589
+ async getBackupStatus(): Promise<{ active: boolean; progress?: number; phase?: string }> {
590
+ try {
591
+ const r = await this.rws1Get('/ctrl/backup');
592
+ const s = (r.state as Record<string, string>) ?? {};
593
+ const phase = s['progress-state'] ?? s.phase ?? '';
594
+ return {
595
+ active: phase !== '' && phase !== 'idle' && phase !== 'finished',
596
+ progress: s.progress ? +s.progress : undefined,
597
+ phase,
598
+ };
599
+ } catch { return { active: false }; }
600
+ }
601
+
602
+ // ── RMMP ───────────────────────────────────────────────────────────────
603
+
604
+ async getRmmpPrivilege(): Promise<string> {
605
+ try {
606
+ const r = await this.rws1Get('/users/rmmp');
607
+ const s = (r.state as Record<string, string>) ?? {};
608
+ const priv = s.privilege ?? 'none';
609
+ const heldByMe = (s.rmmpheldbyme ?? 'false').toLowerCase() === 'true';
610
+ if (priv === 'none' || priv.startsWith('pending')) { return priv; }
611
+ return heldByMe ? priv : 'none';
612
+ } catch { return 'none'; }
613
+ }
614
+
615
+ async requestRmmp(level: 'modify' | 'exclusive' = 'modify'): Promise<void> {
616
+ await this.rws1Post('/users/rmmp', `privilege=${level}`);
617
+ }
618
+
619
+ // ── Stage 7: Backup / Restore / Progress (5 methods) ───────────────────
620
+
621
+ async createBackup(name: string): Promise<void> {
622
+ // Async — controller returns 202 + Location header pointing to /progress/{id}.
623
+ // Caller polls getProgress() to track completion.
624
+ await this.rws1Post('/ctrl/backup?action=backup', `backup=$BACKUP/${encodeURIComponent(name)}`);
625
+ }
626
+
627
+ async restoreBackup(name: string): Promise<void> {
628
+ await this.rws1Post('/ctrl/backup?action=restore', `backup=$BACKUP/${encodeURIComponent(name)}`);
629
+ }
630
+
631
+ async listProgress(): Promise<Array<{ id: string; state: string }>> {
632
+ try {
633
+ const r = await this.rws1Get('/progress');
634
+ return r.states.map(p => ({
635
+ id: (p._title ?? p.id ?? '') as string,
636
+ state: (p.state ?? '') as string,
637
+ }));
638
+ } catch { return []; }
639
+ }
640
+
641
+ async getProgress(id: string): Promise<{ state: string; details?: Record<string, string> } | null> {
642
+ try {
643
+ const r = await this.rws1Get(`/progress/${encodeURIComponent(id)}`);
644
+ const s = r.state as Record<string, string> | null;
645
+ if (!s) { return null; }
646
+ return { state: s.state ?? '', details: s };
647
+ } catch { return null; }
648
+ }
649
+
650
+ // ── Stage 8: DIPC (6 methods) ──────────────────────────────────────────
651
+
652
+ async listDipcQueues(): Promise<Array<{ name: string; size?: number }>> {
653
+ try {
654
+ const r = await this.rws1Get('/rw/dipc');
655
+ return r.states.map(q => ({
656
+ name: (q._title ?? q['queue-name'] ?? '') as string,
657
+ size: q['queue-size'] !== undefined ? +(q['queue-size'] as string) : undefined,
658
+ }));
659
+ } catch { return []; }
660
+ }
661
+
662
+ async createDipcQueue(name: string, options: { maxsize?: number; maxmessages?: number } = {}): Promise<void> {
663
+ const parts = [`dipc-queue-name=${encodeURIComponent(name)}`];
664
+ if (options.maxsize) { parts.push(`dipc-max-size=${options.maxsize}`); }
665
+ if (options.maxmessages) { parts.push(`dipc-max-number-of-messages=${options.maxmessages}`); }
666
+ await this.rws1Post('/rw/dipc?action=create', parts.join('&'));
667
+ }
668
+
669
+ async sendDipcMessage(queue: string, payload: string, type: 'string' | 'num' | 'dnum' | 'bool' = 'string'): Promise<void> {
670
+ const typeCode = type === 'string' ? '0' : type === 'num' ? '1' : type === 'dnum' ? '2' : '3';
671
+ await this.rws1Post(`/rw/dipc/${encodeURIComponent(queue)}?action=send`,
672
+ `dipc-src-queue-name=${encodeURIComponent(queue)}&dipc-cmd=111&dipc-data=${encodeURIComponent(payload)}&dipc-msgtype=${typeCode}`);
673
+ }
674
+
675
+ async readDipcMessage(queue: string): Promise<{ payload: string; type: string } | null> {
676
+ try {
677
+ const r = await this.rws1Get(`/rw/dipc/${encodeURIComponent(queue)}?action=read`);
678
+ const s = r.state as Record<string, string> | null;
679
+ if (!s || !s['dipc-data']) { return null; }
680
+ return { payload: s['dipc-data'], type: s['dipc-msgtype'] ?? 'string' };
681
+ } catch { return null; }
682
+ }
683
+
684
+ async removeDipcQueue(name: string): Promise<void> {
685
+ await this.client.request('DELETE', `/rw/dipc/${encodeURIComponent(name)}?json=1`);
686
+ }
687
+
688
+ // ── Stage 9: Safety (5 methods) ────────────────────────────────────────
689
+
690
+ async getSafetyStatus(): Promise<{ state: string; details?: Record<string, string> }> {
691
+ try {
692
+ const r = await this.rws1Get('/ctrl/safety');
693
+ const s = r.state as Record<string, string> | null;
694
+ return { state: s?.state ?? 'unavailable', details: s ?? undefined };
695
+ } catch { return { state: 'unavailable' }; }
696
+ }
697
+
698
+ async listSafetyZones(): Promise<Array<Record<string, string>>> {
699
+ try {
700
+ const r = await this.rws1Get('/ctrl/safety/zones');
701
+ return r.states as Array<Record<string, string>>;
702
+ } catch { return []; }
703
+ }
704
+
705
+ async runCyclicBrakeCheck(): Promise<void> {
706
+ await this.rws1Post('/ctrl/safety/cyclic-brake-check', '');
707
+ }
708
+
709
+ // ── Stage 10: Virtual time (3 methods, VC-only) ────────────────────────
710
+
711
+ async getVirtualTime(): Promise<{ time: number; running: boolean; speed?: number }> {
712
+ try {
713
+ // RWS 1.0 has /ctrl/virtualtime as a directory; query each sub-resource.
714
+ const fetch = async (sub: string): Promise<Record<string, string>> => {
715
+ try {
716
+ const r = await this.rws1Get(`/ctrl/virtualtime/${sub}`);
717
+ return (r.state as Record<string, string>) ?? {};
718
+ } catch { return {}; }
719
+ };
720
+ const [time, state, speed] = await Promise.all([fetch('vttime'), fetch('vtstate'), fetch('vtspeed')]);
721
+ return {
722
+ time: Number(time.vtcounter ?? time.time ?? 0),
723
+ running: (state.vtcurrstate ?? state.state ?? '').toLowerCase() === 'running',
724
+ speed: speed.vtcurrspeed !== undefined ? +(speed.vtcurrspeed as string) : undefined,
725
+ };
726
+ } catch { return { time: 0, running: false }; }
727
+ }
728
+
729
+ async setVirtualTimeRunning(running: boolean): Promise<void> {
730
+ await this.rws1Post(`/ctrl/virtualtime/vtstate?action=${running ? 'run' : 'pause'}`, '');
731
+ }
732
+
733
+ async setVirtualTimeScale(scale: number): Promise<void> {
734
+ await this.rws1Post('/ctrl/virtualtime/vtspeed?action=set', `vtcurrspeed=${scale}`);
735
+ }
736
+
737
+ // ── Stage 11: Vision (5 methods) ───────────────────────────────────────
738
+
739
+ async listVisionSystems(): Promise<Array<{ name: string; status?: string }>> {
740
+ try {
741
+ const r = await this.rws1Get('/rw/vision');
742
+ return r.states.map(v => ({
743
+ name: (v._title ?? v.name ?? '') as string,
744
+ status: v.status as string | undefined,
745
+ }));
746
+ } catch { return []; }
747
+ }
748
+
749
+ async getVisionSystemInfo(name: string): Promise<Record<string, string>> {
750
+ try {
751
+ const r = await this.rws1Get(`/rw/vision/${encodeURIComponent(name)}`);
752
+ return (r.state as Record<string, string>) ?? {};
753
+ } catch { return {}; }
754
+ }
755
+
756
+ async triggerVisionJob(system: string): Promise<void> {
757
+ await this.rws1Post(`/rw/vision/${encodeURIComponent(system)}?action=trigger`, '');
758
+ }
759
+
760
+ // ── Stage 12: RAPID extras (4 methods) ─────────────────────────────────
761
+
762
+ /**
763
+ * Save a loaded module's program-memory source to controller disk.
764
+ * Live-verified 2026-07-09 on IRC5 VC RW6.16:
765
+ * POST /rw/rapid/tasks/{task}?action=savemod is DEAD — 400 ARG_ERROR
766
+ * (-1073445879, rws_resource_rapid_task.cpp[952]) for every body shape
767
+ * including the empty body, and the task resource advertises no savemod
768
+ * action. The working endpoint is the module-save action (same one
769
+ * readModuleViaSave uses):
770
+ * POST /rw/rapid/modules/{module}?task={task}&action=save
771
+ * body name=<file>&path=<dir> → 204
772
+ * The controller blindly appends '.mod' to the given name — even when it
773
+ * already ends in .mod (name=save1.mod wrote $TEMP/save1.mod.mod) — so a
774
+ * trailing .mod/.sys extension on the destination is stripped here.
775
+ * `filepath` may be a directory ('$TEMP'), a full destination path
776
+ * ('$HOME/backups/Copy.mod'), or a bare file name ('Copy.mod', saved under
777
+ * $HOME); a directory destination saves under the module's own name.
778
+ */
779
+ async saveModule(task: string, moduleName: string, filepath: string): Promise<void> {
780
+ const ext = /\.(mod|sys)$/i;
781
+ const clean = filepath.replace(/\/+$/, '');
782
+ const slash = clean.lastIndexOf('/');
783
+ const last = clean.slice(slash + 1);
784
+ let dir: string;
785
+ let name: string;
786
+ if (ext.test(last)) {
787
+ dir = slash >= 0 ? clean.slice(0, slash) : '$HOME';
788
+ name = last.replace(ext, '');
789
+ } else {
790
+ dir = clean || '$HOME';
791
+ name = moduleName.replace(ext, '');
792
+ }
793
+ // Encode per segment, keeping the $HOME/$TEMP root literal (the controller
794
+ // expects the $-prefix raw; everything after may contain space/#/%/&).
795
+ const encDir = dir.split('/')
796
+ .map((seg, i) => (i === 0 && seg.startsWith('$')) ? seg : encodeURIComponent(seg))
797
+ .join('/');
798
+ await this.rws1Post(
799
+ `/rw/rapid/modules/${encodeURIComponent(moduleName)}?task=${encodeURIComponent(task)}&action=save`,
800
+ `name=${encodeURIComponent(name)}&path=${encDir}`,
801
+ );
802
+ }
803
+
804
+ async getModuleSource(task: string, moduleName: string): Promise<string> {
805
+ // Program memory is the source of truth — the save round-trip reads it
806
+ // directly (mastership-free), so it is the PRIMARY path. Reading
807
+ // $HOME/{module}.mod first would let a stale disk file shadow unsaved
808
+ // edits, and modules loaded from .pgf / RobotStudio / the FlexPendant
809
+ // have no $HOME backing file at all (abb-rws-vscode issue #3).
810
+ try {
811
+ return await this.readModuleViaSave(task, moduleName);
812
+ } catch {
813
+ // Save endpoint failed (permissions, disk, transient) — fall back to the
814
+ // conventional $HOME location.
815
+ return this.client.readFile(`$HOME/${moduleName}.mod`);
816
+ }
817
+ }
818
+
819
+ /**
820
+ * Read a module's source by round-tripping it through the $TEMP volume.
821
+ * Live-verified 2026-07-08 on IRC5 VC RW6.16:
822
+ * POST /rw/rapid/modules/{module}?task={task}&action=save body name=<tmp>&path=$TEMP
823
+ * → 204, no mastership required. The controller ALWAYS appends '.mod' to
824
+ * the given name (even for SysMod modules — never '.sys'), so the name is
825
+ * passed without extension. Note the $-root has no trailing colon/slash,
826
+ * unlike RWS 2.0's 'TEMP:'.
827
+ */
828
+ private async readModuleViaSave(task: string, moduleName: string): Promise<string> {
829
+ const tmp = `${moduleName}_${Date.now().toString(36)}${Math.floor(Math.random() * 0xffff).toString(36)}`;
830
+ await this.rws1Post(
831
+ `/rw/rapid/modules/${encodeURIComponent(moduleName)}?task=${encodeURIComponent(task)}&action=save`,
832
+ `name=${tmp}&path=$TEMP`,
833
+ );
834
+ try {
835
+ return await this.client.readFile(`$TEMP/${tmp}.mod`);
836
+ } finally {
837
+ await this.client.deleteFile(`$TEMP/${tmp}.mod`).catch(() => {});
838
+ }
839
+ }
840
+
841
+ async listModuleRoutines(task: string, moduleName: string): Promise<Array<{ name: string; type: string }>> {
842
+ try {
843
+ const r = await this.rws1Get(`/rw/rapid/modules/${task}/${moduleName}/routines`);
844
+ return r.states.map(rt => ({
845
+ name: (rt.name ?? rt._title ?? '') as string,
846
+ type: (rt.type ?? '') as string,
847
+ }));
848
+ } catch { return []; }
849
+ }
850
+
851
+ async listBreakpoints(task: string): Promise<Array<{ module: string; row: number; col?: number }>> {
852
+ try {
853
+ // Per official doc: CCRapidBreakPointResource — exact path varies by RW version.
854
+ const r = await this.rws1Get(`/rw/rapid/tasks/${task}/breakpoints`);
855
+ return r.states.map(b => ({
856
+ module: (b.module ?? b.modulename ?? '') as string,
857
+ row: +(b['begin-position-row'] ?? b.row ?? 0),
858
+ col: b['begin-position-col'] !== undefined ? +(b['begin-position-col'] as string) : undefined,
859
+ }));
860
+ } catch { return []; }
861
+ }
862
+
863
+ async holdToRun(task: string, action: 'press' | 'release'): Promise<void> {
864
+ await this.rws1Post(`/rw/rapid/tasks/${task}?action=holdtorun`, `action=${action}`);
865
+ }
866
+
867
+ async startProductionMode(): Promise<void> {
868
+ await this.rws1Post('/rw/rapid/execution?action=start-prod', '');
869
+ }
870
+
871
+ // ── Stage 13: Network / time / compatibility (5 methods) ──────────────
872
+
873
+ async getNetworkConfig(): Promise<Record<string, string>> {
874
+ try {
875
+ const r = await this.rws1Get('/ctrl/network');
876
+ return (r.state as Record<string, string>) ?? {};
877
+ } catch { return {}; }
878
+ }
879
+
880
+ async getDnsConfig(): Promise<Record<string, string>> {
881
+ try {
882
+ const r = await this.rws1Get('/ctrl/network/dns');
883
+ return (r.state as Record<string, string>) ?? {};
884
+ } catch { return {}; }
885
+ }
886
+
887
+ async getRoutingTable(): Promise<Array<Record<string, string>>> {
888
+ try {
889
+ const r = await this.rws1Get('/ctrl/network/routes');
890
+ return r.states as Array<Record<string, string>>;
891
+ } catch { return []; }
892
+ }
893
+
894
+ async getTimezone(): Promise<{ tz: string; raw: Record<string, string> }> {
895
+ try {
896
+ const r = await this.rws1Get('/ctrl/clock/timezone');
897
+ const s = (r.state as Record<string, string>) ?? {};
898
+ return { tz: s.timezone ?? '', raw: s };
899
+ } catch { return { tz: '', raw: {} }; }
900
+ }
901
+
902
+ async getCompatibility(): Promise<{ compatible: boolean; details?: Record<string, string> }> {
903
+ try {
904
+ const r = await this.rws1Get('/ctrl/compatible');
905
+ const s = (r.state as Record<string, string>) ?? {};
906
+ return { compatible: (s.compatible ?? '').toLowerCase() === 'true', details: s };
907
+ } catch { return { compatible: false }; }
908
+ }
909
+
910
+ // ── Stage 14: Set mechunit / robtarget for jogging (2 methods) ────────
911
+
912
+ async setMechunitForJogging(mechunit: string): Promise<void> {
913
+ await this.rws1Post('/rw/motionsystem?action=set-mechunit', `mechunit=${encodeURIComponent(mechunit)}`);
914
+ }
915
+
916
+ async setRobtargetForJogging(target: { x: number; y: number; z: number; q1: number; q2: number; q3: number; q4: number }): Promise<void> {
917
+ const t = target;
918
+ await this.rws1Post('/rw/motionsystem?action=set-target',
919
+ `x=${t.x}&y=${t.y}&z=${t.z}&q1=${t.q1}&q2=${t.q2}&q3=${t.q3}&q4=${t.q4}`);
920
+ }
921
+
922
+ // ── Inverse kinematics ──────────────────────────────────────────────────
923
+
924
+ async calcJointsFromCartesian(
925
+ pos: RobTarget,
926
+ seedJoints?: JointTarget,
927
+ mechunit = 'ROB_1',
928
+ ): Promise<JointTarget> {
929
+ if (!this.creds) {
930
+ throw new Error('IK requires credentials — reconnect to enable');
931
+ }
932
+ const { host, port, username, password } = this.creds;
933
+ const seed = seedJoints
934
+ ? `[${seedJoints.rax_1},${seedJoints.rax_2},${seedJoints.rax_3},${seedJoints.rax_4},${seedJoints.rax_5},${seedJoints.rax_6}]`
935
+ : '[0,0,0,0,0,0]';
936
+
937
+ const bodyStr = [
938
+ `curr_position=[${pos.x},${pos.y},${pos.z}]`,
939
+ `curr_orientation=[${pos.q1},${pos.q2},${pos.q3},${pos.q4}]`,
940
+ `curr_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
941
+ `old_rob_joints=${seed}`,
942
+ `old_ext_joints=[9E9,9E9,9E9,9E9,9E9,9E9]`,
943
+ `robot_fixed_object=false`,
944
+ `tool_frame_position=[0,0,0]`,
945
+ `tool_frame_orientation=[1,0,0,0]`,
946
+ `wobj_frame_position=[0,0,0]`,
947
+ `wobj_frame_orientation=[1,0,0,0]`,
948
+ `robot_configuration=[0,0,0,0]`,
949
+ `elog_at_error=false`,
950
+ ].join('&');
951
+
952
+ const path = `/rw/motionsystem/mechunits/${mechunit}?action=CalcJointsFromPose&json=1`;
953
+ const result = await this.digestPost(host, port, path, bodyStr, username, password);
954
+ // RWS 1.0 IK response shape: { _embedded: { _state: [{ rax_1, rax_2, ... }] } }
955
+ const state = (result as { _embedded?: { _state?: Array<Record<string, string>> } })._embedded?._state?.[0];
956
+ if (!state) { throw new Error('IK: no result in response'); }
957
+ return {
958
+ rax_1: +state.rax_1, rax_2: +state.rax_2, rax_3: +state.rax_3,
959
+ rax_4: +state.rax_4, rax_5: +state.rax_5, rax_6: +state.rax_6,
960
+ };
961
+ }
962
+
963
+ private digestPost(host: string, port: number, path: string, body: string, user: string, pass: string): Promise<Record<string, unknown>> {
964
+ // Two-step Digest: first GET challenge, then POST with auth header
965
+ return new Promise((resolve, reject) => {
966
+ // Step 1: send no-auth POST to get the 401 challenge
967
+ const challenge = http.request({ method: 'POST', hostname: host, port, path, headers: { 'Content-Type': 'application/x-www-form-urlencoded' } }, res1 => {
968
+ const wwwAuth = (res1.headers['www-authenticate'] ?? '') as string;
969
+ res1.resume();
970
+ if (res1.statusCode !== 401) { reject(new Error(`IK: expected 401 challenge, got ${res1.statusCode}`)); return; }
971
+
972
+ // Parse Digest challenge
973
+ const realm = wwwAuth.match(/realm="([^"]+)"/)?.[1] ?? '';
974
+ const nonce = wwwAuth.match(/nonce="([^"]+)"/)?.[1] ?? '';
975
+ const qop = wwwAuth.match(/qop="([^"]+)"/)?.[1] ?? 'auth';
976
+
977
+ // Build auth header (RFC 2617)
978
+ const cnonce = crypto.randomBytes(8).toString('hex');
979
+ const nc = '00000001';
980
+ const ha1 = crypto.createHash('md5').update(`${user}:${realm}:${pass}`).digest('hex');
981
+ const ha2 = crypto.createHash('md5').update(`POST:${path.split('?')[0]}`).digest('hex');
982
+ const respH = crypto.createHash('md5').update(`${ha1}:${nonce}:${nc}:${cnonce}:${qop}:${ha2}`).digest('hex');
983
+ const authH = `Digest username="${user}", realm="${realm}", nonce="${nonce}", uri="${path.split('?')[0]}", qop=${qop}, nc=${nc}, cnonce="${cnonce}", response="${respH}"`;
984
+
985
+ // Step 2: POST with Digest auth
986
+ const encoded = Buffer.from(body);
987
+ const req2 = http.request({
988
+ method: 'POST', hostname: host, port, path,
989
+ headers: {
990
+ Authorization: authH,
991
+ 'Content-Type': 'application/x-www-form-urlencoded',
992
+ 'Content-Length': String(encoded.length),
993
+ Accept: 'application/json',
994
+ },
995
+ }, res2 => {
996
+ const chunks: Buffer[] = [];
997
+ res2.on('data', (c: Buffer) => chunks.push(c));
998
+ res2.on('end', () => {
999
+ const raw = Buffer.concat(chunks).toString('utf8');
1000
+ if ((res2.statusCode ?? 0) >= 400) {
1001
+ let msg = `IK HTTP ${res2.statusCode}`;
1002
+ try { msg = JSON.parse(raw)._embedded?.status?.msg ?? msg; } catch { /* ok */ }
1003
+ reject(new Error(msg));
1004
+ return;
1005
+ }
1006
+ try { resolve(JSON.parse(raw) as Record<string, unknown>); }
1007
+ catch { reject(new Error('IK: could not parse response')); }
1008
+ });
1009
+ });
1010
+ req2.on('error', reject);
1011
+ req2.write(encoded);
1012
+ req2.end();
1013
+ });
1014
+ challenge.on('error', reject);
1015
+ challenge.end();
1016
+ });
1017
+ }
1018
+ }