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