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,710 @@
1
+ /**
2
+ * ResponseParser — pure functions that parse RWS 1.0 XML/XHTML responses into typed objects.
3
+ *
4
+ * RWS returns XHTML with <li class="..."> elements containing <span class="..."> children.
5
+ * Parsing uses regex + string methods only — no external XML libraries.
6
+ * All functions throw RwsError('PARSE_ERROR') on malformed or missing data.
7
+ */
8
+
9
+ import { RwsError } from './types.js';
10
+ import type {
11
+ ControllerState,
12
+ OperationMode,
13
+ ExecutionState,
14
+ ExecutionInfo,
15
+ JointTarget,
16
+ RobTarget,
17
+ CartesianFull,
18
+ Signal,
19
+ RapidTask,
20
+ IoNetwork,
21
+ IoDevice,
22
+ SystemInfo,
23
+ ControllerIdentity,
24
+ ElogMessage,
25
+ FileEntry,
26
+ CollisionDetectionState,
27
+ RapidSymbolProperties,
28
+ ControllerClock,
29
+ UiInstruction,
30
+ RapidSymbolInfo,
31
+ } from './types.js';
32
+
33
+ // ─── Internal helpers ────────────────────────────────────────────────────────
34
+
35
+ /**
36
+ * Decode the minimum set of HTML entities that may appear in RWS span values.
37
+ */
38
+ function decodeEntities(s: string): string {
39
+ return s
40
+ .replace(/&amp;/g, '&')
41
+ .replace(/&lt;/g, '<')
42
+ .replace(/&gt;/g, '>')
43
+ .replace(/&quot;/g, '"')
44
+ .replace(/&#39;/g, "'");
45
+ }
46
+
47
+ /**
48
+ * Find the first <li> whose class attribute contains `liClass` as a whole word,
49
+ * then within that block find the first <span> whose class attribute contains
50
+ * `spanClass` as a whole word. Returns the trimmed inner text, or undefined.
51
+ *
52
+ * Uses the \b word boundary so 'rap-task-li' does not match 'rap-task-li-selected'.
53
+ * The 'is' flag makes '.' match newlines and matching case-insensitive.
54
+ */
55
+ function extractSpanValue(xml: string, liClass: string, spanClass: string): string | undefined {
56
+ // Phase 1: find the <li> block
57
+ const liPattern = new RegExp(
58
+ `<li[^>]*class="[^"]*\\b${liClass}\\b[^"]*"[^>]*>(.*?)</li>`,
59
+ 'is',
60
+ );
61
+ const liMatch = xml.match(liPattern);
62
+ if (!liMatch) return undefined;
63
+
64
+ const liContent = liMatch[1];
65
+
66
+ // Phase 2: find the <span> within that block
67
+ const spanPattern = new RegExp(
68
+ `<span[^>]*class="[^"]*\\b${spanClass}\\b[^"]*"[^>]*>(.*?)</span>`,
69
+ 'is',
70
+ );
71
+ const spanMatch = liContent.match(spanPattern);
72
+ if (!spanMatch) return undefined;
73
+
74
+ return decodeEntities(spanMatch[1].trim());
75
+ }
76
+
77
+ /**
78
+ * Like extractSpanValue but searches the entire xml string for a <span> with the given class.
79
+ * Used for flat responses that have no <li> wrapper.
80
+ */
81
+ function extractSpanValueFlat(xml: string, spanClass: string): string | undefined {
82
+ const spanPattern = new RegExp(
83
+ `<span[^>]*class="[^"]*\\b${spanClass}\\b[^"]*"[^>]*>(.*?)</span>`,
84
+ 'is',
85
+ );
86
+ const spanMatch = xml.match(spanPattern);
87
+ if (!spanMatch) return undefined;
88
+ return decodeEntities(spanMatch[1].trim());
89
+ }
90
+
91
+ function requireSpan(xml: string, liClass: string, spanClass: string, context: string): string {
92
+ const val = extractSpanValue(xml, liClass, spanClass);
93
+ if (val === undefined || val === '') {
94
+ throw new RwsError(
95
+ `PARSE_ERROR: missing <span class="${spanClass}"> in <li class="${liClass}"> (${context})`,
96
+ 'PARSE_ERROR',
97
+ );
98
+ }
99
+ return val;
100
+ }
101
+
102
+ function requireFloat(raw: string, field: string): number {
103
+ const n = parseFloat(raw);
104
+ if (isNaN(n)) {
105
+ throw new RwsError(`PARSE_ERROR: expected float for "${field}", got "${raw}"`, 'PARSE_ERROR');
106
+ }
107
+ return n;
108
+ }
109
+
110
+ // ─── Public parsers ──────────────────────────────────────────────────────────
111
+
112
+ const VALID_CONTROLLER_STATES: ReadonlySet<string> = new Set([
113
+ 'init',
114
+ 'motoroff',
115
+ 'motoron',
116
+ 'guardstop',
117
+ 'emergencystop',
118
+ 'emergencystopreset',
119
+ 'sysfail',
120
+ ]);
121
+
122
+ /**
123
+ * Parse a /rw/panel/ctrlstate XML response into a ControllerState.
124
+ * XML: <li class="pnl-ctrlstate"><span class="ctrlstate">motoron</span></li>
125
+ */
126
+ export function parseControllerState(xml: string): ControllerState {
127
+ const raw = requireSpan(xml, 'pnl-ctrlstate', 'ctrlstate', 'parseControllerState');
128
+ if (!VALID_CONTROLLER_STATES.has(raw)) {
129
+ throw new RwsError(`PARSE_ERROR: unknown controller state "${raw}"`, 'PARSE_ERROR');
130
+ }
131
+ return raw as ControllerState;
132
+ }
133
+
134
+ const VALID_OPERATION_MODES: ReadonlySet<string> = new Set(['AUTO', 'MANR', 'MANF']);
135
+
136
+ /**
137
+ * Parse a /rw/panel/opmode XML response into an OperationMode.
138
+ * XML: <li class="pnl-opmode"><span class="opmode">AUTO</span></li>
139
+ */
140
+ export function parseOperationMode(xml: string): OperationMode {
141
+ const raw = requireSpan(xml, 'pnl-opmode', 'opmode', 'parseOperationMode');
142
+ // RWS may return lower-case variants; normalise to upper
143
+ const upper = raw.toUpperCase();
144
+ if (!VALID_OPERATION_MODES.has(upper)) {
145
+ throw new RwsError(`PARSE_ERROR: unknown operation mode "${raw}"`, 'PARSE_ERROR');
146
+ }
147
+ return upper as OperationMode;
148
+ }
149
+
150
+ /**
151
+ * Parse a /rw/rapid/execution XML response into an ExecutionState.
152
+ * XML: <li class="rap-execution"><span class="ctrlexecstate">stopped</span><span class="cycle">forever</span></li>
153
+ *
154
+ * Note: some firmware versions emit the <li> with class "rap-execution-state" — we
155
+ * try both and also fall back to a flat span search.
156
+ */
157
+ export function parseExecutionState(xml: string): ExecutionState {
158
+ return parseExecutionInfo(xml).state;
159
+ }
160
+
161
+ /**
162
+ * Parse a /rw/rapid/execution XML response into ExecutionInfo (state + cycle).
163
+ */
164
+ export function parseExecutionInfo(xml: string): ExecutionInfo {
165
+ let raw =
166
+ extractSpanValue(xml, 'rap-execution', 'ctrlexecstate') ??
167
+ extractSpanValue(xml, 'rap-execution', 'excstate') ??
168
+ extractSpanValue(xml, 'rap-execution-state', 'ctrlexecstate') ??
169
+ extractSpanValue(xml, 'rap-execution-state', 'excstate') ??
170
+ extractSpanValueFlat(xml, 'ctrlexecstate') ??
171
+ extractSpanValueFlat(xml, 'excstate');
172
+
173
+ if (!raw) {
174
+ throw new RwsError(
175
+ 'PARSE_ERROR: missing ctrlexecstate/excstate in execution response',
176
+ 'PARSE_ERROR',
177
+ );
178
+ }
179
+ raw = raw.toLowerCase();
180
+ if (raw === 'stop') raw = 'stopped';
181
+ if (raw !== 'running' && raw !== 'stopped') {
182
+ throw new RwsError(`PARSE_ERROR: unknown execution state "${raw}"`, 'PARSE_ERROR');
183
+ }
184
+
185
+ const cycle =
186
+ extractSpanValue(xml, 'rap-execution', 'cycle') ??
187
+ extractSpanValue(xml, 'rap-execution-state', 'cycle') ??
188
+ extractSpanValueFlat(xml, 'cycle') ??
189
+ 'asis';
190
+
191
+ return { state: raw as ExecutionState, cycle };
192
+ }
193
+
194
+ /**
195
+ * Parse a /rw/mechunit/{unit}/joint-target XML response into a JointTarget.
196
+ * XML: <li class="rap-jointtarget">
197
+ * <span class="rax_1">10.00</span> ... <span class="rax_6">-90.00</span>
198
+ * </li>
199
+ */
200
+ export function parseJointTarget(xml: string): JointTarget {
201
+ const axes = ['rax_1', 'rax_2', 'rax_3', 'rax_4', 'rax_5', 'rax_6'] as const;
202
+
203
+ // Find the containing <li> block first
204
+ const liPattern = /<li[^>]*class="[^"]*\bms-jointtarget\b[^"]*"[^>]*>(.*?)<\/li>/is;
205
+ const liMatch = xml.match(liPattern);
206
+ if (!liMatch) {
207
+ throw new RwsError('PARSE_ERROR: missing <li class="ms-jointtarget">', 'PARSE_ERROR');
208
+ }
209
+ const block = liMatch[1];
210
+
211
+ const result: Partial<JointTarget> = {};
212
+ for (const ax of axes) {
213
+ const spanPattern = new RegExp(
214
+ `<span[^>]*class="[^"]*\\b${ax}\\b[^"]*"[^>]*>(.*?)</span>`,
215
+ 'is',
216
+ );
217
+ const m = block.match(spanPattern);
218
+ if (!m) {
219
+ throw new RwsError(`PARSE_ERROR: missing <span class="${ax}">`, 'PARSE_ERROR');
220
+ }
221
+ result[ax] = requireFloat(m[1].trim(), ax);
222
+ }
223
+ return result as JointTarget;
224
+ }
225
+
226
+ /**
227
+ * Parse a /rw/mechunit/{unit}/robtarget XML response into a RobTarget.
228
+ * XML: <li class="rap-robtarget">
229
+ * <span class="x">...</span><span class="y">...</span>...
230
+ * </li>
231
+ */
232
+ export function parseRobTarget(xml: string): RobTarget {
233
+ const liPattern = /<li[^>]*class="[^"]*\bms-robtargets\b[^"]*"[^>]*>(.*?)<\/li>/is;
234
+ const liMatch = xml.match(liPattern);
235
+ if (!liMatch) {
236
+ throw new RwsError('PARSE_ERROR: missing <li class="ms-robtargets">', 'PARSE_ERROR');
237
+ }
238
+ const block = liMatch[1];
239
+
240
+ function getField(cls: string): number {
241
+ const spanPattern = new RegExp(
242
+ `<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`,
243
+ 'is',
244
+ );
245
+ const m = block.match(spanPattern);
246
+ if (!m) throw new RwsError(`PARSE_ERROR: missing <span class="${cls}">`, 'PARSE_ERROR');
247
+ return requireFloat(m[1].trim(), cls);
248
+ }
249
+
250
+ return {
251
+ x: getField('x'),
252
+ y: getField('y'),
253
+ z: getField('z'),
254
+ q1: getField('q1'),
255
+ q2: getField('q2'),
256
+ q3: getField('q3'),
257
+ q4: getField('q4'),
258
+ };
259
+ }
260
+
261
+ /**
262
+ * Parse a single I/O signal from a /rw/iosystem/signals/... XML response.
263
+ * XML: <li class="ios-signal-li">
264
+ * <span class="name">DI_1</span>
265
+ * <span class="lvalue">0</span>
266
+ * <span class="type">DI</span>
267
+ * </li>
268
+ *
269
+ * Can also be called with a single <li> block extracted by parseSignalList.
270
+ */
271
+ export function parseSignal(xml: string): Signal {
272
+ // Individual signal endpoint returns <li class="ios-signal">
273
+ // Signal list endpoint returns <li class="ios-signal-li">
274
+ const nameRaw =
275
+ extractSpanValue(xml, 'ios-signal', 'name') ??
276
+ extractSpanValue(xml, 'ios-signal-li', 'name') ??
277
+ extractSpanValueFlat(xml, 'name');
278
+ const lvalueRaw =
279
+ extractSpanValue(xml, 'ios-signal', 'lvalue') ??
280
+ extractSpanValue(xml, 'ios-signal-li', 'lvalue') ??
281
+ extractSpanValueFlat(xml, 'lvalue');
282
+ const typeRaw =
283
+ extractSpanValue(xml, 'ios-signal', 'type') ??
284
+ extractSpanValue(xml, 'ios-signal-li', 'type') ??
285
+ extractSpanValueFlat(xml, 'type');
286
+
287
+ if (!nameRaw) throw new RwsError('PARSE_ERROR: missing signal name', 'PARSE_ERROR');
288
+ if (lvalueRaw === undefined) throw new RwsError('PARSE_ERROR: missing signal lvalue', 'PARSE_ERROR');
289
+ if (!typeRaw) throw new RwsError('PARSE_ERROR: missing signal type', 'PARSE_ERROR');
290
+
291
+ const validTypes = new Set(['DI', 'DO', 'AI', 'AO', 'GI', 'GO']);
292
+ if (!validTypes.has(typeRaw)) {
293
+ throw new RwsError(`PARSE_ERROR: unknown signal type "${typeRaw}"`, 'PARSE_ERROR');
294
+ }
295
+
296
+ return {
297
+ name: nameRaw,
298
+ value: lvalueRaw,
299
+ type: typeRaw as Signal['type'],
300
+ lvalue: lvalueRaw,
301
+ };
302
+ }
303
+
304
+ /**
305
+ * Parse a list of I/O signals from a /rw/iosystem/signals XML response.
306
+ * Extracts every <li class="ios-signal-li"> block and calls parseSignal on each.
307
+ */
308
+ export function parseSignalList(xml: string): Signal[] {
309
+ const blocks = [
310
+ ...xml.matchAll(/<li[^>]*class="[^"]*\bios-signal-li\b[^"]*"[^>]*>.*?<\/li>/gis),
311
+ ];
312
+ return blocks.map(([block]) => parseSignal(block));
313
+ }
314
+
315
+ /**
316
+ * Parse a /rw/rapid/tasks XML response into an array of RapidTask objects.
317
+ * XML: multiple <li class="rap-task-li"> elements.
318
+ */
319
+ export function parseRapidTasks(xml: string): RapidTask[] {
320
+ const blocks = [
321
+ ...xml.matchAll(/<li[^>]*class="[^"]*\brap-task-li\b[^"]*"[^>]*>.*?<\/li>/gis),
322
+ ];
323
+
324
+ if (blocks.length === 0) {
325
+ throw new RwsError('PARSE_ERROR: no <li class="rap-task-li"> found', 'PARSE_ERROR');
326
+ }
327
+
328
+ return blocks.map(([block]) => {
329
+ function getSpan(cls: string): string {
330
+ const m = block.match(
331
+ new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'),
332
+ );
333
+ return m ? decodeEntities(m[1].trim()) : '';
334
+ }
335
+
336
+ const name = getSpan('name');
337
+ if (!name) throw new RwsError('PARSE_ERROR: RAPID task missing name', 'PARSE_ERROR');
338
+
339
+ let excstateRaw = getSpan('excstate').toLowerCase();
340
+ // Normalise known variants
341
+ if (excstateRaw === 'stop') excstateRaw = 'stopped';
342
+ if (excstateRaw === 'read') excstateRaw = 'stopped'; // 'read' = ready/idle on some firmware
343
+ // Treat any unrecognised state as 'stopped' so fetchAll never crashes
344
+ if (excstateRaw !== 'running' && excstateRaw !== 'stopped') {
345
+ excstateRaw = 'stopped';
346
+ }
347
+
348
+ // 'active' may be 'On'/'Off' or 'true'/'false' depending on firmware version
349
+ const activeRaw = getSpan('active').toLowerCase();
350
+ const active = activeRaw === 'true' || activeRaw === 'on';
351
+
352
+ return {
353
+ name,
354
+ type: getSpan('type'),
355
+ taskstate: getSpan('taskstate'),
356
+ excstate: excstateRaw as ExecutionState,
357
+ active,
358
+ motiontask: getSpan('motiontask').toLowerCase() === 'true',
359
+ };
360
+ });
361
+ }
362
+
363
+ /**
364
+ * Parse a /rw/panel/speedratio response into a number (0–100).
365
+ * XML: <li class="pnl-speedratio"><span class="speedratio">100</span></li>
366
+ */
367
+ export function parseSpeedRatio(xml: string): number {
368
+ const raw = requireSpan(xml, 'pnl-speedratio', 'speedratio', 'parseSpeedRatio');
369
+ return requireFloat(raw, 'speedratio');
370
+ }
371
+
372
+ /**
373
+ * Parse a /rw/rapid/symbol/data/... response — returns the raw value string.
374
+ * XML: <li class="rap-data"><span class="value">42</span></li>
375
+ * The caller interprets the string (number, bool, array, record, etc.).
376
+ */
377
+ export function parseRapidSymbolValue(xml: string): string {
378
+ const val = extractSpanValue(xml, 'rap-data', 'value');
379
+ if (val === undefined) {
380
+ throw new RwsError('PARSE_ERROR: missing <span class="value"> in rap-data', 'PARSE_ERROR');
381
+ }
382
+ return val;
383
+ }
384
+
385
+ /**
386
+ * Parse a /rw/motionsystem/mechunits/{unit}/cartesian response into a CartesianFull.
387
+ * XML: <li class="ms-mechunit-cartesian">
388
+ * <span class="x">...</span>...<span class="j1">0</span>...
389
+ * </li>
390
+ */
391
+ export function parseCartesianFull(xml: string): CartesianFull {
392
+ const liPattern = /<li[^>]*class="[^"]*\bms-mechunit-cartesian\b[^"]*"[^>]*>(.*?)<\/li>/is;
393
+ const liMatch = xml.match(liPattern);
394
+ if (!liMatch) {
395
+ throw new RwsError('PARSE_ERROR: missing <li class="ms-mechunit-cartesian">', 'PARSE_ERROR');
396
+ }
397
+ const block = liMatch[1];
398
+
399
+ function getField(cls: string): number {
400
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
401
+ if (!m) throw new RwsError(`PARSE_ERROR: missing <span class="${cls}"> in cartesian`, 'PARSE_ERROR');
402
+ return requireFloat(m[1].trim(), cls);
403
+ }
404
+
405
+ return {
406
+ x: getField('x'), y: getField('y'), z: getField('z'),
407
+ q1: getField('q1'), q2: getField('q2'), q3: getField('q3'), q4: getField('q4'),
408
+ j1: getField('j1'), j4: getField('j4'), j6: getField('j6'), jx: getField('jx'),
409
+ };
410
+ }
411
+
412
+ /**
413
+ * Parse a /rw/iosystem/networks response into an array of IoNetwork.
414
+ * XML: <li class="ios-network-li"><span class="name">...</span>...</li>
415
+ */
416
+ export function parseNetworks(xml: string): IoNetwork[] {
417
+ const blocks = [...xml.matchAll(/<li[^>]*class="[^"]*\bios-network-li\b[^"]*"[^>]*>.*?<\/li>/gis)];
418
+ return blocks.map(([block]) => {
419
+ function get(cls: string): string {
420
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
421
+ return m ? decodeEntities(m[1].trim()) : '';
422
+ }
423
+ return { name: get('name'), pstate: get('pstate'), lstate: get('lstate') };
424
+ });
425
+ }
426
+
427
+ /**
428
+ * Parse a /rw/iosystem/devices?network=... response into an array of IoDevice.
429
+ * XML: <li class="ios-device-li" title="Local/DRV_1"><span class="name">...</span>...</li>
430
+ */
431
+ export function parseDevices(xml: string): IoDevice[] {
432
+ const blocks = [...xml.matchAll(/<li[^>]*class="[^"]*\bios-device-li\b[^"]*"[^>]*title="([^"]*)"[^>]*>.*?<\/li>/gis)];
433
+ return blocks.map(([block, titleAttr]) => {
434
+ function get(cls: string): string {
435
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
436
+ return m ? decodeEntities(m[1].trim()) : '';
437
+ }
438
+ // title is "Network/DeviceName"
439
+ const parts = titleAttr.split('/');
440
+ const network = parts.length >= 2 ? parts[0] : '';
441
+ return {
442
+ name: get('name'),
443
+ network,
444
+ lstate: get('lstate'),
445
+ pstate: get('pstate'),
446
+ address: get('address'),
447
+ };
448
+ });
449
+ }
450
+
451
+ /**
452
+ * Parse a /rw/system response into SystemInfo.
453
+ * XML: <li class="sys-system-li">...</li> + <li class="sys-option-li">...</li>
454
+ */
455
+ export function parseSystemInfo(xml: string): SystemInfo {
456
+ const sysPattern = /<li[^>]*class="[^"]*\bsys-system-li\b[^"]*"[^>]*>(.*?)<\/li>/is;
457
+ const sysMatch = xml.match(sysPattern);
458
+ if (!sysMatch) throw new RwsError('PARSE_ERROR: missing sys-system-li', 'PARSE_ERROR');
459
+ const block = sysMatch[1];
460
+
461
+ function get(cls: string): string {
462
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
463
+ return m ? decodeEntities(m[1].trim()) : '';
464
+ }
465
+
466
+ const options = [...xml.matchAll(/<li[^>]*class="[^"]*\bsys-option-li\b[^"]*"[^>]*>.*?<span[^>]*class="[^"]*\boption\b[^"]*"[^>]*>(.*?)<\/span>.*?<\/li>/gis)]
467
+ .map(([, opt]) => decodeEntities(opt.trim()))
468
+ .filter(Boolean);
469
+
470
+ return {
471
+ name: get('name'),
472
+ rwVersion: get('rwversion') || `${get('major')}.${get('minor')}.${get('build')}`,
473
+ sysid: get('sysid'),
474
+ startTime: get('starttm'),
475
+ options,
476
+ };
477
+ }
478
+
479
+ /**
480
+ * Parse a /ctrl/identity response into ControllerIdentity.
481
+ * XML: <li class="ctrl-identity-info"><span class="ctrl-name">...</span>...</li>
482
+ */
483
+ export function parseControllerIdentity(xml: string): ControllerIdentity {
484
+ const liPattern = /<li[^>]*class="[^"]*\bctrl-identity-info\b[^"]*"[^>]*>(.*?)<\/li>/is;
485
+ const liMatch = xml.match(liPattern);
486
+ if (!liMatch) throw new RwsError('PARSE_ERROR: missing ctrl-identity-info', 'PARSE_ERROR');
487
+ const block = liMatch[1];
488
+
489
+ function get(cls: string): string {
490
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
491
+ return m ? decodeEntities(m[1].trim()) : '';
492
+ }
493
+
494
+ return {
495
+ name: get('ctrl-name'),
496
+ id: get('ctrl-id'),
497
+ type: get('ctrl-type'),
498
+ mac: get('ctrl-mac'),
499
+ };
500
+ }
501
+
502
+ /**
503
+ * Parse a /rw/elog/{domain} response into an array of ElogMessage.
504
+ * XML: <li class="elog-message-li" title="/rw/elog/0/14073">...</li>
505
+ */
506
+ export function parseElogMessages(xml: string): ElogMessage[] {
507
+ const blocks = [...xml.matchAll(/<li[^>]*class="[^"]*\belog-message-li\b[^"]*"[^>]*title="([^"]*)"[^>]*>.*?<\/li>/gis)];
508
+ return blocks.map(([block, titleAttr]) => {
509
+ function get(cls: string): string {
510
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
511
+ return m ? decodeEntities(m[1].trim()) : '';
512
+ }
513
+ // Extract seqnum from title: "/rw/elog/0/14073" → 14073
514
+ const seqMatch = titleAttr.match(/\/(\d+)$/);
515
+ const seqnum = seqMatch ? parseInt(seqMatch[1], 10) : 0;
516
+ return {
517
+ seqnum,
518
+ code: parseInt(get('code'), 10) || 0,
519
+ msgtype: parseInt(get('msgtype'), 10) || 1,
520
+ timestamp: get('tstamp'),
521
+ srcName: get('src-name'),
522
+ title: get('title'),
523
+ desc: get('desc'),
524
+ causes: get('causes'),
525
+ consequences: get('conseqs'),
526
+ actions: get('actions'),
527
+ };
528
+ });
529
+ }
530
+
531
+ /**
532
+ * Parse a /fileservice/{path} directory listing response into FileEntry[].
533
+ * XML: <li class="fs-file" title="filename.mod">...</li>
534
+ * <li class="fs-dir" title="DirName">...</li>
535
+ */
536
+ export function parseDirectory(xml: string): FileEntry[] {
537
+ const fileBlocks = [...xml.matchAll(/<li[^>]*class="[^"]*\bfs-file\b[^"]*"[^>]*title="([^"]*)"[^>]*>.*?<\/li>/gis)];
538
+ const dirBlocks = [...xml.matchAll(/<li[^>]*class="[^"]*\bfs-dir\b[^"]*"[^>]*title="([^"]*)"[^>]*>.*?<\/li>/gis)];
539
+
540
+ function get(block: string, cls: string): string {
541
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
542
+ return m ? decodeEntities(m[1].trim()) : '';
543
+ }
544
+
545
+ const files: FileEntry[] = fileBlocks.map(([block, name]) => ({
546
+ name,
547
+ type: 'file' as const,
548
+ size: get(block, 'fs-size') ? parseInt(get(block, 'fs-size'), 10) : undefined,
549
+ created: get(block, 'fs-cdate') || undefined,
550
+ modified: get(block, 'fs-mdate') || undefined,
551
+ readonly: get(block, 'fs-readonly') === 'true',
552
+ }));
553
+
554
+ const dirs: FileEntry[] = dirBlocks.map(([block, name]) => ({
555
+ name,
556
+ type: 'dir' as const,
557
+ created: get(block, 'fs-cdate') || undefined,
558
+ modified: get(block, 'fs-mdate') || undefined,
559
+ }));
560
+
561
+ return [...dirs, ...files];
562
+ }
563
+
564
+ const VALID_COLLISION_STATES: ReadonlySet<string> = new Set([
565
+ 'INIT', 'TRIGGERED', 'CONFIRMED', 'TRIGGERED_ACK',
566
+ ]);
567
+
568
+ /**
569
+ * Parse a /rw/panel/coldetstate response into a CollisionDetectionState.
570
+ * XML: <li class="pnl-coldetstate"><span class="coldetstate">INIT</span></li>
571
+ */
572
+ export function parseCollisionDetectionState(xml: string): CollisionDetectionState {
573
+ const raw = requireSpan(xml, 'pnl-coldetstate', 'coldetstate', 'parseCollisionDetectionState');
574
+ const upper = raw.toUpperCase();
575
+ if (!VALID_COLLISION_STATES.has(upper)) {
576
+ // Return INIT as safe fallback for unknown values (firmware may add new states)
577
+ return 'INIT';
578
+ }
579
+ return upper as CollisionDetectionState;
580
+ }
581
+
582
+ /**
583
+ * Parse a /rw/rapid/symbol/properties/... response into RapidSymbolProperties.
584
+ * XML: <li class="rap-sympropvar" title="RAPID/T_ROB1/user/reg1">...</li>
585
+ */
586
+ export function parseRapidSymbolProperties(xml: string): RapidSymbolProperties {
587
+ const liPattern = /<li[^>]*class="[^"]*\brap-sympropvar\b[^"]*"[^>]*>(.*?)<\/li>/is;
588
+ const liMatch = xml.match(liPattern);
589
+ if (!liMatch) {
590
+ throw new RwsError('PARSE_ERROR: missing <li class="rap-sympropvar">', 'PARSE_ERROR');
591
+ }
592
+ const block = liMatch[1];
593
+
594
+ function get(cls: string): string {
595
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
596
+ return m ? decodeEntities(m[1].trim()) : '';
597
+ }
598
+
599
+ // Extract symburl from the <li title="..."> attribute
600
+ const titleMatch = xml.match(/<li[^>]*class="[^"]*\brap-sympropvar\b[^"]*"[^>]*title="([^"]*)"[^>]*>/i);
601
+ const symburl = titleMatch ? titleMatch[1] : get('symburl');
602
+
603
+ return {
604
+ symburl,
605
+ symtyp: get('symtyp'),
606
+ named: get('named').toLowerCase() === 'true',
607
+ dattyp: get('dattyp'),
608
+ ndim: parseInt(get('ndim') || '0', 10),
609
+ dim: get('dim'),
610
+ heap: get('heap').toLowerCase() === 'true',
611
+ linked: get('linked').toLowerCase() === 'true',
612
+ local: get('local').toLowerCase() === 'true',
613
+ ro: get('ro').toLowerCase() === 'true',
614
+ taskvar: get('taskvar').toLowerCase() === 'true',
615
+ storage: get('storage'),
616
+ typurl: get('typurl'),
617
+ };
618
+ }
619
+
620
+ /**
621
+ * Parse a GET /ctrl/clock response into ControllerClock.
622
+ * XML: <li class="ctrl-clock-info"><span class="datetime">2015-06-18 T 12:56:07</span></li>
623
+ */
624
+ export function parseControllerClock(xml: string): ControllerClock {
625
+ const val = extractSpanValue(xml, 'ctrl-clock-info', 'datetime') ?? extractSpanValueFlat(xml, 'datetime');
626
+ if (!val) {
627
+ throw new RwsError('PARSE_ERROR: missing datetime in ctrl-clock-info', 'PARSE_ERROR');
628
+ }
629
+ return { datetime: val };
630
+ }
631
+
632
+ /**
633
+ * Parse a GET /rw/rapid/uiinstr/active response into a UiInstruction.
634
+ * XML: <li class="rap-uiactive-li"><span class="instr">TPReadNum</span>...</li>
635
+ * Returns null if no UI instruction is currently active.
636
+ */
637
+ export function parseActiveUiInstruction(xml: string): UiInstruction | null {
638
+ const liPattern = /<li[^>]*class="[^"]*\brap-uiactive-li\b[^"]*"[^>]*>(.*?)<\/li>/is;
639
+ const liMatch = xml.match(liPattern);
640
+ if (!liMatch) return null;
641
+ const block = liMatch[1];
642
+
643
+ function get(cls: string): string {
644
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
645
+ return m ? decodeEntities(m[1].trim()) : '';
646
+ }
647
+
648
+ const instr = get('instr');
649
+ if (!instr) return null; // no active instruction
650
+
651
+ return {
652
+ instr,
653
+ event: get('event'),
654
+ stack: get('stack'),
655
+ execlv: get('execlv'),
656
+ msg: get('msg'),
657
+ };
658
+ }
659
+
660
+ /**
661
+ * Parse a RAPID symbol search response into RapidSymbolInfo[].
662
+ * XML: multiple <li class="rap-sympropvar-li"> elements.
663
+ */
664
+ export function parseRapidSymbolSearch(xml: string): RapidSymbolInfo[] {
665
+ const blocks = [...xml.matchAll(/<li[^>]*class="[^"]*\brap-sympropvar-li\b[^"]*"[^>]*>.*?<\/li>/gis)];
666
+ return blocks.map(([block]) => {
667
+ function get(cls: string): string {
668
+ const m = block.match(new RegExp(`<span[^>]*class="[^"]*\\b${cls}\\b[^"]*"[^>]*>(.*?)</span>`, 'is'));
669
+ return m ? decodeEntities(m[1].trim()) : '';
670
+ }
671
+ // Extract symburl from title attr or span
672
+ const titleMatch = block.match(/<li[^>]*title="([^"]*)"[^>]*>/i);
673
+ const symburl = titleMatch ? titleMatch[1] : get('symburl');
674
+ return {
675
+ symburl,
676
+ name: get('name'),
677
+ symtyp: get('symtyp'),
678
+ dattyp: get('dattyp'),
679
+ ndim: parseInt(get('ndim') || '0', 10),
680
+ local: get('local').toLowerCase() === 'true',
681
+ ro: get('rdonly').toLowerCase() === 'true' || get('ro').toLowerCase() === 'true',
682
+ taskvar: get('taskvar').toLowerCase() === 'true',
683
+ };
684
+ });
685
+ }
686
+
687
+ /**
688
+ * Extract the subscription ID from a Location header value returned by POST /subscription.
689
+ *
690
+ * The caller should pass the raw Location header string, e.g.:
691
+ * 'http://192.168.125.1/subscription/1'
692
+ * Returns '1'.
693
+ *
694
+ * If passed full XML instead (some firmware versions put the ID in the body),
695
+ * the function also tries to extract a numeric trailing path segment.
696
+ */
697
+ export function parseSubscriptionId(locationOrXml: string): string {
698
+ // IRC5 RWS 1.0 may return /subscription/{id} or /poll/{id}
699
+ const urlMatch = locationOrXml.match(/\/(?:subscription|poll)\/(\d+)/);
700
+ if (urlMatch) return urlMatch[1];
701
+
702
+ // Fallback: look for a self-link in the XML body
703
+ const hrefMatch = locationOrXml.match(/href="[^"]*\/(?:subscription|poll)\/(\d+)"/i);
704
+ if (hrefMatch) return hrefMatch[1];
705
+
706
+ throw new RwsError(
707
+ `PARSE_ERROR: cannot extract subscription ID from "${locationOrXml}"`,
708
+ 'PARSE_ERROR',
709
+ );
710
+ }