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,350 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WsSubscriber — WebSocket subscription manager for ABB IRC5 RWS events.
|
|
3
|
+
*
|
|
4
|
+
* Flow:
|
|
5
|
+
* 1. POST /subscription via HttpSession to register resources → get subscription ID
|
|
6
|
+
* 2. Open WebSocket to ws://{host}/subscription/{id} with robapi2_subscription subprotocol
|
|
7
|
+
* 3. subscribe() resolves only once the WebSocket is open; if the socket errors or
|
|
8
|
+
* closes before opening, subscribe() rejects and best-effort DELETEs the
|
|
9
|
+
* registration so the controller-side subscription slot is not leaked
|
|
10
|
+
* 4. Parse incoming XML event messages → emit typed SubscriptionEvent objects
|
|
11
|
+
* 5. Auto-reconnect when an established stream drops: max 3 retries, exponential
|
|
12
|
+
* backoff 1s/2s/4s
|
|
13
|
+
*
|
|
14
|
+
* Always connects through the 'ws' package: the RWS upgrade request must carry the
|
|
15
|
+
* session Cookie header, and native (undici) WebSocket has no headers option — it
|
|
16
|
+
* silently ignores the ws-style third constructor argument, so the handshake goes
|
|
17
|
+
* out unauthenticated. Live-verified 2026-07-08 against IRC5 RW6.16: native WS is
|
|
18
|
+
* rejected with HTTP 403, 'ws' with the same Cookie opens and delivers events.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { createRequire } from 'module';
|
|
22
|
+
import type { HttpSession } from './HttpSession.js';
|
|
23
|
+
import type { SubscriptionResource, SubscriptionEvent } from './types.js';
|
|
24
|
+
import { RwsError } from './types.js';
|
|
25
|
+
import { subscriptions } from './ResourceMapper.js';
|
|
26
|
+
import { parseSubscriptionId } from './ResponseParser.js';
|
|
27
|
+
|
|
28
|
+
const BACKOFF_MS = [1000, 2000, 4000] as const;
|
|
29
|
+
const MAX_RETRIES = 3;
|
|
30
|
+
|
|
31
|
+
/** WebSocket constructor shape used by WsSubscriber — ws-style options 3rd arg */
|
|
32
|
+
type WebSocketCtor = new (
|
|
33
|
+
url: string,
|
|
34
|
+
protocols: string[],
|
|
35
|
+
options: { headers: Record<string, string> },
|
|
36
|
+
) => WebSocket;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Resolve the 'ws' package constructor. Never returns native globalThis.WebSocket —
|
|
40
|
+
* it cannot send the Cookie header the controller requires for WS auth.
|
|
41
|
+
*/
|
|
42
|
+
function resolveWebSocket(): WebSocketCtor {
|
|
43
|
+
try {
|
|
44
|
+
const require = createRequire(import.meta.url);
|
|
45
|
+
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
46
|
+
return require('ws') as any;
|
|
47
|
+
} catch {
|
|
48
|
+
throw new RwsError(
|
|
49
|
+
'The "ws" package is required for RWS 1.0 subscriptions but could not be loaded.',
|
|
50
|
+
'NETWORK_ERROR',
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Path builder for subscription resources ─────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Map a SubscriptionResource to its RWS 1.0 event path (with ;state suffix where needed).
|
|
59
|
+
* Paths are NOT percent-encoded — semicolons must be literal in the subscription body.
|
|
60
|
+
*/
|
|
61
|
+
function resourceToPath(resource: SubscriptionResource): string {
|
|
62
|
+
if (resource === 'execution') return '/rw/rapid/execution;ctrlexecstate';
|
|
63
|
+
if (resource === 'controllerstate') return '/rw/panel/ctrlstate;ctrlstate';
|
|
64
|
+
if (resource === 'operationmode') return '/rw/panel/opmode;opmode';
|
|
65
|
+
if (resource === 'speedratio') return '/rw/panel/speedratio;speedratio';
|
|
66
|
+
if (resource === 'coldetstate') return '/rw/panel/coldetstate;coldetstate';
|
|
67
|
+
if (resource === 'uiinstr') return '/rw/rapid/uiinstr;uievent';
|
|
68
|
+
if (resource.type === 'signal') {
|
|
69
|
+
// Convention: name can be 'network/device/signalname' (3 parts) for a physical signal,
|
|
70
|
+
// or just 'signalname' for virtual/flat signals.
|
|
71
|
+
return `/rw/iosystem/signals/${resource.name};state`;
|
|
72
|
+
}
|
|
73
|
+
if (resource.type === 'persvar') {
|
|
74
|
+
// RAPID persistent variable subscription path (full path: RAPID/task/module/symbol)
|
|
75
|
+
return `/rw/rapid/symbol/data/${resource.name};value`;
|
|
76
|
+
}
|
|
77
|
+
if (resource.type === 'taskchange') {
|
|
78
|
+
return `/rw/rapid/tasks/${encodeURIComponent(resource.task)};taskchange`;
|
|
79
|
+
}
|
|
80
|
+
if (resource.type === 'execycle') {
|
|
81
|
+
return '/rw/rapid/execution;rapidexeccycle';
|
|
82
|
+
}
|
|
83
|
+
if (resource.type === 'elog') {
|
|
84
|
+
return `/rw/elog/${resource.domain}`;
|
|
85
|
+
}
|
|
86
|
+
// TypeScript exhaustiveness check
|
|
87
|
+
const _: never = resource;
|
|
88
|
+
void _;
|
|
89
|
+
throw new RwsError('Unknown subscription resource type', 'UNKNOWN');
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Build the application/x-www-form-urlencoded body for POST /subscription.
|
|
94
|
+
* Paths are NOT percent-encoded; the semicolons are literal as expected by RWS.
|
|
95
|
+
*/
|
|
96
|
+
function buildSubscriptionBody(resources: SubscriptionResource[]): string {
|
|
97
|
+
const parts: string[] = [`resources=${resources.length}`];
|
|
98
|
+
resources.forEach((resource, index) => {
|
|
99
|
+
const i = index + 1;
|
|
100
|
+
const path = resourceToPath(resource);
|
|
101
|
+
// Do NOT encodeURIComponent the path — RWS expects literal semicolons
|
|
102
|
+
parts.push(`${i}=${path}&${i}-p=1`);
|
|
103
|
+
});
|
|
104
|
+
return parts.join('&');
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// ─── XML event parsing ────────────────────────────────────────────────────────
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Parse an incoming RWS WebSocket XML event message.
|
|
111
|
+
* Expected structure (simplified):
|
|
112
|
+
* <html><body><div class="bind-data"><ul>
|
|
113
|
+
* <li class="..."><a href="/rw/rapid/execution;state">...</a>
|
|
114
|
+
* <span class="excstate">running</span>
|
|
115
|
+
* </li>
|
|
116
|
+
* </ul></div></body></html>
|
|
117
|
+
*/
|
|
118
|
+
function parseWsMessage(data: string): SubscriptionEvent[] {
|
|
119
|
+
const events: SubscriptionEvent[] = [];
|
|
120
|
+
|
|
121
|
+
// Extract all <li> blocks in the message
|
|
122
|
+
const liPattern = /<li[^>]*>(.*?)<\/li>/gis;
|
|
123
|
+
let liMatch: RegExpExecArray | null;
|
|
124
|
+
|
|
125
|
+
while ((liMatch = liPattern.exec(data)) !== null) {
|
|
126
|
+
const block = liMatch[1];
|
|
127
|
+
|
|
128
|
+
// Extract resource URL from the <a href="..."> anchor
|
|
129
|
+
const hrefMatch = block.match(/<a[^>]*href="([^"]+)"/i);
|
|
130
|
+
if (!hrefMatch) continue;
|
|
131
|
+
const resource = hrefMatch[1];
|
|
132
|
+
|
|
133
|
+
// Extract value from the first <span> in this block
|
|
134
|
+
const spanMatch = block.match(/<span[^>]*>(.*?)<\/span>/is);
|
|
135
|
+
const value = spanMatch ? spanMatch[1].trim() : '';
|
|
136
|
+
|
|
137
|
+
events.push({ resource, value, timestamp: new Date() });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return events;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ─── WsSubscriber ─────────────────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
interface ActiveSubscription {
|
|
146
|
+
id: string;
|
|
147
|
+
wsUrl: string;
|
|
148
|
+
deleteUrl: string; // HTTP URL used to DELETE the subscription on close
|
|
149
|
+
ws: WebSocket | null;
|
|
150
|
+
handler: (event: SubscriptionEvent) => void;
|
|
151
|
+
retryCount: number;
|
|
152
|
+
closed: boolean;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
export class WsSubscriber {
|
|
156
|
+
private readonly session: HttpSession;
|
|
157
|
+
private readonly host: string;
|
|
158
|
+
private readonly port: number;
|
|
159
|
+
private readonly wsCtor: WebSocketCtor | undefined;
|
|
160
|
+
private subscriptions: Map<string, ActiveSubscription> = new Map();
|
|
161
|
+
|
|
162
|
+
constructor(session: HttpSession, host: string, port: number, wsCtor?: WebSocketCtor) {
|
|
163
|
+
this.session = session;
|
|
164
|
+
this.host = host;
|
|
165
|
+
this.port = port;
|
|
166
|
+
this.wsCtor = wsCtor;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Subscribe to one or more RWS resources. Returns an unsubscribe function.
|
|
171
|
+
*
|
|
172
|
+
* Resolves only once the event WebSocket is open. If the socket fails before
|
|
173
|
+
* opening (refused connection, failed upgrade), rejects with RwsError after
|
|
174
|
+
* best-effort deleting the subscription registered by the POST — otherwise the
|
|
175
|
+
* caller believes it has live events while the controller streams to nobody.
|
|
176
|
+
*
|
|
177
|
+
* @param resources - Array of resources to subscribe to
|
|
178
|
+
* @param handler - Called for each incoming event
|
|
179
|
+
* @returns - Async function that cancels the subscription and closes the WebSocket
|
|
180
|
+
*/
|
|
181
|
+
async subscribe(
|
|
182
|
+
resources: SubscriptionResource[],
|
|
183
|
+
handler: (event: SubscriptionEvent) => void,
|
|
184
|
+
): Promise<() => Promise<void>> {
|
|
185
|
+
// Step 1: POST /subscription to register resources
|
|
186
|
+
const body = buildSubscriptionBody(resources);
|
|
187
|
+
const response = await this.session.post(subscriptions(), body);
|
|
188
|
+
|
|
189
|
+
if (response.status !== 201) {
|
|
190
|
+
throw new RwsError(
|
|
191
|
+
`Subscription POST returned ${response.status}, expected 201`,
|
|
192
|
+
'UNKNOWN',
|
|
193
|
+
response.status,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
const locationHeader = response.headers.get('location');
|
|
198
|
+
if (!locationHeader) {
|
|
199
|
+
throw new RwsError('Subscription POST missing Location header', 'UNKNOWN');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
const subscriptionId = parseSubscriptionId(locationHeader);
|
|
203
|
+
|
|
204
|
+
// Step 2: Derive WebSocket URL and HTTP delete URL from Location header.
|
|
205
|
+
// IRC5 may return ws://host/poll/{id} or http://host/subscription/{id} or a path.
|
|
206
|
+
let wsUrl: string;
|
|
207
|
+
let deleteUrl: string;
|
|
208
|
+
if (locationHeader.startsWith('ws://') || locationHeader.startsWith('wss://')) {
|
|
209
|
+
wsUrl = locationHeader;
|
|
210
|
+
deleteUrl = locationHeader.replace(/^ws:/, 'http:').replace(/^wss:/, 'https:');
|
|
211
|
+
} else if (locationHeader.startsWith('http://') || locationHeader.startsWith('https://')) {
|
|
212
|
+
wsUrl = locationHeader.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:');
|
|
213
|
+
deleteUrl = locationHeader;
|
|
214
|
+
} else {
|
|
215
|
+
wsUrl = `ws://${this.host}:${this.port}${locationHeader}`;
|
|
216
|
+
deleteUrl = `http://${this.host}:${this.port}${locationHeader}`;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const sub: ActiveSubscription = {
|
|
220
|
+
id: subscriptionId,
|
|
221
|
+
wsUrl,
|
|
222
|
+
deleteUrl,
|
|
223
|
+
ws: null,
|
|
224
|
+
handler,
|
|
225
|
+
retryCount: 0,
|
|
226
|
+
closed: false,
|
|
227
|
+
};
|
|
228
|
+
|
|
229
|
+
this.subscriptions.set(subscriptionId, sub);
|
|
230
|
+
|
|
231
|
+
// Step 3: open the WebSocket and wait for it — a subscription without a live
|
|
232
|
+
// event stream is worse than no subscription (silent staleness).
|
|
233
|
+
try {
|
|
234
|
+
await this.openWebSocket(sub);
|
|
235
|
+
} catch (err) {
|
|
236
|
+
sub.closed = true;
|
|
237
|
+
sub.ws = null;
|
|
238
|
+
this.subscriptions.delete(subscriptionId);
|
|
239
|
+
// Free the controller-side slot registered by the POST — best-effort
|
|
240
|
+
await this.session.delete(sub.deleteUrl).catch(() => undefined);
|
|
241
|
+
throw err;
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
// Return unsubscribe function
|
|
245
|
+
return async () => {
|
|
246
|
+
sub.closed = true;
|
|
247
|
+
if (sub.ws) {
|
|
248
|
+
sub.ws.close();
|
|
249
|
+
sub.ws = null;
|
|
250
|
+
}
|
|
251
|
+
this.subscriptions.delete(subscriptionId);
|
|
252
|
+
// Best-effort DELETE — ignore errors (controller may have already cleaned up)
|
|
253
|
+
await this.session.delete(sub.deleteUrl).catch(() => undefined);
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** Close all active subscriptions */
|
|
258
|
+
async closeAll(): Promise<void> {
|
|
259
|
+
const promises: Promise<void>[] = [];
|
|
260
|
+
for (const sub of this.subscriptions.values()) {
|
|
261
|
+
sub.closed = true;
|
|
262
|
+
if (sub.ws) {
|
|
263
|
+
sub.ws.close();
|
|
264
|
+
sub.ws = null;
|
|
265
|
+
}
|
|
266
|
+
promises.push(
|
|
267
|
+
this.session.delete(sub.deleteUrl).then(() => undefined).catch(() => undefined),
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
this.subscriptions.clear();
|
|
271
|
+
await Promise.allSettled(promises);
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── WebSocket lifecycle ────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Open the event WebSocket for a subscription. Resolves once the socket is
|
|
278
|
+
* open; rejects if it errors or closes before ever opening (refused connection,
|
|
279
|
+
* rejected upgrade). Reconnect handling for established streams lives in the
|
|
280
|
+
* close handler, so callers of reconnect attempts must catch rejections.
|
|
281
|
+
*/
|
|
282
|
+
private openWebSocket(sub: ActiveSubscription): Promise<void> {
|
|
283
|
+
return new Promise<void>((resolve, reject) => {
|
|
284
|
+
const cookieHeader = this.session.getCookieHeader();
|
|
285
|
+
const WS = this.wsCtor ?? resolveWebSocket();
|
|
286
|
+
const ws = new WS(sub.wsUrl, ['robapi2_subscription'], {
|
|
287
|
+
headers: { Cookie: cookieHeader },
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
sub.ws = ws;
|
|
291
|
+
let opened = false;
|
|
292
|
+
|
|
293
|
+
const failBeforeOpen = (): void => {
|
|
294
|
+
reject(new RwsError(
|
|
295
|
+
`WebSocket for subscription ${sub.id} failed before opening`,
|
|
296
|
+
'NETWORK_ERROR',
|
|
297
|
+
));
|
|
298
|
+
};
|
|
299
|
+
|
|
300
|
+
ws.onopen = (): void => {
|
|
301
|
+
opened = true;
|
|
302
|
+
sub.retryCount = 0;
|
|
303
|
+
resolve();
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
ws.onmessage = (event: MessageEvent): void => {
|
|
307
|
+
try {
|
|
308
|
+
const data = typeof event.data === 'string' ? event.data : String(event.data);
|
|
309
|
+
const events = parseWsMessage(data);
|
|
310
|
+
for (const e of events) {
|
|
311
|
+
sub.handler(e);
|
|
312
|
+
}
|
|
313
|
+
} catch {
|
|
314
|
+
// Silently discard unparseable messages — don't crash the subscriber
|
|
315
|
+
}
|
|
316
|
+
};
|
|
317
|
+
|
|
318
|
+
ws.onerror = (): void => {
|
|
319
|
+
// Post-open errors are followed by onclose; reconnect logic lives there
|
|
320
|
+
if (!opened) failBeforeOpen();
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
ws.onclose = (event: Event & { wasClean?: boolean }): void => {
|
|
324
|
+
if (!opened) {
|
|
325
|
+
// Never established — reject and let the caller decide (subscribe
|
|
326
|
+
// deletes the registration; reconnect attempts consume a retry)
|
|
327
|
+
failBeforeOpen();
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
if (sub.closed) return; // intentional close — do not reconnect
|
|
331
|
+
|
|
332
|
+
if (!event.wasClean) {
|
|
333
|
+
this.scheduleReconnect(sub);
|
|
334
|
+
}
|
|
335
|
+
};
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/** Reconnect a lost (previously open) stream with backoff; gives up after MAX_RETRIES. */
|
|
340
|
+
private scheduleReconnect(sub: ActiveSubscription): void {
|
|
341
|
+
if (sub.closed || sub.retryCount >= MAX_RETRIES) return;
|
|
342
|
+
const delay = BACKOFF_MS[sub.retryCount] ?? BACKOFF_MS[BACKOFF_MS.length - 1];
|
|
343
|
+
sub.retryCount++;
|
|
344
|
+
setTimeout(() => {
|
|
345
|
+
if (sub.closed) return;
|
|
346
|
+
// A reconnect attempt that fails before opening consumes a retry and tries again
|
|
347
|
+
this.openWebSocket(sub).catch(() => this.scheduleReconnect(sub));
|
|
348
|
+
}, delay);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight regex-based XHTML parser for ABB RWS 2.0 responses.
|
|
3
|
+
* RWS 2.0 returns application/xhtml+xml;v=2.0 for all endpoints.
|
|
4
|
+
* Data lives in <li class="TYPE"> elements containing <span class="FIELD">VALUE</span> nodes.
|
|
5
|
+
*/
|
|
6
|
+
export class XhtmlParser {
|
|
7
|
+
constructor(private readonly html: string) {}
|
|
8
|
+
|
|
9
|
+
/** Returns span-value map from the first <li class="liClass"> in the document. */
|
|
10
|
+
getState(liClass: string): Record<string, string> {
|
|
11
|
+
return this.getAllStates(liClass)[0] ?? {};
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Returns an array of span-value maps, one per <li class="liClass"> in the document. */
|
|
15
|
+
getAllStates(liClass: string): Array<Record<string, string>> {
|
|
16
|
+
const results: Array<Record<string, string>> = [];
|
|
17
|
+
const liRe = new RegExp(`<li class="${liClass}"([^>]*)>([\\s\\S]*?)</li>`, 'g');
|
|
18
|
+
for (const m of this.html.matchAll(liRe)) {
|
|
19
|
+
const attrs = m[1];
|
|
20
|
+
const inner = m[2];
|
|
21
|
+
const fields: Record<string, string> = {};
|
|
22
|
+
|
|
23
|
+
const titleM = attrs.match(/title="([^"]*)"/);
|
|
24
|
+
if (titleM) { fields['_title'] = titleM[1]; }
|
|
25
|
+
|
|
26
|
+
// href with rel="self" — stores the resource path (used for signal network/device)
|
|
27
|
+
const hrefM = inner.match(/href="([^"]*?)" rel="self"/);
|
|
28
|
+
if (hrefM) { fields['_href'] = hrefM[1]; }
|
|
29
|
+
|
|
30
|
+
for (const [, cls, val] of inner.matchAll(/<span class="([^"]+)">([^<]*)<\/span>/g)) {
|
|
31
|
+
fields[cls] = val;
|
|
32
|
+
}
|
|
33
|
+
results.push(fields);
|
|
34
|
+
}
|
|
35
|
+
return results;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Extracts a single span value from anywhere in the document. */
|
|
39
|
+
get(spanClass: string): string | undefined {
|
|
40
|
+
return this.html.match(new RegExp(`<span class="${spanClass}">([^<]*)<\\/span>`))?.[1];
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Returns error details if the response contains an ABB error status block.
|
|
45
|
+
* Error pattern: <span class="code">-1073445862</span><span class="msg">...</span>
|
|
46
|
+
*/
|
|
47
|
+
getError(): { code: string; msg: string } | null {
|
|
48
|
+
const codeM = this.html.match(/<span class="code">(-\d+)<\/span>/);
|
|
49
|
+
if (!codeM) { return null; }
|
|
50
|
+
const msgM = this.html.match(/<span class="msg">([^<]*)<\/span>/);
|
|
51
|
+
return { code: codeM[1], msg: msgM?.[1] ?? '' };
|
|
52
|
+
}
|
|
53
|
+
}
|
package/src/detect.ts
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
import * as https from 'https';
|
|
2
|
+
import * as http from 'http';
|
|
3
|
+
import { RwsClient } from './RwsClient.js';
|
|
4
|
+
import { RwsClient2 } from './RwsClient2.js';
|
|
5
|
+
import { RWS1Adapter } from './RWS1Adapter.js';
|
|
6
|
+
import { RWS2Adapter } from './RWS2Adapter.js';
|
|
7
|
+
import { RwsError } from './types.js';
|
|
8
|
+
import type { IRWSAdapter } from './IRWSAdapter.js';
|
|
9
|
+
|
|
10
|
+
export type AnyClient = RwsClient | RwsClient2;
|
|
11
|
+
export type Protocol = 'rws1' | 'rws2';
|
|
12
|
+
|
|
13
|
+
export interface ConnectOptions {
|
|
14
|
+
/** Hostname or IP, e.g. '192.168.125.1' or '127.0.0.1'. */
|
|
15
|
+
host: string;
|
|
16
|
+
/** TCP port. If omitted, common ports are probed (5466, 9403, 443 https; 80, 11811 http). */
|
|
17
|
+
port?: number;
|
|
18
|
+
/** Force the transport scheme. If omitted, inferred from port (443, 5466, 9403 → https). */
|
|
19
|
+
https?: boolean;
|
|
20
|
+
/** Default 'Admin'. */
|
|
21
|
+
username?: string;
|
|
22
|
+
/** Default 'robotics'. */
|
|
23
|
+
password?: string;
|
|
24
|
+
/** Per-request timeout in ms. Default 5000. */
|
|
25
|
+
timeout?: number;
|
|
26
|
+
/**
|
|
27
|
+
* Verify TLS certificates. Default false — ABB controllers (virtual and
|
|
28
|
+
* real) ship self-signed certs, so verification stays off unless the
|
|
29
|
+
* deployment has a CA-signed cert on the controller. Applies to the probe
|
|
30
|
+
* requests and to the RWS 2.0 client this factory constructs.
|
|
31
|
+
* Live-verified 2026-07-09: strict probe rejects the OmniCore VC's
|
|
32
|
+
* self-signed cert (RW7.21); default detects it; plain-HTTP IRC5 unaffected.
|
|
33
|
+
*/
|
|
34
|
+
strictTls?: boolean;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ProbeResult {
|
|
38
|
+
protocol: Protocol;
|
|
39
|
+
port: number;
|
|
40
|
+
https: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Probe a single host:port and return which RWS protocol it speaks, or null if
|
|
45
|
+
* neither. Reads the WWW-Authenticate header on a 401:
|
|
46
|
+
* - "Digest …" → RWS 1.0
|
|
47
|
+
* - "Basic …" → RWS 2.0
|
|
48
|
+
*/
|
|
49
|
+
export async function probeProtocol(
|
|
50
|
+
host: string,
|
|
51
|
+
port: number,
|
|
52
|
+
useHttps: boolean,
|
|
53
|
+
timeoutMs = 3000,
|
|
54
|
+
strictTls = false,
|
|
55
|
+
): Promise<Protocol | null> {
|
|
56
|
+
return new Promise(resolve => {
|
|
57
|
+
const insecure = useHttps && !strictTls;
|
|
58
|
+
const agent = insecure ? new https.Agent({ rejectUnauthorized: false }) : undefined;
|
|
59
|
+
const options: http.RequestOptions & { agent?: https.Agent; rejectUnauthorized?: boolean } = {
|
|
60
|
+
method: 'GET',
|
|
61
|
+
hostname: host,
|
|
62
|
+
port,
|
|
63
|
+
path: '/rw/system',
|
|
64
|
+
headers: { Accept: 'application/xhtml+xml;v=2.0' },
|
|
65
|
+
agent,
|
|
66
|
+
// Per-request as well as on the agent: agent-swapping hosts (VS Code extension
|
|
67
|
+
// host on non-localhost targets) otherwise re-enable TLS verification (issue #2).
|
|
68
|
+
// Under strictTls neither is set, so certs verify normally.
|
|
69
|
+
...(insecure ? { rejectUnauthorized: false } : {}),
|
|
70
|
+
};
|
|
71
|
+
const transport = useHttps ? https : http;
|
|
72
|
+
const req = (transport as typeof https).request(options as https.RequestOptions, res => {
|
|
73
|
+
const auth = (res.headers['www-authenticate'] || '').toString().toLowerCase();
|
|
74
|
+
// Drain the body so the socket can return to the pool.
|
|
75
|
+
res.on('data', () => {});
|
|
76
|
+
res.on('end', () => {
|
|
77
|
+
if (auth.startsWith('digest ')) { resolve('rws1'); return; }
|
|
78
|
+
if (auth.startsWith('basic ')) { resolve('rws2'); return; }
|
|
79
|
+
// No Digest/Basic challenge → not an RWS controller. Anything else that
|
|
80
|
+
// answers /rw/system (Bearer-protected services, random web servers,
|
|
81
|
+
// captive portals) used to be misreported as rws2, sending connect
|
|
82
|
+
// attempts at hosts that will never speak the protocol. Controllers
|
|
83
|
+
// always challenge an unauthenticated probe, so there is no legitimate
|
|
84
|
+
// challenge-less case to allow.
|
|
85
|
+
resolve(null);
|
|
86
|
+
});
|
|
87
|
+
});
|
|
88
|
+
req.on('error', () => resolve(null));
|
|
89
|
+
req.setTimeout(timeoutMs, () => { req.destroy(); resolve(null); });
|
|
90
|
+
req.end();
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Probe common RWS ports on a host and return the first one that answers.
|
|
96
|
+
* Order favors known defaults: 5466 (OmniCore VC HTTPS), 9403 (alt OmniCore),
|
|
97
|
+
* 443 (real OmniCore), 80 (real IRC5 HTTP), 11811 (legacy IRC5 VC).
|
|
98
|
+
*/
|
|
99
|
+
export async function probeHost(host: string, timeoutMs = 1500, strictTls = false): Promise<ProbeResult | null> {
|
|
100
|
+
const candidates: Array<[number, boolean]> = [
|
|
101
|
+
[5466, true ],
|
|
102
|
+
[9403, true ],
|
|
103
|
+
[443, true ],
|
|
104
|
+
[80, false],
|
|
105
|
+
[11811, false],
|
|
106
|
+
];
|
|
107
|
+
for (const [port, https] of candidates) {
|
|
108
|
+
const proto = await probeProtocol(host, port, https, timeoutMs, strictTls);
|
|
109
|
+
if (proto) { return { protocol: proto, port, https }; }
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* True when the failure is a credential rejection, in either protocol's flavor:
|
|
116
|
+
* RwsClient throws RwsError code='AUTH_FAILED' (message has no "401"), RwsClient2
|
|
117
|
+
* throws a plain Error with the HTTP status in the message.
|
|
118
|
+
*/
|
|
119
|
+
function isAuthRejection(err: unknown): boolean {
|
|
120
|
+
if (err instanceof RwsError) { return err.code === 'AUTH_FAILED'; }
|
|
121
|
+
return /401|unauthor/i.test(String(err));
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Probe a controller's auth scheme and return the matching protocol-level client,
|
|
126
|
+
* already connected. The returned client is either an `RwsClient` (RWS 1.0) or
|
|
127
|
+
* `RwsClient2` (RWS 2.0); use `client instanceof RwsClient2` (or check `protocol`)
|
|
128
|
+
* to narrow if you need protocol-specific behavior.
|
|
129
|
+
*
|
|
130
|
+
* @throws RwsError code='PROTOCOL_DETECT_FAILED' when neither auth scheme is detected.
|
|
131
|
+
*/
|
|
132
|
+
export async function createClient(opts: ConnectOptions): Promise<AnyClient> {
|
|
133
|
+
const { host, username = 'Admin', password = 'robotics', timeout = 5000 } = opts;
|
|
134
|
+
const strictTls = opts.strictTls === true;
|
|
135
|
+
|
|
136
|
+
let port = opts.port;
|
|
137
|
+
let useHttps = opts.https;
|
|
138
|
+
let protocol: Protocol;
|
|
139
|
+
|
|
140
|
+
if (port === undefined) {
|
|
141
|
+
const probe = await probeHost(host, opts.timeout, strictTls);
|
|
142
|
+
if (!probe) {
|
|
143
|
+
throw new RwsError(`No RWS endpoint found on ${host} — tried ports 5466, 9403, 443, 80, 11811`, 'PROTOCOL_DETECT_FAILED');
|
|
144
|
+
}
|
|
145
|
+
port = probe.port;
|
|
146
|
+
useHttps = probe.https;
|
|
147
|
+
protocol = probe.protocol;
|
|
148
|
+
} else {
|
|
149
|
+
if (useHttps === undefined) { useHttps = port === 443 || port === 5466 || port === 9403; }
|
|
150
|
+
const detected = await probeProtocol(host, port, useHttps, opts.timeout, strictTls);
|
|
151
|
+
if (!detected) {
|
|
152
|
+
throw new RwsError(`No RWS auth challenge from ${host}:${port} — controller may be off, wrong port, or non-RWS service`, 'PROTOCOL_DETECT_FAILED');
|
|
153
|
+
}
|
|
154
|
+
protocol = detected;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// RWS 1.0 (IRC5) ships with `Default User`; `Admin` may not exist or have
|
|
158
|
+
// different password. RWS 2.0 (OmniCore) typically has both. So when the
|
|
159
|
+
// caller didn't pin a username and connect fails on `Admin`, transparently
|
|
160
|
+
// retry with `Default User` so cross-controller code "just works."
|
|
161
|
+
const fallbackUser = opts.username === undefined && username === 'Admin'
|
|
162
|
+
? 'Default User'
|
|
163
|
+
: null;
|
|
164
|
+
|
|
165
|
+
if (protocol === 'rws1') {
|
|
166
|
+
try {
|
|
167
|
+
const c = new RwsClient({ host, port, username, password, timeout });
|
|
168
|
+
await c.connect();
|
|
169
|
+
return c;
|
|
170
|
+
} catch (err) {
|
|
171
|
+
if (fallbackUser && isAuthRejection(err)) {
|
|
172
|
+
const c = new RwsClient({ host, port, username: fallbackUser, password, timeout });
|
|
173
|
+
await c.connect();
|
|
174
|
+
return c;
|
|
175
|
+
}
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
const scheme = useHttps ? 'https' : 'http';
|
|
180
|
+
const clientOpts = { timeout, rejectUnauthorized: strictTls };
|
|
181
|
+
try {
|
|
182
|
+
const c = new RwsClient2(`${scheme}://${host}:${port}`, username, password, clientOpts);
|
|
183
|
+
await c.connect();
|
|
184
|
+
return c;
|
|
185
|
+
} catch (err) {
|
|
186
|
+
if (fallbackUser && isAuthRejection(err)) {
|
|
187
|
+
const c = new RwsClient2(`${scheme}://${host}:${port}`, fallbackUser, password, clientOpts);
|
|
188
|
+
await c.connect();
|
|
189
|
+
return c;
|
|
190
|
+
}
|
|
191
|
+
throw err;
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Like `createClient` but returns the unified-interface adapter (`IRWSAdapter`).
|
|
198
|
+
* Use this when you want to hold a single typed reference across both protocols
|
|
199
|
+
* — e.g. if you write code that calls `.getControllerState()` etc. without
|
|
200
|
+
* caring whether the underlying transport is 1.0 or 2.0.
|
|
201
|
+
*/
|
|
202
|
+
export async function createAdapter(opts: ConnectOptions): Promise<IRWSAdapter> {
|
|
203
|
+
const { host, username = 'Admin', password = 'robotics', timeout = 5000 } = opts;
|
|
204
|
+
const strictTls = opts.strictTls === true;
|
|
205
|
+
|
|
206
|
+
let port = opts.port;
|
|
207
|
+
let useHttps = opts.https;
|
|
208
|
+
let protocol: Protocol;
|
|
209
|
+
|
|
210
|
+
if (port === undefined) {
|
|
211
|
+
const probe = await probeHost(host, opts.timeout, strictTls);
|
|
212
|
+
if (!probe) {
|
|
213
|
+
throw new RwsError(`No RWS endpoint found on ${host}`, 'PROTOCOL_DETECT_FAILED');
|
|
214
|
+
}
|
|
215
|
+
port = probe.port;
|
|
216
|
+
useHttps = probe.https;
|
|
217
|
+
protocol = probe.protocol;
|
|
218
|
+
} else {
|
|
219
|
+
if (useHttps === undefined) { useHttps = port === 443 || port === 5466 || port === 9403; }
|
|
220
|
+
const detected = await probeProtocol(host, port, useHttps, opts.timeout, strictTls);
|
|
221
|
+
if (!detected) {
|
|
222
|
+
throw new RwsError(`No RWS auth challenge from ${host}:${port}`, 'PROTOCOL_DETECT_FAILED');
|
|
223
|
+
}
|
|
224
|
+
protocol = detected;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Same `Default User` retry as createClient — see the comment there.
|
|
228
|
+
const fallbackUser = opts.username === undefined && username === 'Admin'
|
|
229
|
+
? 'Default User'
|
|
230
|
+
: null;
|
|
231
|
+
|
|
232
|
+
if (protocol === 'rws1') {
|
|
233
|
+
try {
|
|
234
|
+
const inner = new RwsClient({ host, port, username, password, timeout });
|
|
235
|
+
await inner.connect();
|
|
236
|
+
return new RWS1Adapter(inner, { host, port, username, password });
|
|
237
|
+
} catch (err) {
|
|
238
|
+
if (fallbackUser && isAuthRejection(err)) {
|
|
239
|
+
const inner = new RwsClient({ host, port, username: fallbackUser, password, timeout });
|
|
240
|
+
await inner.connect();
|
|
241
|
+
return new RWS1Adapter(inner, { host, port, username: fallbackUser, password });
|
|
242
|
+
}
|
|
243
|
+
throw err;
|
|
244
|
+
}
|
|
245
|
+
} else {
|
|
246
|
+
const scheme = useHttps ? 'https' : 'http';
|
|
247
|
+
const clientOpts = { timeout, rejectUnauthorized: strictTls };
|
|
248
|
+
try {
|
|
249
|
+
const a = new RWS2Adapter(`${scheme}://${host}:${port}`, username, password, clientOpts);
|
|
250
|
+
await a.connect();
|
|
251
|
+
return a;
|
|
252
|
+
} catch (err) {
|
|
253
|
+
if (fallbackUser && isAuthRejection(err)) {
|
|
254
|
+
const a = new RWS2Adapter(`${scheme}://${host}:${port}`, fallbackUser, password, clientOpts);
|
|
255
|
+
await a.connect();
|
|
256
|
+
return a;
|
|
257
|
+
}
|
|
258
|
+
throw err;
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|