abb-rws-client 0.7.2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/README.md +17 -8
  3. package/dist/HalJsonParser.d.ts +55 -0
  4. package/dist/HalJsonParser.d.ts.map +1 -0
  5. package/dist/HalJsonParser.js +155 -0
  6. package/dist/HalJsonParser.js.map +1 -0
  7. package/dist/HttpSession.d.ts.map +1 -1
  8. package/dist/HttpSession.js +13 -2
  9. package/dist/HttpSession.js.map +1 -1
  10. package/dist/IRWSAdapter.d.ts +7 -1
  11. package/dist/IRWSAdapter.d.ts.map +1 -1
  12. package/dist/MdnsDiscovery.d.ts +57 -0
  13. package/dist/MdnsDiscovery.d.ts.map +1 -0
  14. package/dist/MdnsDiscovery.js +313 -0
  15. package/dist/MdnsDiscovery.js.map +1 -0
  16. package/dist/MultiRobotManager.d.ts +5 -2
  17. package/dist/MultiRobotManager.d.ts.map +1 -1
  18. package/dist/MultiRobotManager.js +8 -3
  19. package/dist/MultiRobotManager.js.map +1 -1
  20. package/dist/RWS1Adapter.d.ts +60 -2
  21. package/dist/RWS1Adapter.d.ts.map +1 -1
  22. package/dist/RWS1Adapter.js +152 -4
  23. package/dist/RWS1Adapter.js.map +1 -1
  24. package/dist/ResourceMapper.d.ts +4 -0
  25. package/dist/ResourceMapper.d.ts.map +1 -1
  26. package/dist/ResourceMapper.js +9 -1
  27. package/dist/ResourceMapper.js.map +1 -1
  28. package/dist/RobotManager.d.ts +72 -12
  29. package/dist/RobotManager.d.ts.map +1 -1
  30. package/dist/RobotManager.js +255 -50
  31. package/dist/RobotManager.js.map +1 -1
  32. package/dist/RwsClient2.d.ts +150 -10
  33. package/dist/RwsClient2.d.ts.map +1 -1
  34. package/dist/RwsClient2.js +599 -236
  35. package/dist/RwsClient2.js.map +1 -1
  36. package/dist/WsSubscriber.d.ts +31 -5
  37. package/dist/WsSubscriber.d.ts.map +1 -1
  38. package/dist/WsSubscriber.js +104 -50
  39. package/dist/WsSubscriber.js.map +1 -1
  40. package/dist/detect.d.ts +12 -3
  41. package/dist/detect.d.ts.map +1 -1
  42. package/dist/detect.js +69 -25
  43. package/dist/detect.js.map +1 -1
  44. package/dist/index.d.ts +3 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +2 -0
  47. package/dist/index.js.map +1 -1
  48. package/dist/types.js +3 -3
  49. package/dist/types.js.map +1 -1
  50. package/examples/05-remote-control-rmmp.mjs +10 -12
  51. package/examples/06-pull-module-source.mjs +13 -20
  52. package/package.json +62 -60
  53. package/src/HalJsonParser.ts +137 -0
  54. package/src/HttpSession.ts +460 -0
  55. package/src/IRWSAdapter.ts +422 -0
  56. package/src/Logger.ts +54 -0
  57. package/src/MdnsDiscovery.ts +336 -0
  58. package/src/MultiRobotManager.ts +159 -0
  59. package/src/RWS1Adapter.ts +1018 -0
  60. package/src/RWS2Adapter.ts +19 -0
  61. package/src/ResourceMapper.ts +517 -0
  62. package/src/ResponseParser.ts +710 -0
  63. package/src/RobotManager.ts +1705 -0
  64. package/src/RwsClient.ts +1150 -0
  65. package/src/RwsClient2.ts +2214 -0
  66. package/src/WsSubscriber.ts +350 -0
  67. package/src/XhtmlParser.ts +53 -0
  68. package/src/detect.ts +261 -0
  69. package/src/index.ts +83 -0
  70. package/src/types.ts +336 -0
@@ -0,0 +1,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
+ }