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