abb-rws-client 0.7.2 → 1.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +137 -0
- package/README.md +17 -8
- package/dist/HalJsonParser.d.ts +55 -0
- package/dist/HalJsonParser.d.ts.map +1 -0
- package/dist/HalJsonParser.js +155 -0
- package/dist/HalJsonParser.js.map +1 -0
- package/dist/HttpSession.d.ts.map +1 -1
- package/dist/HttpSession.js +13 -2
- package/dist/HttpSession.js.map +1 -1
- package/dist/IRWSAdapter.d.ts +7 -1
- package/dist/IRWSAdapter.d.ts.map +1 -1
- package/dist/MdnsDiscovery.d.ts +57 -0
- package/dist/MdnsDiscovery.d.ts.map +1 -0
- package/dist/MdnsDiscovery.js +313 -0
- package/dist/MdnsDiscovery.js.map +1 -0
- package/dist/MultiRobotManager.d.ts +5 -2
- package/dist/MultiRobotManager.d.ts.map +1 -1
- package/dist/MultiRobotManager.js +8 -3
- package/dist/MultiRobotManager.js.map +1 -1
- package/dist/RWS1Adapter.d.ts +60 -2
- package/dist/RWS1Adapter.d.ts.map +1 -1
- package/dist/RWS1Adapter.js +152 -4
- package/dist/RWS1Adapter.js.map +1 -1
- package/dist/ResourceMapper.d.ts +4 -0
- package/dist/ResourceMapper.d.ts.map +1 -1
- package/dist/ResourceMapper.js +9 -1
- package/dist/ResourceMapper.js.map +1 -1
- package/dist/RobotManager.d.ts +72 -12
- package/dist/RobotManager.d.ts.map +1 -1
- package/dist/RobotManager.js +255 -50
- package/dist/RobotManager.js.map +1 -1
- package/dist/RwsClient2.d.ts +150 -10
- package/dist/RwsClient2.d.ts.map +1 -1
- package/dist/RwsClient2.js +599 -236
- package/dist/RwsClient2.js.map +1 -1
- package/dist/WsSubscriber.d.ts +31 -5
- package/dist/WsSubscriber.d.ts.map +1 -1
- package/dist/WsSubscriber.js +104 -50
- package/dist/WsSubscriber.js.map +1 -1
- package/dist/detect.d.ts +12 -3
- package/dist/detect.d.ts.map +1 -1
- package/dist/detect.js +69 -25
- package/dist/detect.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/types.js +3 -3
- package/dist/types.js.map +1 -1
- package/examples/05-remote-control-rmmp.mjs +10 -12
- package/examples/06-pull-module-source.mjs +13 -20
- package/package.json +62 -60
- package/src/HalJsonParser.ts +137 -0
- package/src/HttpSession.ts +460 -0
- package/src/IRWSAdapter.ts +422 -0
- package/src/Logger.ts +54 -0
- package/src/MdnsDiscovery.ts +336 -0
- package/src/MultiRobotManager.ts +159 -0
- package/src/RWS1Adapter.ts +1018 -0
- package/src/RWS2Adapter.ts +19 -0
- package/src/ResourceMapper.ts +517 -0
- package/src/ResponseParser.ts +710 -0
- package/src/RobotManager.ts +1705 -0
- package/src/RwsClient.ts +1150 -0
- package/src/RwsClient2.ts +2214 -0
- package/src/WsSubscriber.ts +350 -0
- package/src/XhtmlParser.ts +53 -0
- package/src/detect.ts +261 -0
- package/src/index.ts +83 -0
- package/src/types.ts +336 -0
package/dist/RobotManager.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RwsClient } from './RwsClient.js';
|
|
2
|
+
import { RwsError } from './types.js';
|
|
2
3
|
import * as https from 'https';
|
|
3
4
|
import * as http from 'http';
|
|
4
5
|
import * as net from 'net';
|
|
@@ -8,6 +9,7 @@ import * as path from 'path';
|
|
|
8
9
|
import { RWS1Adapter } from './RWS1Adapter.js';
|
|
9
10
|
import { RWS2Adapter } from './RWS2Adapter.js';
|
|
10
11
|
import { Logger } from './Logger.js';
|
|
12
|
+
import { discoverControllersMdns } from './MdnsDiscovery.js';
|
|
11
13
|
const SESSION_FILE = path.join(os.homedir(), '.abb-rws-session');
|
|
12
14
|
export class RobotManager {
|
|
13
15
|
adapter = null;
|
|
@@ -28,8 +30,18 @@ export class RobotManager {
|
|
|
28
30
|
subscriptionActive = false;
|
|
29
31
|
/** In-flight connect promise — used to dedupe rapid-clicks so we never run two connects in parallel. */
|
|
30
32
|
connectingPromise = null;
|
|
33
|
+
/** Args of the in-flight connect, so a repeat call can tell "same target" from "new target". */
|
|
34
|
+
connectingArgs = null;
|
|
31
35
|
/** Monotonic counter so old polling timers can detect they've been superseded and self-cancel. */
|
|
32
36
|
pollGeneration = 0;
|
|
37
|
+
/** Bumped by disconnect() so an in-flight doConnect() can detect it was cancelled and unwind. */
|
|
38
|
+
connectEpoch = 0;
|
|
39
|
+
refreshIntervalMs;
|
|
40
|
+
strictTls;
|
|
41
|
+
constructor(opts = {}) {
|
|
42
|
+
this.refreshIntervalMs = Math.max(200, opts.refreshIntervalMs ?? 1000);
|
|
43
|
+
this.strictTls = opts.strictTls === true;
|
|
44
|
+
}
|
|
33
45
|
get state() { return this._state; }
|
|
34
46
|
/** The port currently in use (or last attempted). Useful for persisting auto-recovered port changes. */
|
|
35
47
|
get currentPort() { return this.adapterConfig?.port; }
|
|
@@ -38,8 +50,9 @@ export class RobotManager {
|
|
|
38
50
|
if (!this.adapter || !this.adapterConfig) {
|
|
39
51
|
return undefined;
|
|
40
52
|
}
|
|
41
|
-
// RWS2Adapter is HTTPS, RWS1Adapter is HTTP
|
|
42
|
-
|
|
53
|
+
// RWS2Adapter is HTTPS, RWS1Adapter is HTTP. instanceof, not constructor.name —
|
|
54
|
+
// minified bundles rename classes, which made this persist the wrong protocol.
|
|
55
|
+
return this.adapter instanceof RWS2Adapter;
|
|
43
56
|
}
|
|
44
57
|
onDidChange(fn) { this.handlers.push(fn); }
|
|
45
58
|
notify() { this.handlers.forEach(h => h()); }
|
|
@@ -51,14 +64,19 @@ export class RobotManager {
|
|
|
51
64
|
*/
|
|
52
65
|
onError(fn) { this.errorListener = fn; }
|
|
53
66
|
// ─── Auto-detection ─────────────────────────────────────────────────────────
|
|
54
|
-
static probePort(host, port, useHttps, timeoutMs = 3000) {
|
|
67
|
+
static probePort(host, port, useHttps, timeoutMs = 3000, strictTls = false) {
|
|
55
68
|
return new Promise(resolve => {
|
|
56
|
-
const
|
|
69
|
+
const insecure = useHttps && !strictTls;
|
|
70
|
+
const agent = insecure ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
|
57
71
|
const options = {
|
|
58
72
|
method: 'GET', hostname: host, port,
|
|
59
73
|
path: '/rw/system',
|
|
60
74
|
headers: { Accept: 'application/xhtml+xml;v=2.0' },
|
|
61
|
-
|
|
75
|
+
// rejectUnauthorized must also be per-request: hosts that swap the agent
|
|
76
|
+
// (VS Code extension host, non-localhost targets) drop agent-level TLS
|
|
77
|
+
// settings — real controllers have self-signed certs (issue #2).
|
|
78
|
+
// Under strictTls neither is set, so certs verify normally.
|
|
79
|
+
...(insecure ? { agent, rejectUnauthorized: false } : {}),
|
|
62
80
|
};
|
|
63
81
|
const tid = setTimeout(() => { req.destroy(); resolve(null); }, timeoutMs);
|
|
64
82
|
const req = (useHttps ? https : http).request(options, res => {
|
|
@@ -92,8 +110,8 @@ export class RobotManager {
|
|
|
92
110
|
{ port: 9403, useHttps: true },
|
|
93
111
|
];
|
|
94
112
|
/** Returns ALL responding controllers on a single host. */
|
|
95
|
-
static async detectAllControllers(host) {
|
|
96
|
-
const results = await Promise.all(RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps)));
|
|
113
|
+
static async detectAllControllers(host, strictTls = false) {
|
|
114
|
+
const results = await Promise.all(RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 3000, strictTls)));
|
|
97
115
|
return results.filter((r) => r !== null);
|
|
98
116
|
}
|
|
99
117
|
/**
|
|
@@ -105,13 +123,13 @@ export class RobotManager {
|
|
|
105
123
|
* TCP scan of the local-VC port range — this catches RobotStudio VCs whose
|
|
106
124
|
* ports are randomly assigned each startup.
|
|
107
125
|
*/
|
|
108
|
-
static async discoverControllers(extraHosts = []) {
|
|
126
|
+
static async discoverControllers(extraHosts = [], strictTls = false) {
|
|
109
127
|
const hosts = [
|
|
110
128
|
'127.0.0.1', // Local virtual controllers (RobotStudio)
|
|
111
129
|
'192.168.125.1', // ABB standard service port — both IRC5 and OmniCore real robots
|
|
112
130
|
...extraHosts,
|
|
113
131
|
];
|
|
114
|
-
const probes = hosts.flatMap(host => RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 1500)
|
|
132
|
+
const probes = hosts.flatMap(host => RobotManager.PROBE_PORTS.map(c => RobotManager.probePort(host, c.port, c.useHttps, 1500, strictTls)
|
|
115
133
|
.then((r) => r ? { host, port: r.port, useHttps: r.useHttps, authType: r.authType } : null)));
|
|
116
134
|
const results = await Promise.all(probes);
|
|
117
135
|
const found = results.filter((r) => r !== null);
|
|
@@ -120,15 +138,30 @@ export class RobotManager {
|
|
|
120
138
|
const localHits = found.filter(c => c.host === '127.0.0.1' || c.host === 'localhost');
|
|
121
139
|
if (localHits.length === 0) {
|
|
122
140
|
Logger.info(`no controllers on standard ports of 127.0.0.1 — running wide scan…`);
|
|
123
|
-
const wide = await RobotManager.wideHostScan('127.0.0.1');
|
|
141
|
+
const wide = await RobotManager.wideHostScan('127.0.0.1', strictTls);
|
|
124
142
|
Logger.info(`wide scan found ${wide.length} ABB controller(s) on 127.0.0.1`);
|
|
125
143
|
found.push(...wide);
|
|
126
144
|
}
|
|
127
145
|
return found;
|
|
128
146
|
}
|
|
147
|
+
/**
|
|
148
|
+
* Discover controllers via mDNS/Bonjour — additive alternative to the TCP
|
|
149
|
+
* probing of `discoverControllers`. Both real controllers and RobotStudio
|
|
150
|
+
* VCs advertise `RobotWebServices_<systemname>` on `_http._tcp.local`
|
|
151
|
+
* (VCs via the mDNSResponder service RobotStudio installs), so this finds
|
|
152
|
+
* VCs on their randomly-assigned ports without a port scan, and carries
|
|
153
|
+
* RW7 metadata (version, GUID, ports) from the TXT records.
|
|
154
|
+
*
|
|
155
|
+
* `probableProtocol` is a heuristic from the advertisement (RW7 attaches
|
|
156
|
+
* TXT metadata, RW6 does not) — confirm with `probeSpecificPort` before
|
|
157
|
+
* connecting. See MdnsDiscovery.ts for the live-verified wire details.
|
|
158
|
+
*/
|
|
159
|
+
static async discoverControllersMdns(opts) {
|
|
160
|
+
return discoverControllersMdns(opts);
|
|
161
|
+
}
|
|
129
162
|
/** Returns only the first responding controller (used internally during connect). */
|
|
130
|
-
static async detectController(host) {
|
|
131
|
-
const all = await RobotManager.detectAllControllers(host);
|
|
163
|
+
static async detectController(host, strictTls = false) {
|
|
164
|
+
const all = await RobotManager.detectAllControllers(host, strictTls);
|
|
132
165
|
return all[0] ?? null;
|
|
133
166
|
}
|
|
134
167
|
/**
|
|
@@ -137,9 +170,9 @@ export class RobotManager {
|
|
|
137
170
|
* Returns null only if neither responds — protects against guessing wrong
|
|
138
171
|
* (e.g. RobotStudio-assigned port 5466 is HTTPS but doesn't fit any heuristic).
|
|
139
172
|
*/
|
|
140
|
-
static async probeSpecificPort(host, port) {
|
|
141
|
-
return ((await RobotManager.probePort(host, port, true, 2000)) ??
|
|
142
|
-
(await RobotManager.probePort(host, port, false, 2000)));
|
|
173
|
+
static async probeSpecificPort(host, port, strictTls = false) {
|
|
174
|
+
return ((await RobotManager.probePort(host, port, true, 2000, strictTls)) ??
|
|
175
|
+
(await RobotManager.probePort(host, port, false, 2000, strictTls)));
|
|
143
176
|
}
|
|
144
177
|
/** Fast TCP-only check to see if a port is accepting connections. */
|
|
145
178
|
static tcpPing(host, port, timeoutMs = 100) {
|
|
@@ -163,7 +196,7 @@ export class RobotManager {
|
|
|
163
196
|
* Heavy operation — only call when standard-port detection finds nothing.
|
|
164
197
|
* Prefer host=127.0.0.1; scanning a remote host this aggressively is rude.
|
|
165
198
|
*/
|
|
166
|
-
static async wideHostScan(host) {
|
|
199
|
+
static async wideHostScan(host, strictTls = false) {
|
|
167
200
|
// RobotStudio assigns RWS ports across a wide range. Observed values include
|
|
168
201
|
// 5466, 9403, 11811, 15120, 16146, 28447 — covering 4000–30000 catches them all.
|
|
169
202
|
const startPort = 1024;
|
|
@@ -184,7 +217,7 @@ export class RobotManager {
|
|
|
184
217
|
await Promise.all(Array.from({ length: concurrency }, worker));
|
|
185
218
|
Logger.info(`wide scan: ${tcpOpen.length} open TCP port(s) on ${host}, HTTP-probing each…`);
|
|
186
219
|
// HTTP-probe TCP-open ports — filter to actual ABB controllers
|
|
187
|
-
const probes = await Promise.all(tcpOpen.map(port => RobotManager.probeSpecificPort(host, port)
|
|
220
|
+
const probes = await Promise.all(tcpOpen.map(port => RobotManager.probeSpecificPort(host, port, strictTls)
|
|
188
221
|
.then((r) => r ? { host, port: r.port, useHttps: r.useHttps, authType: r.authType } : null)));
|
|
189
222
|
return probes.filter((r) => r !== null);
|
|
190
223
|
}
|
|
@@ -195,20 +228,54 @@ export class RobotManager {
|
|
|
195
228
|
* Required when two controllers share the same host IP.
|
|
196
229
|
* @param useHttps If provided alongside port, sets the protocol explicitly.
|
|
197
230
|
*
|
|
198
|
-
* Rapid duplicate calls are coalesced — concurrent
|
|
199
|
-
* in-flight promise. Without this, fast double-clicks
|
|
200
|
-
* adapters/timers and overwhelm the controller's session
|
|
231
|
+
* Rapid duplicate calls with the SAME target are coalesced — concurrent
|
|
232
|
+
* callers receive the same in-flight promise. Without this, fast double-clicks
|
|
233
|
+
* would spawn parallel adapters/timers and overwhelm the controller's session
|
|
234
|
+
* pool. A call with a DIFFERENT target instead cancels the in-flight attempt
|
|
235
|
+
* and connects fresh — otherwise the caller ends up connected, but not to
|
|
236
|
+
* what it asked for. Same for a SAME-target call after a disconnect() has
|
|
237
|
+
* cancelled the in-flight attempt: coalescing onto it would hand the caller
|
|
238
|
+
* a promise that resolves with the manager still disconnected.
|
|
201
239
|
*/
|
|
202
240
|
connect(host, username, password, port, useHttps) {
|
|
203
241
|
if (this.connectingPromise) {
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
242
|
+
const pending = this.connectingArgs;
|
|
243
|
+
const sameArgs = !!pending
|
|
244
|
+
&& pending.host === host && pending.username === username
|
|
245
|
+
&& pending.password === password && pending.port === port
|
|
246
|
+
&& pending.useHttps === useHttps;
|
|
247
|
+
const stillCurrent = !!pending && pending.epoch === this.connectEpoch && !pending.cancelled;
|
|
248
|
+
if (sameArgs && stillCurrent) {
|
|
249
|
+
Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} ignored (same connect already in flight)`);
|
|
250
|
+
return this.connectingPromise;
|
|
251
|
+
}
|
|
252
|
+
Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} supersedes ${sameArgs ? 'cancelled' : 'in-flight'} connect to ${pending?.host ?? '?'}`);
|
|
253
|
+
return this.startConnect(host, username, password, port, useHttps, this.connectingPromise);
|
|
254
|
+
}
|
|
255
|
+
return this.startConnect(host, username, password, port, useHttps);
|
|
256
|
+
}
|
|
257
|
+
startConnect(host, username, password, port, useHttps, supersedes) {
|
|
258
|
+
const attempt = { host, username, password, port, useHttps, epoch: this.connectEpoch };
|
|
259
|
+
this.connectingArgs = attempt;
|
|
260
|
+
const p = (async () => {
|
|
261
|
+
if (supersedes) {
|
|
262
|
+
await this.disconnectInternal(); // bumps connectEpoch → the in-flight doConnect unwinds
|
|
263
|
+
attempt.epoch = this.connectEpoch; // our own cancel of the old attempt doesn't invalidate this one
|
|
264
|
+
await supersedes.catch(() => { }); // let it finish unwinding before we start fresh
|
|
265
|
+
}
|
|
266
|
+
await this.doConnect(host, username, password, port, useHttps, attempt);
|
|
267
|
+
})().finally(() => {
|
|
268
|
+
// Only clear if we're still the current attempt — a superseding connect
|
|
269
|
+
// may have replaced these fields while we were settling.
|
|
270
|
+
if (this.connectingPromise === p) {
|
|
271
|
+
this.connectingPromise = null;
|
|
272
|
+
this.connectingArgs = null;
|
|
273
|
+
}
|
|
274
|
+
});
|
|
275
|
+
this.connectingPromise = p;
|
|
276
|
+
return p;
|
|
210
277
|
}
|
|
211
|
-
async doConnect(host, username, password, port, useHttps) {
|
|
278
|
+
async doConnect(host, username, password, port, useHttps, attempt) {
|
|
212
279
|
// If we're already connected to the same controller, this is a no-op.
|
|
213
280
|
// Without this, every accidental click of the Connect button burns a session
|
|
214
281
|
// through the disconnect → /logout → /rw/system reconnect cycle.
|
|
@@ -216,18 +283,35 @@ export class RobotManager {
|
|
|
216
283
|
if (this._state.connected && cfg
|
|
217
284
|
&& cfg.host === host
|
|
218
285
|
&& (port === undefined || cfg.port === port)
|
|
219
|
-
&& cfg.username === username
|
|
286
|
+
&& cfg.username === username
|
|
287
|
+
&& cfg.password === password) {
|
|
220
288
|
Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ''} skipped (already connected)`);
|
|
221
289
|
return;
|
|
222
290
|
}
|
|
223
291
|
if (this._state.connected) {
|
|
224
|
-
await this.
|
|
292
|
+
await this.disconnectInternal();
|
|
293
|
+
}
|
|
294
|
+
// Any disconnect() after this point (user click, removeRobot) bumps the
|
|
295
|
+
// epoch; we re-check after every await so an aborted connect can't
|
|
296
|
+
// resurrect timers or subscriptions. The attempt record mirrors the value
|
|
297
|
+
// so connect() knows whether coalescing onto this attempt is still valid.
|
|
298
|
+
// User cancellations additionally set attempt.cancelled, which survives
|
|
299
|
+
// this re-read — otherwise a disconnect() racing our own teardown above
|
|
300
|
+
// would be erased here and the connection resurrected.
|
|
301
|
+
const epoch = this.connectEpoch;
|
|
302
|
+
if (attempt) {
|
|
303
|
+
attempt.epoch = epoch;
|
|
304
|
+
}
|
|
305
|
+
const aborted = () => attempt?.cancelled === true || epoch !== this.connectEpoch;
|
|
306
|
+
if (attempt?.cancelled) {
|
|
307
|
+
Logger.info(`connect → ${host} aborted (disconnected before probing)`);
|
|
308
|
+
return;
|
|
225
309
|
}
|
|
226
310
|
Logger.info(`connect → ${host}${port !== undefined ? ':' + port : ' (auto-detect)'} as "${username}"`);
|
|
227
311
|
let probe;
|
|
228
312
|
if (port !== undefined) {
|
|
229
313
|
// Port is pinned in config — verify it's actually reachable with the right protocol.
|
|
230
|
-
const verified = await RobotManager.probeSpecificPort(host, port);
|
|
314
|
+
const verified = await RobotManager.probeSpecificPort(host, port, this.strictTls);
|
|
231
315
|
if (verified) {
|
|
232
316
|
probe = verified;
|
|
233
317
|
Logger.info(`port ${port} verified: ${verified.useHttps ? 'HTTPS' : 'HTTP'}/${verified.authType}`);
|
|
@@ -237,11 +321,11 @@ export class RobotManager {
|
|
|
237
321
|
Logger.warn(`saved port ${port} not responding — scanning ${host} for an active controller…`);
|
|
238
322
|
const expectedAuth = (useHttps ?? (port === 443 || port === 9403)) ? 'basic' : 'digest';
|
|
239
323
|
// Phase 1: quick scan of the standard ports
|
|
240
|
-
let candidates = await RobotManager.detectAllControllers(host);
|
|
324
|
+
let candidates = await RobotManager.detectAllControllers(host, this.strictTls);
|
|
241
325
|
// Phase 2: if nothing on standard ports and we're scanning localhost, do a wide scan
|
|
242
326
|
if (candidates.length === 0 && (host === '127.0.0.1' || host === 'localhost')) {
|
|
243
327
|
Logger.info(`standard ports empty — running wide scan 1024–30000 (this takes ~3 s)…`);
|
|
244
|
-
const wide = await RobotManager.wideHostScan(host);
|
|
328
|
+
const wide = await RobotManager.wideHostScan(host, this.strictTls);
|
|
245
329
|
candidates = wide.map(c => ({ port: c.port, useHttps: c.useHttps, authType: c.authType }));
|
|
246
330
|
Logger.info(`wide scan found ${candidates.length} ABB controller(s) on ${host}`);
|
|
247
331
|
}
|
|
@@ -261,7 +345,7 @@ export class RobotManager {
|
|
|
261
345
|
else {
|
|
262
346
|
// Auto-detect: probe all common ports
|
|
263
347
|
Logger.info(`auto-detecting controller at ${host} (ports 80, 443, 28447, 9403)`);
|
|
264
|
-
const found = await RobotManager.detectController(host);
|
|
348
|
+
const found = await RobotManager.detectController(host, this.strictTls);
|
|
265
349
|
if (!found) {
|
|
266
350
|
const err = `No ABB RWS controller found at ${host}.\nChecked ports: 80, 443, 28447, 9403.\nEnsure the controller is reachable and RWS is enabled.`;
|
|
267
351
|
Logger.error(`auto-detect failed for ${host}`);
|
|
@@ -270,6 +354,10 @@ export class RobotManager {
|
|
|
270
354
|
probe = found;
|
|
271
355
|
Logger.info(`auto-detected: port ${probe.port} ${probe.useHttps ? 'HTTPS' : 'HTTP'}/${probe.authType}`);
|
|
272
356
|
}
|
|
357
|
+
if (aborted()) {
|
|
358
|
+
Logger.info(`connect → ${host} aborted (disconnected while probing)`);
|
|
359
|
+
return;
|
|
360
|
+
}
|
|
273
361
|
const cookieKey = `${host}:${probe.port}`;
|
|
274
362
|
const prevCfg = this.adapterConfig;
|
|
275
363
|
const sameConfig = prevCfg
|
|
@@ -278,7 +366,7 @@ export class RobotManager {
|
|
|
278
366
|
if (!this.adapter || !sameConfig) {
|
|
279
367
|
if (probe.authType === 'basic') {
|
|
280
368
|
const scheme = probe.useHttps ? 'https' : 'http';
|
|
281
|
-
this.adapter = new RWS2Adapter(`${scheme}://${host}:${probe.port}`, username, password);
|
|
369
|
+
this.adapter = new RWS2Adapter(`${scheme}://${host}:${probe.port}`, username, password, { rejectUnauthorized: this.strictTls });
|
|
282
370
|
}
|
|
283
371
|
else {
|
|
284
372
|
// Session cookie keyed by host:port so two controllers on same IP don't clobber each other
|
|
@@ -295,6 +383,11 @@ export class RobotManager {
|
|
|
295
383
|
Logger.error(`adapter.connect() failed for ${host}:${probe.port}`, e);
|
|
296
384
|
throw e;
|
|
297
385
|
}
|
|
386
|
+
if (aborted()) {
|
|
387
|
+
Logger.info(`connect → ${host} aborted (disconnected mid-connect) — closing session`);
|
|
388
|
+
await this.adapter.disconnect().catch(() => { });
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
298
391
|
const cookie = this.adapter.getSessionCookie();
|
|
299
392
|
if (cookie) {
|
|
300
393
|
this.saveSessionCookie(cookieKey, cookie);
|
|
@@ -304,15 +397,28 @@ export class RobotManager {
|
|
|
304
397
|
Logger.info(`connected to ${host}:${probe.port}`);
|
|
305
398
|
this.notify();
|
|
306
399
|
// Start WebSocket subscriptions for instant state-change events.
|
|
307
|
-
// If subscriptions succeed, polling runs at 5
|
|
308
|
-
// If they fail, polling runs at
|
|
400
|
+
// If subscriptions succeed, polling runs at 5× refreshIntervalMs (positions only).
|
|
401
|
+
// If they fail, polling runs at refreshIntervalMs (full state coverage as before).
|
|
309
402
|
await this.startSubscriptions();
|
|
403
|
+
if (aborted()) {
|
|
404
|
+
Logger.info(`connect → ${host} aborted (disconnected during subscription setup)`);
|
|
405
|
+
if (this.unsubscribeFn) {
|
|
406
|
+
await this.unsubscribeFn().catch(() => { });
|
|
407
|
+
this.unsubscribeFn = null;
|
|
408
|
+
}
|
|
409
|
+
this.subscriptionActive = false;
|
|
410
|
+
await this.adapter.disconnect().catch(() => { });
|
|
411
|
+
return;
|
|
412
|
+
}
|
|
310
413
|
// Every connect() invalidates older polling cycles so any timer that
|
|
311
414
|
// wasn't cleared (race between disconnect/reconnect) self-cancels.
|
|
312
415
|
const myGeneration = ++this.pollGeneration;
|
|
313
416
|
this.consecutiveFails = 0;
|
|
314
417
|
await this.fetchAll(myGeneration);
|
|
315
|
-
|
|
418
|
+
if (aborted()) {
|
|
419
|
+
return;
|
|
420
|
+
}
|
|
421
|
+
const pollMs = this.subscriptionActive ? 5 * this.refreshIntervalMs : this.refreshIntervalMs;
|
|
316
422
|
// Single-flight guard: if a fetchAll is still running when the timer
|
|
317
423
|
// fires, skip this tick. Prevents the request pile-up that caused
|
|
318
424
|
// 10-second timeouts on /cartesian and /tasks during heavy motion
|
|
@@ -327,8 +433,21 @@ export class RobotManager {
|
|
|
327
433
|
}, pollMs);
|
|
328
434
|
}
|
|
329
435
|
async disconnect() {
|
|
436
|
+
// A user disconnect permanently cancels any in-flight connect attempt.
|
|
437
|
+
// The flag (not just the epoch) is what makes it stick: doConnect re-reads
|
|
438
|
+
// the epoch after its own internal teardowns, which would otherwise erase
|
|
439
|
+
// this cancellation and resurrect the connection.
|
|
440
|
+
if (this.connectingArgs) {
|
|
441
|
+
this.connectingArgs.cancelled = true;
|
|
442
|
+
}
|
|
443
|
+
return this.disconnectInternal();
|
|
444
|
+
}
|
|
445
|
+
/** Teardown used by doConnect/startConnect for their own reconnect cycles — must not cancel the attempt that invoked it. */
|
|
446
|
+
async disconnectInternal() {
|
|
330
447
|
// Bump generation FIRST so any in-flight fetchAll calls bail before they
|
|
331
|
-
// can trigger another disconnect cascade.
|
|
448
|
+
// can trigger another disconnect cascade. The epoch likewise cancels any
|
|
449
|
+
// in-flight doConnect() so it can't re-install timers/subscriptions.
|
|
450
|
+
this.connectEpoch++;
|
|
332
451
|
this.pollGeneration++;
|
|
333
452
|
if (this.timer) {
|
|
334
453
|
clearInterval(this.timer);
|
|
@@ -458,6 +577,24 @@ export class RobotManager {
|
|
|
458
577
|
}
|
|
459
578
|
await this.adapter.setOperationMode(mode);
|
|
460
579
|
}
|
|
580
|
+
// ─── Simulation panel (virtual controllers, RWS 2.0 only) ──────────────────
|
|
581
|
+
//
|
|
582
|
+
// Thin passthroughs to the RWS2 client's sim* methods — the endpoints exist
|
|
583
|
+
// only on RW7 virtual controllers (404 on real hardware and on RW6).
|
|
584
|
+
simAdapter() {
|
|
585
|
+
if (!(this.adapter instanceof RWS2Adapter)) {
|
|
586
|
+
throw new RwsError('Simulation panel requires an OmniCore (RWS 2.0) virtual controller', 'UNKNOWN');
|
|
587
|
+
}
|
|
588
|
+
return this.adapter;
|
|
589
|
+
}
|
|
590
|
+
async simEmergencyStop() { return this.simAdapter().simEmergencyStop(); }
|
|
591
|
+
async simResetEmergencyStop() { return this.simAdapter().simResetEmergencyStop(); }
|
|
592
|
+
async simGeneralStop(engage = true) { return this.simAdapter().simGeneralStop(engage); }
|
|
593
|
+
async simAutoStop(engage = true) { return this.simAdapter().simAutoStop(engage); }
|
|
594
|
+
async simEnableSwitch(on) { return this.simAdapter().simEnableSwitch(on); }
|
|
595
|
+
async teleportMechunit(mechunit, joints, extJoints) {
|
|
596
|
+
return this.simAdapter().teleportMechunit(mechunit, joints, extJoints);
|
|
597
|
+
}
|
|
461
598
|
// ─── RAPID execution ────────────────────────────────────────────────────────
|
|
462
599
|
//
|
|
463
600
|
// start/stop/resetpp/cycle all REQUIRE 'edit' mastership on RWS 2.0
|
|
@@ -554,10 +691,10 @@ export class RobotManager {
|
|
|
554
691
|
await this.adapter.startRapid();
|
|
555
692
|
});
|
|
556
693
|
}
|
|
557
|
-
/** Active task name
|
|
694
|
+
/** Active task name — the task flagged active, else the first task, else T_ROB1. */
|
|
558
695
|
activeTaskName() {
|
|
559
696
|
const active = this._state.tasks.find(t => t.active);
|
|
560
|
-
return active?.name ?? 'T_ROB1';
|
|
697
|
+
return active?.name ?? this._state.tasks[0]?.name ?? 'T_ROB1';
|
|
561
698
|
}
|
|
562
699
|
async stopRapid() {
|
|
563
700
|
if (!this.adapter) {
|
|
@@ -1310,7 +1447,7 @@ export class RobotManager {
|
|
|
1310
1447
|
'execution',
|
|
1311
1448
|
'coldetstate',
|
|
1312
1449
|
{ type: 'elog', domain: 0 },
|
|
1313
|
-
], event => this.handleSubscriptionEvent(event));
|
|
1450
|
+
], event => this.handleSubscriptionEvent(event), () => this.handleSubscriptionLost());
|
|
1314
1451
|
this.subscriptionActive = true;
|
|
1315
1452
|
}
|
|
1316
1453
|
catch (e) {
|
|
@@ -1319,6 +1456,29 @@ export class RobotManager {
|
|
|
1319
1456
|
console.log(`[RobotManager] WS subscriptions unavailable, using polling only: ${e instanceof Error ? e.message : String(e)}`);
|
|
1320
1457
|
}
|
|
1321
1458
|
}
|
|
1459
|
+
/**
|
|
1460
|
+
* Adapter reports the event stream as terminally lost (WS reconnect attempts
|
|
1461
|
+
* exhausted). Drop back to the fast polling cadence so state stays fresh —
|
|
1462
|
+
* without this the manager keeps the 5× slow poll and the UI goes stale.
|
|
1463
|
+
*/
|
|
1464
|
+
handleSubscriptionLost() {
|
|
1465
|
+
if (!this.subscriptionActive) {
|
|
1466
|
+
return;
|
|
1467
|
+
}
|
|
1468
|
+
this.subscriptionActive = false;
|
|
1469
|
+
Logger.warn('live event stream lost — resuming fast polling');
|
|
1470
|
+
if (!this._state.connected || !this.timer) {
|
|
1471
|
+
return;
|
|
1472
|
+
}
|
|
1473
|
+
clearInterval(this.timer);
|
|
1474
|
+
const myGeneration = this.pollGeneration;
|
|
1475
|
+
this.timer = setInterval(() => {
|
|
1476
|
+
if (this.fetchInFlight) {
|
|
1477
|
+
return;
|
|
1478
|
+
}
|
|
1479
|
+
this.fetchAll(myGeneration);
|
|
1480
|
+
}, this.refreshIntervalMs);
|
|
1481
|
+
}
|
|
1322
1482
|
handleSubscriptionEvent(event) {
|
|
1323
1483
|
if (!this._state.connected) {
|
|
1324
1484
|
return;
|
|
@@ -1395,7 +1555,24 @@ export class RobotManager {
|
|
|
1395
1555
|
}
|
|
1396
1556
|
catch { /* new file */ }
|
|
1397
1557
|
data[host] = cookie;
|
|
1398
|
-
|
|
1558
|
+
// The file is shared across every RobotManager (and every host process),
|
|
1559
|
+
// so replace it atomically: write a temp file in the same directory,
|
|
1560
|
+
// then rename over the real one. A plain write lets a concurrent
|
|
1561
|
+
// manager read a half-written file and drop entries.
|
|
1562
|
+
const tmp = `${SESSION_FILE}.${process.pid}.${Date.now()}.tmp`;
|
|
1563
|
+
fs.writeFileSync(tmp, JSON.stringify(data), 'utf8');
|
|
1564
|
+
try {
|
|
1565
|
+
fs.renameSync(tmp, SESSION_FILE);
|
|
1566
|
+
}
|
|
1567
|
+
catch {
|
|
1568
|
+
// A concurrent writer beat us to the rename (Windows briefly locks the
|
|
1569
|
+
// destination) — drop our temp file and let the other writer win; the
|
|
1570
|
+
// cookie is re-saved on the next connect anyway.
|
|
1571
|
+
try {
|
|
1572
|
+
fs.unlinkSync(tmp);
|
|
1573
|
+
}
|
|
1574
|
+
catch { /* already gone */ }
|
|
1575
|
+
}
|
|
1399
1576
|
}
|
|
1400
1577
|
catch { /* non-fatal */ }
|
|
1401
1578
|
}
|
|
@@ -1405,8 +1582,12 @@ export class RobotManager {
|
|
|
1405
1582
|
consecutiveFails = 0;
|
|
1406
1583
|
fetchInFlight = false;
|
|
1407
1584
|
async fetchAll(generation) {
|
|
1408
|
-
// Bail if this fetch belongs to an old generation (we've reconnected or
|
|
1409
|
-
|
|
1585
|
+
// Bail if this fetch belongs to an old generation (we've reconnected or
|
|
1586
|
+
// disconnected since). Re-checked after every await below — a disconnect
|
|
1587
|
+
// mid-poll clears _state, and a late-resolving request must not resurrect
|
|
1588
|
+
// the stale snapshot into it.
|
|
1589
|
+
const stale = () => generation !== undefined && generation !== this.pollGeneration;
|
|
1590
|
+
if (stale()) {
|
|
1410
1591
|
return;
|
|
1411
1592
|
}
|
|
1412
1593
|
if (!this.adapter) {
|
|
@@ -1417,23 +1598,36 @@ export class RobotManager {
|
|
|
1417
1598
|
}
|
|
1418
1599
|
this.fetchInFlight = true;
|
|
1419
1600
|
try {
|
|
1420
|
-
const
|
|
1421
|
-
const [execInfo, ctrlstate, opmode, speedRatio, tasks, modules, joints, cartesianFull] = await Promise.all([
|
|
1601
|
+
const [execInfo, ctrlstate, opmode, speedRatio, tasks, joints, cartesianFull] = await Promise.all([
|
|
1422
1602
|
this.adapter.getRapidExecutionInfo(),
|
|
1423
1603
|
this.adapter.getControllerState(),
|
|
1424
1604
|
this.adapter.getOperationMode(),
|
|
1425
1605
|
this.adapter.getSpeedRatio(),
|
|
1426
1606
|
this.adapter.getRapidTasks(),
|
|
1427
|
-
this.adapter.listModules(taskName),
|
|
1428
1607
|
this.adapter.getJointPositions(),
|
|
1429
1608
|
this.adapter.getCartesianFull(),
|
|
1430
1609
|
]);
|
|
1610
|
+
if (stale()) {
|
|
1611
|
+
return;
|
|
1612
|
+
}
|
|
1613
|
+
// Module list needs a task name — resolve it from the tasks we just
|
|
1614
|
+
// fetched, not a hardcoded T_ROB1 (multi-task systems and OmniCore
|
|
1615
|
+
// single-arm variants name their tasks differently).
|
|
1616
|
+
this._state.tasks = tasks;
|
|
1617
|
+
const modules = await this.adapter.listModules(this.activeTaskName());
|
|
1618
|
+
if (stale()) {
|
|
1619
|
+
return;
|
|
1620
|
+
}
|
|
1431
1621
|
const cartesian = { x: cartesianFull.x, y: cartesianFull.y, z: cartesianFull.z, q1: cartesianFull.q1, q2: cartesianFull.q2, q3: cartesianFull.q3, q4: cartesianFull.q4 };
|
|
1432
1622
|
Object.assign(this._state, {
|
|
1433
1623
|
ctrlstate, opmode, execstate: execInfo.state, execCycle: execInfo.cycle,
|
|
1434
1624
|
speedRatio, tasks, modules, joints, cartesian, cartesianFull,
|
|
1435
1625
|
});
|
|
1436
|
-
|
|
1626
|
+
const coldetstate = await this.adapter.getCollisionDetectionState().catch(() => null);
|
|
1627
|
+
if (stale()) {
|
|
1628
|
+
return;
|
|
1629
|
+
}
|
|
1630
|
+
this._state.coldetstate = coldetstate;
|
|
1437
1631
|
this.fetchCount++;
|
|
1438
1632
|
// Identity / systemInfo / eventLog / mechunits — only refresh occasionally,
|
|
1439
1633
|
// BUT keep retrying every poll until they're populated (so a transient first-poll
|
|
@@ -1449,6 +1643,9 @@ export class RobotManager {
|
|
|
1449
1643
|
this.adapter.getEventLog(0, 'en').catch(e => { Logger.warn(`getEventLog failed: ${e instanceof Error ? e.message : String(e)}`); return []; }),
|
|
1450
1644
|
this.adapter.listMechunits().catch(e => { Logger.warn(`listMechunits failed: ${e instanceof Error ? e.message : String(e)}`); return ['ROB_1']; }),
|
|
1451
1645
|
]);
|
|
1646
|
+
if (stale()) {
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1452
1649
|
if (identity) {
|
|
1453
1650
|
this._state.identity = identity;
|
|
1454
1651
|
}
|
|
@@ -1465,6 +1662,9 @@ export class RobotManager {
|
|
|
1465
1662
|
try {
|
|
1466
1663
|
while (true) {
|
|
1467
1664
|
const page = await this.adapter.listAllSignals(start, PAGE);
|
|
1665
|
+
if (stale()) {
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1468
1668
|
all.push(...page);
|
|
1469
1669
|
if (page.length < PAGE) {
|
|
1470
1670
|
break;
|
|
@@ -1475,12 +1675,15 @@ export class RobotManager {
|
|
|
1475
1675
|
}
|
|
1476
1676
|
catch { /* non-fatal */ }
|
|
1477
1677
|
}
|
|
1678
|
+
if (stale()) {
|
|
1679
|
+
return;
|
|
1680
|
+
}
|
|
1478
1681
|
this.consecutiveFails = 0;
|
|
1479
1682
|
this.notify();
|
|
1480
1683
|
}
|
|
1481
1684
|
catch (e) {
|
|
1482
1685
|
// Stale poll (disconnect or reconnect happened mid-flight) — don't count this failure.
|
|
1483
|
-
if (
|
|
1686
|
+
if (stale()) {
|
|
1484
1687
|
return;
|
|
1485
1688
|
}
|
|
1486
1689
|
this.consecutiveFails++;
|
|
@@ -1498,8 +1701,10 @@ export class RobotManager {
|
|
|
1498
1701
|
Logger.error(`disconnecting after 3 failed polls`, e);
|
|
1499
1702
|
Logger.show();
|
|
1500
1703
|
// Capture config BEFORE disconnect (which clears it) so the Reconnect button works.
|
|
1704
|
+
// Internal variant: this tears down the DEAD connection — it must not
|
|
1705
|
+
// cancel a fresh connect attempt the user may have started meanwhile.
|
|
1501
1706
|
const cfg = this.adapterConfig;
|
|
1502
|
-
await this.
|
|
1707
|
+
await this.disconnectInternal();
|
|
1503
1708
|
// Hand off to the host's error listener (VS Code extension, CLI, etc.).
|
|
1504
1709
|
// If no listener is installed, the failure is silent beyond the log lines above.
|
|
1505
1710
|
if (this.errorListener) {
|