jmri-client 4.2.0-beta.2 → 5.1.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 (67) hide show
  1. package/README.md +3 -1
  2. package/dist/browser/jmri-client.js +88 -28
  3. package/dist/cjs/index.js +2442 -31
  4. package/dist/esm/index.js +2393 -17
  5. package/dist/types/client.d.ts +9 -1
  6. package/dist/types/index.d.ts +1 -1
  7. package/dist/types/managers/roster-manager.d.ts +9 -1
  8. package/dist/types/mocks/mock-data.d.ts +30 -6
  9. package/dist/types/mocks/mock-response-manager.d.ts +7 -2
  10. package/dist/types/types/jmri-messages.d.ts +22 -0
  11. package/docs/API.md +8 -0
  12. package/docs/BROWSER.md +4 -4
  13. package/docs/MIGRATION.md +30 -1
  14. package/docs/MOCK_MODE.md +15 -9
  15. package/package.json +17 -18
  16. package/dist/cjs/client.js +0 -366
  17. package/dist/cjs/core/connection-state-manager.js +0 -84
  18. package/dist/cjs/core/heartbeat-manager.js +0 -79
  19. package/dist/cjs/core/index.js +0 -25
  20. package/dist/cjs/core/message-queue.js +0 -59
  21. package/dist/cjs/core/reconnection-manager.js +0 -97
  22. package/dist/cjs/core/websocket-adapter.js +0 -135
  23. package/dist/cjs/core/websocket-client.js +0 -388
  24. package/dist/cjs/managers/index.js +0 -25
  25. package/dist/cjs/managers/light-manager.js +0 -111
  26. package/dist/cjs/managers/power-manager.js +0 -90
  27. package/dist/cjs/managers/roster-manager.js +0 -118
  28. package/dist/cjs/managers/system-connections-manager.js +0 -28
  29. package/dist/cjs/managers/throttle-manager.js +0 -233
  30. package/dist/cjs/managers/turnout-manager.js +0 -111
  31. package/dist/cjs/mocks/index.js +0 -12
  32. package/dist/cjs/mocks/mock-data.js +0 -237
  33. package/dist/cjs/mocks/mock-response-manager.js +0 -290
  34. package/dist/cjs/types/client-options.js +0 -66
  35. package/dist/cjs/types/events.js +0 -16
  36. package/dist/cjs/types/index.js +0 -23
  37. package/dist/cjs/types/jmri-messages.js +0 -95
  38. package/dist/cjs/types/throttle.js +0 -19
  39. package/dist/cjs/utils/exponential-backoff.js +0 -40
  40. package/dist/cjs/utils/index.js +0 -21
  41. package/dist/cjs/utils/message-id.js +0 -40
  42. package/dist/esm/client.js +0 -362
  43. package/dist/esm/core/connection-state-manager.js +0 -80
  44. package/dist/esm/core/heartbeat-manager.js +0 -75
  45. package/dist/esm/core/index.js +0 -9
  46. package/dist/esm/core/message-queue.js +0 -55
  47. package/dist/esm/core/reconnection-manager.js +0 -93
  48. package/dist/esm/core/websocket-adapter.js +0 -98
  49. package/dist/esm/core/websocket-client.js +0 -384
  50. package/dist/esm/managers/index.js +0 -9
  51. package/dist/esm/managers/light-manager.js +0 -107
  52. package/dist/esm/managers/power-manager.js +0 -86
  53. package/dist/esm/managers/roster-manager.js +0 -114
  54. package/dist/esm/managers/system-connections-manager.js +0 -24
  55. package/dist/esm/managers/throttle-manager.js +0 -229
  56. package/dist/esm/managers/turnout-manager.js +0 -107
  57. package/dist/esm/mocks/index.js +0 -6
  58. package/dist/esm/mocks/mock-data.js +0 -234
  59. package/dist/esm/mocks/mock-response-manager.js +0 -286
  60. package/dist/esm/types/client-options.js +0 -62
  61. package/dist/esm/types/events.js +0 -13
  62. package/dist/esm/types/index.js +0 -7
  63. package/dist/esm/types/jmri-messages.js +0 -89
  64. package/dist/esm/types/throttle.js +0 -15
  65. package/dist/esm/utils/exponential-backoff.js +0 -36
  66. package/dist/esm/utils/index.js +0 -5
  67. package/dist/esm/utils/message-id.js +0 -36
package/dist/esm/index.js CHANGED
@@ -1,17 +1,2393 @@
1
- /**
2
- * jmri-client v3.0
3
- * WebSocket client for JMRI with real-time updates and throttle control
4
- */
5
- export { JmriClient } from './client.js';
6
- export { WebSocketClient } from './core/websocket-client.js';
7
- // Export types
8
- export {
9
- // JMRI message types
10
- PowerState, TurnoutState, LightState,
11
- // Event types
12
- ConnectionState } from './types/index.js';
13
- // Export utility functions
14
- export { isThrottleFunctionKey, isValidSpeed } from './types/throttle.js';
15
- export { powerStateToString, turnoutStateToString, lightStateToString } from './types/jmri-messages.js';
16
- // Export mock system for testing and demo purposes
17
- export { MockResponseManager, mockResponseManager, mockData } from './mocks/index.js';
1
+ // src/client.ts
2
+ import { EventEmitter as EventEmitter10 } from "eventemitter3";
3
+
4
+ // src/core/websocket-client.ts
5
+ import { EventEmitter as EventEmitter5 } from "eventemitter3";
6
+
7
+ // src/core/websocket-adapter.ts
8
+ import { EventEmitter } from "eventemitter3";
9
+ async function createWebSocketAdapter(url, protocols) {
10
+ const isBrowser = typeof window !== "undefined" && typeof window.WebSocket !== "undefined";
11
+ if (isBrowser) {
12
+ return new BrowserWebSocketAdapter(url, protocols);
13
+ } else {
14
+ return await NodeWebSocketAdapter.create(url, protocols);
15
+ }
16
+ }
17
+ var BrowserWebSocketAdapter = class extends EventEmitter {
18
+ constructor(url, protocols) {
19
+ super();
20
+ this.ws = new WebSocket(url, protocols);
21
+ this.ws.onopen = () => {
22
+ this.emit("open");
23
+ };
24
+ this.ws.onmessage = (event) => {
25
+ this.emit("message", event.data);
26
+ };
27
+ this.ws.onerror = () => {
28
+ this.emit("error", new Error("WebSocket error"));
29
+ };
30
+ this.ws.onclose = (event) => {
31
+ this.emit("close", event.code, event.reason);
32
+ };
33
+ }
34
+ send(data) {
35
+ this.ws.send(data);
36
+ }
37
+ close(code, reason) {
38
+ this.ws.close(code, reason);
39
+ }
40
+ get readyState() {
41
+ return this.ws.readyState;
42
+ }
43
+ };
44
+ var NodeWebSocketAdapter = class _NodeWebSocketAdapter extends EventEmitter {
45
+ constructor(ws) {
46
+ super();
47
+ this.ws = ws;
48
+ this.ws.on("open", () => {
49
+ this.emit("open");
50
+ });
51
+ this.ws.on("message", (data) => {
52
+ const message = typeof data === "string" ? data : data.toString();
53
+ this.emit("message", message);
54
+ });
55
+ this.ws.on("error", (error) => {
56
+ this.emit("error", error);
57
+ });
58
+ this.ws.on("close", (code, reason) => {
59
+ this.emit("close", code, reason);
60
+ });
61
+ }
62
+ static async create(url, protocols) {
63
+ const { default: WebSocket2 } = await import("ws");
64
+ const ws = new WebSocket2(url, protocols);
65
+ return new _NodeWebSocketAdapter(ws);
66
+ }
67
+ send(data) {
68
+ this.ws.send(data);
69
+ }
70
+ close(code, reason) {
71
+ this.ws.close(code, reason);
72
+ }
73
+ get readyState() {
74
+ return this.ws.readyState;
75
+ }
76
+ };
77
+
78
+ // src/types/events.ts
79
+ var ConnectionState = /* @__PURE__ */ ((ConnectionState2) => {
80
+ ConnectionState2["DISCONNECTED"] = "disconnected";
81
+ ConnectionState2["CONNECTING"] = "connecting";
82
+ ConnectionState2["CONNECTED"] = "connected";
83
+ ConnectionState2["RECONNECTING"] = "reconnecting";
84
+ return ConnectionState2;
85
+ })(ConnectionState || {});
86
+
87
+ // src/utils/message-id.ts
88
+ var MessageIdGenerator = class {
89
+ constructor() {
90
+ this.currentId = 0;
91
+ this.maxId = Number.MAX_SAFE_INTEGER;
92
+ }
93
+ /**
94
+ * Generate next sequential ID
95
+ * Wraps around at MAX_SAFE_INTEGER
96
+ */
97
+ next() {
98
+ this.currentId++;
99
+ if (this.currentId >= this.maxId) {
100
+ this.currentId = 1;
101
+ }
102
+ return this.currentId;
103
+ }
104
+ /**
105
+ * Reset ID counter to 0
106
+ */
107
+ reset() {
108
+ this.currentId = 0;
109
+ }
110
+ /**
111
+ * Get current ID without incrementing
112
+ */
113
+ current() {
114
+ return this.currentId;
115
+ }
116
+ };
117
+
118
+ // src/core/message-queue.ts
119
+ var MessageQueue = class {
120
+ constructor(maxSize = 100) {
121
+ this.queue = [];
122
+ this.maxSize = maxSize;
123
+ }
124
+ /**
125
+ * Add message to queue
126
+ * If queue is full, oldest message is removed
127
+ */
128
+ enqueue(message) {
129
+ if (this.queue.length >= this.maxSize) {
130
+ this.queue.shift();
131
+ }
132
+ this.queue.push(message);
133
+ }
134
+ /**
135
+ * Get all queued messages and clear queue
136
+ */
137
+ flush() {
138
+ const messages = [...this.queue];
139
+ this.queue = [];
140
+ return messages;
141
+ }
142
+ /**
143
+ * Clear all queued messages without returning them
144
+ */
145
+ clear() {
146
+ this.queue = [];
147
+ }
148
+ /**
149
+ * Get number of queued messages
150
+ */
151
+ size() {
152
+ return this.queue.length;
153
+ }
154
+ /**
155
+ * Check if queue is empty
156
+ */
157
+ isEmpty() {
158
+ return this.queue.length === 0;
159
+ }
160
+ /**
161
+ * Check if queue is full
162
+ */
163
+ isFull() {
164
+ return this.queue.length >= this.maxSize;
165
+ }
166
+ };
167
+
168
+ // src/core/connection-state-manager.ts
169
+ import { EventEmitter as EventEmitter2 } from "eventemitter3";
170
+ var VALID_TRANSITIONS = {
171
+ ["disconnected" /* DISCONNECTED */]: ["connecting" /* CONNECTING */],
172
+ ["connecting" /* CONNECTING */]: ["connected" /* CONNECTED */, "disconnected" /* DISCONNECTED */],
173
+ ["connected" /* CONNECTED */]: ["disconnected" /* DISCONNECTED */, "reconnecting" /* RECONNECTING */],
174
+ ["reconnecting" /* RECONNECTING */]: ["connecting" /* CONNECTING */, "connected" /* CONNECTED */, "disconnected" /* DISCONNECTED */]
175
+ };
176
+ var ConnectionStateManager = class extends EventEmitter2 {
177
+ constructor() {
178
+ super(...arguments);
179
+ this.currentState = "disconnected" /* DISCONNECTED */;
180
+ }
181
+ /**
182
+ * Get current connection state
183
+ */
184
+ getState() {
185
+ return this.currentState;
186
+ }
187
+ /**
188
+ * Check if currently connected
189
+ */
190
+ isConnected() {
191
+ return this.currentState === "connected" /* CONNECTED */;
192
+ }
193
+ /**
194
+ * Check if currently connecting
195
+ */
196
+ isConnecting() {
197
+ return this.currentState === "connecting" /* CONNECTING */;
198
+ }
199
+ /**
200
+ * Check if currently disconnected
201
+ */
202
+ isDisconnected() {
203
+ return this.currentState === "disconnected" /* DISCONNECTED */;
204
+ }
205
+ /**
206
+ * Check if currently reconnecting
207
+ */
208
+ isReconnecting() {
209
+ return this.currentState === "reconnecting" /* RECONNECTING */;
210
+ }
211
+ /**
212
+ * Transition to new state
213
+ * Validates transition and emits event
214
+ */
215
+ transition(newState) {
216
+ const validTransitions = VALID_TRANSITIONS[this.currentState];
217
+ if (!validTransitions.includes(newState)) {
218
+ throw new Error(
219
+ `Invalid state transition: ${this.currentState} -> ${newState}`
220
+ );
221
+ }
222
+ const previousState = this.currentState;
223
+ this.currentState = newState;
224
+ this.emit("stateChanged", newState, previousState);
225
+ }
226
+ /**
227
+ * Force state without validation (use with caution)
228
+ */
229
+ forceState(newState) {
230
+ const previousState = this.currentState;
231
+ this.currentState = newState;
232
+ this.emit("stateChanged", newState, previousState);
233
+ }
234
+ /**
235
+ * Reset to disconnected state
236
+ */
237
+ reset() {
238
+ this.forceState("disconnected" /* DISCONNECTED */);
239
+ }
240
+ };
241
+
242
+ // src/core/heartbeat-manager.ts
243
+ import { EventEmitter as EventEmitter3 } from "eventemitter3";
244
+ var HeartbeatManager = class extends EventEmitter3 {
245
+ constructor(options) {
246
+ super();
247
+ this.isRunning = false;
248
+ this.options = options;
249
+ }
250
+ /**
251
+ * Start heartbeat monitoring
252
+ * @param sendPing - Callback to send ping message
253
+ */
254
+ start(sendPing) {
255
+ if (!this.options.enabled || this.isRunning) {
256
+ return;
257
+ }
258
+ this.isRunning = true;
259
+ this.pingInterval = setInterval(() => {
260
+ sendPing();
261
+ this.emit("pingSent");
262
+ this.pongTimeout = setTimeout(() => {
263
+ this.emit("timeout");
264
+ }, this.options.timeout);
265
+ }, this.options.interval);
266
+ }
267
+ /**
268
+ * Stop heartbeat monitoring
269
+ */
270
+ stop() {
271
+ this.isRunning = false;
272
+ if (this.pingInterval) {
273
+ clearInterval(this.pingInterval);
274
+ this.pingInterval = void 0;
275
+ }
276
+ if (this.pongTimeout) {
277
+ clearTimeout(this.pongTimeout);
278
+ this.pongTimeout = void 0;
279
+ }
280
+ }
281
+ /**
282
+ * Handle pong received from server
283
+ */
284
+ receivedPong() {
285
+ if (this.pongTimeout) {
286
+ clearTimeout(this.pongTimeout);
287
+ this.pongTimeout = void 0;
288
+ }
289
+ this.emit("pongReceived");
290
+ }
291
+ /**
292
+ * Check if heartbeat is running
293
+ */
294
+ running() {
295
+ return this.isRunning;
296
+ }
297
+ /**
298
+ * Update heartbeat options
299
+ */
300
+ updateOptions(options) {
301
+ const wasRunning = this.isRunning;
302
+ if (wasRunning) {
303
+ this.stop();
304
+ }
305
+ this.options = { ...this.options, ...options };
306
+ }
307
+ };
308
+
309
+ // src/core/reconnection-manager.ts
310
+ import { EventEmitter as EventEmitter4 } from "eventemitter3";
311
+
312
+ // src/utils/exponential-backoff.ts
313
+ function calculateBackoffDelay(attempt, options) {
314
+ const exponentialDelay = options.initialDelay * Math.pow(options.multiplier, attempt - 1);
315
+ const cappedDelay = Math.min(exponentialDelay, options.maxDelay);
316
+ if (options.jitter) {
317
+ const jitterAmount = cappedDelay * 0.25;
318
+ const jitter = (Math.random() * 2 - 1) * jitterAmount;
319
+ return Math.max(0, Math.round(cappedDelay + jitter));
320
+ }
321
+ return Math.round(cappedDelay);
322
+ }
323
+ function shouldReconnect(attempt, maxAttempts) {
324
+ if (maxAttempts === 0) {
325
+ return true;
326
+ }
327
+ return attempt <= maxAttempts;
328
+ }
329
+
330
+ // src/core/reconnection-manager.ts
331
+ var ReconnectionManager = class extends EventEmitter4 {
332
+ constructor(options) {
333
+ super();
334
+ this.currentAttempt = 0;
335
+ this.isReconnecting = false;
336
+ this.options = options;
337
+ }
338
+ /**
339
+ * Start reconnection process
340
+ * @param reconnect - Callback to attempt reconnection
341
+ */
342
+ start(reconnect) {
343
+ if (!this.options.enabled || this.isReconnecting) {
344
+ return;
345
+ }
346
+ this.isReconnecting = true;
347
+ this.currentAttempt = 0;
348
+ this.scheduleNextAttempt(reconnect);
349
+ }
350
+ /**
351
+ * Schedule next reconnection attempt
352
+ */
353
+ scheduleNextAttempt(reconnect) {
354
+ this.currentAttempt++;
355
+ if (!shouldReconnect(this.currentAttempt, this.options.maxAttempts)) {
356
+ this.emit("maxAttemptsReached", this.currentAttempt - 1);
357
+ this.stop();
358
+ return;
359
+ }
360
+ const delay = calculateBackoffDelay(this.currentAttempt, this.options);
361
+ this.emit("attemptScheduled", this.currentAttempt, delay);
362
+ this.reconnectTimeout = setTimeout(async () => {
363
+ this.emit("attempting", this.currentAttempt);
364
+ try {
365
+ await reconnect();
366
+ this.stop();
367
+ this.emit("success", this.currentAttempt);
368
+ } catch (error) {
369
+ this.emit("failed", this.currentAttempt, error);
370
+ this.scheduleNextAttempt(reconnect);
371
+ }
372
+ }, delay);
373
+ }
374
+ /**
375
+ * Stop reconnection process
376
+ */
377
+ stop() {
378
+ this.isReconnecting = false;
379
+ if (this.reconnectTimeout) {
380
+ clearTimeout(this.reconnectTimeout);
381
+ this.reconnectTimeout = void 0;
382
+ }
383
+ }
384
+ /**
385
+ * Reset attempt counter
386
+ */
387
+ reset() {
388
+ this.stop();
389
+ this.currentAttempt = 0;
390
+ }
391
+ /**
392
+ * Check if currently reconnecting
393
+ */
394
+ reconnecting() {
395
+ return this.isReconnecting;
396
+ }
397
+ /**
398
+ * Get current attempt number
399
+ */
400
+ getAttempt() {
401
+ return this.currentAttempt;
402
+ }
403
+ /**
404
+ * Update reconnection options
405
+ */
406
+ updateOptions(options) {
407
+ this.options = { ...this.options, ...options };
408
+ }
409
+ };
410
+
411
+ // src/types/jmri-messages.ts
412
+ var PowerState = /* @__PURE__ */ ((PowerState2) => {
413
+ PowerState2[PowerState2["UNKNOWN"] = 0] = "UNKNOWN";
414
+ PowerState2[PowerState2["ON"] = 2] = "ON";
415
+ PowerState2[PowerState2["OFF"] = 4] = "OFF";
416
+ return PowerState2;
417
+ })(PowerState || {});
418
+ function powerStateToString(state) {
419
+ switch (state) {
420
+ case 2 /* ON */:
421
+ return "ON";
422
+ case 4 /* OFF */:
423
+ return "OFF";
424
+ case 0 /* UNKNOWN */:
425
+ return "UNKNOWN";
426
+ default:
427
+ return "UNKNOWN";
428
+ }
429
+ }
430
+ var TurnoutState = /* @__PURE__ */ ((TurnoutState2) => {
431
+ TurnoutState2[TurnoutState2["UNKNOWN"] = 0] = "UNKNOWN";
432
+ TurnoutState2[TurnoutState2["CLOSED"] = 2] = "CLOSED";
433
+ TurnoutState2[TurnoutState2["THROWN"] = 4] = "THROWN";
434
+ TurnoutState2[TurnoutState2["INCONSISTENT"] = 8] = "INCONSISTENT";
435
+ return TurnoutState2;
436
+ })(TurnoutState || {});
437
+ function turnoutStateToString(state) {
438
+ switch (state) {
439
+ case 2 /* CLOSED */:
440
+ return "CLOSED";
441
+ case 4 /* THROWN */:
442
+ return "THROWN";
443
+ case 8 /* INCONSISTENT */:
444
+ return "INCONSISTENT";
445
+ case 0 /* UNKNOWN */:
446
+ default:
447
+ return "UNKNOWN";
448
+ }
449
+ }
450
+ var LightState = /* @__PURE__ */ ((LightState2) => {
451
+ LightState2[LightState2["UNKNOWN"] = 0] = "UNKNOWN";
452
+ LightState2[LightState2["ON"] = 2] = "ON";
453
+ LightState2[LightState2["OFF"] = 4] = "OFF";
454
+ return LightState2;
455
+ })(LightState || {});
456
+ function lightStateToString(state) {
457
+ switch (state) {
458
+ case 2 /* ON */:
459
+ return "ON";
460
+ case 4 /* OFF */:
461
+ return "OFF";
462
+ case 0 /* UNKNOWN */:
463
+ default:
464
+ return "UNKNOWN";
465
+ }
466
+ }
467
+
468
+ // src/mocks/mock-data.ts
469
+ var mockData = {
470
+ "hello": {
471
+ "type": "hello",
472
+ "data": {
473
+ "JMRI": "5.9.2",
474
+ "json": "5.0",
475
+ "version": "v5",
476
+ "heartbeat": 13500,
477
+ "railroad": "Demo Railroad",
478
+ "node": "jmri-server",
479
+ "activeProfile": "Demo Profile"
480
+ }
481
+ },
482
+ "power": {
483
+ "get": {
484
+ "on": {
485
+ "type": "power",
486
+ "data": {
487
+ "state": 2
488
+ }
489
+ },
490
+ "off": {
491
+ "type": "power",
492
+ "data": {
493
+ "state": 4
494
+ }
495
+ }
496
+ },
497
+ "post": {
498
+ "success": {
499
+ "type": "power",
500
+ "data": {
501
+ "state": 2
502
+ }
503
+ }
504
+ }
505
+ },
506
+ "roster": {
507
+ "list": [
508
+ {
509
+ "type": "rosterEntry",
510
+ "data": {
511
+ "name": "CSX754",
512
+ "address": "754",
513
+ "isLongAddress": true,
514
+ "road": "CSX",
515
+ "number": "754",
516
+ "mfg": "Athearn",
517
+ "decoderModel": "DH163D",
518
+ "decoderFamily": "Digitrax DH163",
519
+ "model": "GP38-2",
520
+ "comment": "Blue and yellow scheme",
521
+ "maxSpeedPct": 100,
522
+ "image": null,
523
+ "icon": "/roster/CSX754/icon",
524
+ "shuntingFunction": "",
525
+ "owner": "",
526
+ "dateModified": "2026-02-10T00:00:00.000+00:00",
527
+ "functionKeys": [
528
+ { "name": "F0", "label": "Headlight", "lockable": true, "icon": null, "selectedIcon": null },
529
+ { "name": "F1", "label": "Bell", "lockable": true, "icon": null, "selectedIcon": null },
530
+ { "name": "F2", "label": "Horn", "lockable": false, "icon": null, "selectedIcon": null },
531
+ { "name": "F3", "label": null, "lockable": false, "icon": null, "selectedIcon": null },
532
+ { "name": "F4", "label": "Dynamic Brake", "lockable": true, "icon": null, "selectedIcon": null },
533
+ { "name": "F5", "label": null, "lockable": false, "icon": null, "selectedIcon": null }
534
+ ],
535
+ "attributes": [{ "name": "RosterGroup:diesels", "value": "yes" }],
536
+ "rosterGroups": ["diesels"]
537
+ },
538
+ "id": 1
539
+ },
540
+ {
541
+ "type": "rosterEntry",
542
+ "data": {
543
+ "name": "UP3985",
544
+ "address": "3985",
545
+ "isLongAddress": true,
546
+ "road": "Union Pacific",
547
+ "number": "3985",
548
+ "mfg": "Rivarossi",
549
+ "decoderModel": "Sound decoder",
550
+ "decoderFamily": "ESU LokSound",
551
+ "model": "Challenger 4-6-6-4",
552
+ "comment": "Steam locomotive",
553
+ "maxSpeedPct": 100,
554
+ "image": null,
555
+ "icon": "/roster/UP3985/icon",
556
+ "shuntingFunction": "",
557
+ "owner": "",
558
+ "dateModified": "2026-02-10T00:00:00.000+00:00",
559
+ "functionKeys": [
560
+ { "name": "F0", "label": "Headlight", "lockable": true, "icon": null, "selectedIcon": null },
561
+ { "name": "F1", "label": "Bell", "lockable": true, "icon": null, "selectedIcon": null },
562
+ { "name": "F2", "label": "Whistle", "lockable": false, "icon": null, "selectedIcon": null },
563
+ { "name": "F3", "label": "Steam", "lockable": true, "icon": null, "selectedIcon": null },
564
+ { "name": "F4", "label": null, "lockable": false, "icon": null, "selectedIcon": null }
565
+ ],
566
+ "attributes": [{ "name": "RosterGroup:steam", "value": "yes" }],
567
+ "rosterGroups": ["steam"]
568
+ },
569
+ "id": 2
570
+ },
571
+ {
572
+ "type": "rosterEntry",
573
+ "data": {
574
+ "name": "BNSF5240",
575
+ "address": "5240",
576
+ "isLongAddress": true,
577
+ "road": "BNSF",
578
+ "number": "5240",
579
+ "mfg": "Kato",
580
+ "decoderModel": "DCC Sound",
581
+ "decoderFamily": "Kato",
582
+ "model": "SD40-2",
583
+ "comment": "Heritage II paint",
584
+ "maxSpeedPct": 100,
585
+ "image": null,
586
+ "icon": "/roster/BNSF5240/icon",
587
+ "shuntingFunction": "",
588
+ "owner": "",
589
+ "dateModified": "2026-02-10T00:00:00.000+00:00",
590
+ "functionKeys": [
591
+ { "name": "F0", "label": "Headlight", "lockable": true, "icon": null, "selectedIcon": null },
592
+ { "name": "F1", "label": "Bell", "lockable": true, "icon": null, "selectedIcon": null },
593
+ { "name": "F2", "label": "Horn", "lockable": false, "icon": null, "selectedIcon": null },
594
+ { "name": "F3", "label": "Dynamic Brake", "lockable": true, "icon": null, "selectedIcon": null },
595
+ { "name": "F4", "label": null, "lockable": false, "icon": null, "selectedIcon": null },
596
+ { "name": "F5", "label": "Mars Light", "lockable": true, "icon": null, "selectedIcon": null }
597
+ ],
598
+ "attributes": [{ "name": "RosterGroup:diesels", "value": "yes" }],
599
+ "rosterGroups": ["diesels"]
600
+ },
601
+ "id": 3
602
+ }
603
+ ]
604
+ },
605
+ "rosterGroup": {
606
+ "list": [
607
+ { "type": "rosterGroup", "data": { "name": "diesels", "length": 2 } },
608
+ { "type": "rosterGroup", "data": { "name": "steam", "length": 1 } }
609
+ ]
610
+ },
611
+ "throttle": {
612
+ "acquire": {
613
+ "success": {
614
+ "type": "throttle",
615
+ "data": {
616
+ "throttle": "{THROTTLE_ID}",
617
+ "address": "{ADDRESS}",
618
+ "speed": 0,
619
+ "forward": true,
620
+ "F0": false,
621
+ "F1": false,
622
+ "F2": false,
623
+ "F3": false,
624
+ "F4": false
625
+ }
626
+ }
627
+ },
628
+ "release": {
629
+ "success": {
630
+ "type": "throttle",
631
+ "data": {}
632
+ }
633
+ },
634
+ "control": {
635
+ "speed": {
636
+ "type": "throttle",
637
+ "data": {
638
+ "throttle": "{THROTTLE_ID}",
639
+ "speed": "{SPEED}"
640
+ }
641
+ },
642
+ "direction": {
643
+ "type": "throttle",
644
+ "data": {
645
+ "throttle": "{THROTTLE_ID}",
646
+ "forward": "{FORWARD}"
647
+ }
648
+ },
649
+ "function": {
650
+ "type": "throttle",
651
+ "data": {
652
+ "throttle": "{THROTTLE_ID}",
653
+ "{FUNCTION}": "{VALUE}"
654
+ }
655
+ }
656
+ }
657
+ },
658
+ "light": {
659
+ "list": [
660
+ { "type": "light", "data": { "name": "IL1", "userName": "Yard Light", "comment": null, "properties": [], "state": 4 } },
661
+ { "type": "light", "data": { "name": "IL2", "userName": "Platform Light", "comment": null, "properties": [], "state": 4 } },
662
+ { "type": "light", "data": { "name": "IL3", "userName": "Signal Lamp", "comment": null, "properties": [], "state": 2 } }
663
+ ]
664
+ },
665
+ "turnout": {
666
+ "list": [
667
+ { "type": "turnout", "data": { "name": "LT1", "userName": "Main Diverge", "state": 2 } },
668
+ { "type": "turnout", "data": { "name": "LT2", "userName": "Yard Lead", "state": 2 } },
669
+ { "type": "turnout", "data": { "name": "LT3", "userName": "Siding Entry", "state": 4 } }
670
+ ]
671
+ },
672
+ "ping": {
673
+ "type": "ping"
674
+ },
675
+ "pong": {
676
+ "type": "pong"
677
+ },
678
+ "goodbye": {
679
+ "type": "goodbye"
680
+ },
681
+ "error": {
682
+ "throttleNotFound": {
683
+ "type": "error",
684
+ "data": {
685
+ "code": 404,
686
+ "message": "Throttle not found"
687
+ }
688
+ },
689
+ "invalidSpeed": {
690
+ "type": "error",
691
+ "data": {
692
+ "code": 400,
693
+ "message": "Invalid speed value"
694
+ }
695
+ },
696
+ "connectionError": {
697
+ "type": "error",
698
+ "data": {
699
+ "code": 500,
700
+ "message": "Connection error"
701
+ }
702
+ }
703
+ }
704
+ };
705
+
706
+ // src/mocks/mock-response-manager.ts
707
+ var MockResponseManager = class {
708
+ constructor(options = {}) {
709
+ this.powerStateByPrefix = /* @__PURE__ */ new Map();
710
+ this.throttles = /* @__PURE__ */ new Map();
711
+ this.lights = /* @__PURE__ */ new Map([
712
+ ["IL1", 4 /* OFF */],
713
+ ["IL2", 4 /* OFF */],
714
+ ["IL3", 2 /* ON */]
715
+ ]);
716
+ this.turnouts = /* @__PURE__ */ new Map([
717
+ ["LT1", 2 /* CLOSED */],
718
+ ["LT2", 2 /* CLOSED */],
719
+ ["LT3", 4 /* THROWN */]
720
+ ]);
721
+ this.responseDelay = options.responseDelay ?? 50;
722
+ this.powerState = options.initialPowerState ?? 4 /* OFF */;
723
+ }
724
+ /**
725
+ * Get a mock response for a given message
726
+ */
727
+ async getMockResponse(message) {
728
+ if (this.responseDelay > 0) {
729
+ await this.delay(this.responseDelay);
730
+ }
731
+ switch (message.type) {
732
+ case "hello":
733
+ return this.getHelloResponse();
734
+ case "power":
735
+ return this.getPowerResponse(message);
736
+ case "roster":
737
+ return this.getRosterResponse(message);
738
+ case "rosterGroup":
739
+ return this.getRosterGroupResponse();
740
+ case "throttle":
741
+ return this.getThrottleResponse(message);
742
+ case "light":
743
+ return this.getLightResponse(message);
744
+ case "turnout":
745
+ return this.getTurnoutResponse(message);
746
+ case "ping":
747
+ return this.getPingResponse();
748
+ case "goodbye":
749
+ return this.getGoodbyeResponse();
750
+ default:
751
+ return null;
752
+ }
753
+ }
754
+ /**
755
+ * Get hello response (connection establishment)
756
+ */
757
+ getHelloResponse() {
758
+ return JSON.parse(JSON.stringify(mockData.hello));
759
+ }
760
+ /**
761
+ * Get power response, with optional per-prefix state tracking
762
+ */
763
+ getPowerResponse(message) {
764
+ const prefix = message.data?.prefix;
765
+ if (message.method === "post" && message.data?.state !== void 0) {
766
+ if (prefix !== void 0) {
767
+ this.powerStateByPrefix.set(prefix, message.data.state);
768
+ return { type: "power", data: { state: message.data.state, prefix } };
769
+ }
770
+ this.powerState = message.data.state;
771
+ return { type: "power", data: { state: this.powerState } };
772
+ }
773
+ if (prefix !== void 0) {
774
+ const state = this.powerStateByPrefix.get(prefix) ?? 0 /* UNKNOWN */;
775
+ return { type: "power", data: { state, prefix } };
776
+ }
777
+ return { type: "power", data: { state: this.powerState } };
778
+ }
779
+ /**
780
+ * Get roster response, optionally filtered by group
781
+ */
782
+ getRosterResponse(message) {
783
+ if (message.type === "roster" && message.method === "list") {
784
+ const all = JSON.parse(JSON.stringify(mockData.roster.list));
785
+ const group = message.params?.group;
786
+ if (group) {
787
+ return {
788
+ type: "roster",
789
+ data: all.filter((e) => e.data.rosterGroups?.includes(group))
790
+ };
791
+ }
792
+ return { type: "roster", data: all };
793
+ }
794
+ return { type: "roster", data: [] };
795
+ }
796
+ /**
797
+ * Get roster group response
798
+ */
799
+ getRosterGroupResponse() {
800
+ return {
801
+ type: "rosterGroup",
802
+ data: JSON.parse(JSON.stringify(mockData.rosterGroup.list))
803
+ };
804
+ }
805
+ /**
806
+ * Get throttle response
807
+ */
808
+ getThrottleResponse(message) {
809
+ const data = message.data || {};
810
+ if (data.address !== void 0 && !data.throttle) {
811
+ const throttleId = data.name || `MOCK-${data.address}`;
812
+ const throttleState = {
813
+ throttle: throttleId,
814
+ address: data.address,
815
+ speed: 0,
816
+ forward: true,
817
+ F0: false,
818
+ F1: false,
819
+ F2: false,
820
+ F3: false,
821
+ F4: false,
822
+ ...data.prefix !== void 0 && { prefix: data.prefix }
823
+ };
824
+ this.throttles.set(throttleId, throttleState);
825
+ return { type: "throttle", data: { ...throttleState } };
826
+ }
827
+ if (data.release !== void 0 && data.throttle) {
828
+ this.throttles.delete(data.throttle);
829
+ return {
830
+ type: "throttle",
831
+ data: {}
832
+ };
833
+ }
834
+ if (data.throttle) {
835
+ const throttleState = this.throttles.get(data.throttle);
836
+ if (!throttleState) {
837
+ const newState = {
838
+ throttle: data.throttle,
839
+ address: 0,
840
+ speed: 0,
841
+ forward: true
842
+ };
843
+ this.throttles.set(data.throttle, newState);
844
+ return {
845
+ type: "throttle",
846
+ data: { ...newState }
847
+ };
848
+ }
849
+ if (data.speed !== void 0) {
850
+ throttleState.speed = data.speed;
851
+ }
852
+ if (data.forward !== void 0) {
853
+ throttleState.forward = data.forward;
854
+ }
855
+ for (let i = 0; i <= 28; i++) {
856
+ const key = `F${i}`;
857
+ if (data[key] !== void 0) {
858
+ throttleState[key] = data[key];
859
+ }
860
+ }
861
+ return {
862
+ type: "throttle",
863
+ data: {}
864
+ };
865
+ }
866
+ return {
867
+ type: "throttle",
868
+ data: {}
869
+ };
870
+ }
871
+ /**
872
+ * Get light response
873
+ */
874
+ getLightResponse(message) {
875
+ if (message.method === "list") {
876
+ return {
877
+ type: "light",
878
+ data: JSON.parse(JSON.stringify(mockData.light.list))
879
+ };
880
+ }
881
+ const name = message.data?.name;
882
+ if (!name) {
883
+ return { type: "light", data: { name: "", state: 0 /* UNKNOWN */ } };
884
+ }
885
+ if (message.method === "post" && message.data?.state !== void 0) {
886
+ this.lights.set(name, message.data.state);
887
+ }
888
+ const state = this.lights.get(name) ?? 0 /* UNKNOWN */;
889
+ return { type: "light", data: { name, state } };
890
+ }
891
+ /**
892
+ * Get turnout response
893
+ */
894
+ getTurnoutResponse(message) {
895
+ if (message.method === "list") {
896
+ return {
897
+ type: "turnout",
898
+ data: JSON.parse(JSON.stringify(mockData.turnout.list))
899
+ };
900
+ }
901
+ const name = message.data?.name;
902
+ if (!name) {
903
+ return { type: "turnout", data: { name: "", state: 0 /* UNKNOWN */ } };
904
+ }
905
+ if (message.method === "post" && message.data?.state !== void 0) {
906
+ this.turnouts.set(name, message.data.state);
907
+ }
908
+ const state = this.turnouts.get(name) ?? 0 /* UNKNOWN */;
909
+ return { type: "turnout", data: { name, state } };
910
+ }
911
+ /**
912
+ * Get ping response (pong)
913
+ */
914
+ getPingResponse() {
915
+ return JSON.parse(JSON.stringify(mockData.pong));
916
+ }
917
+ /**
918
+ * Get goodbye response
919
+ */
920
+ getGoodbyeResponse() {
921
+ return JSON.parse(JSON.stringify(mockData.goodbye));
922
+ }
923
+ /**
924
+ * Get current power state
925
+ */
926
+ getPowerState() {
927
+ return this.powerState;
928
+ }
929
+ /**
930
+ * Set power state (for testing)
931
+ */
932
+ setPowerState(state) {
933
+ this.powerState = state;
934
+ }
935
+ /**
936
+ * Get all throttles (for testing)
937
+ */
938
+ getThrottles() {
939
+ return this.throttles;
940
+ }
941
+ /**
942
+ * Get all light states (for testing)
943
+ */
944
+ getLights() {
945
+ return this.lights;
946
+ }
947
+ /**
948
+ * Get all turnout states (for testing)
949
+ */
950
+ getTurnouts() {
951
+ return this.turnouts;
952
+ }
953
+ /**
954
+ * Reset all state (for testing)
955
+ */
956
+ reset() {
957
+ this.powerState = 4 /* OFF */;
958
+ this.powerStateByPrefix.clear();
959
+ this.throttles.clear();
960
+ this.lights = /* @__PURE__ */ new Map([
961
+ ["IL1", 4 /* OFF */],
962
+ ["IL2", 4 /* OFF */],
963
+ ["IL3", 2 /* ON */]
964
+ ]);
965
+ this.turnouts = /* @__PURE__ */ new Map([
966
+ ["LT1", 2 /* CLOSED */],
967
+ ["LT2", 2 /* CLOSED */],
968
+ ["LT3", 4 /* THROWN */]
969
+ ]);
970
+ }
971
+ /**
972
+ * Delay helper
973
+ */
974
+ delay(ms) {
975
+ return new Promise((resolve) => setTimeout(resolve, ms));
976
+ }
977
+ };
978
+ var mockResponseManager = new MockResponseManager({ responseDelay: 0 });
979
+
980
+ // src/core/websocket-client.ts
981
+ var WebSocketClient = class extends EventEmitter5 {
982
+ constructor(options) {
983
+ super();
984
+ // Request/response tracking
985
+ this.pendingRequests = /* @__PURE__ */ new Map();
986
+ // Connection state
987
+ this.isManualDisconnect = false;
988
+ this.options = options;
989
+ this.url = `${options.protocol}://${options.host}:${options.port}/json/`;
990
+ this.messageIdGen = new MessageIdGenerator();
991
+ this.messageQueue = new MessageQueue(options.messageQueueSize);
992
+ this.stateManager = new ConnectionStateManager();
993
+ this.heartbeatManager = new HeartbeatManager(options.heartbeat);
994
+ this.reconnectionManager = new ReconnectionManager(options.reconnection);
995
+ if (options.mock.enabled) {
996
+ this.mockManager = new MockResponseManager({
997
+ responseDelay: options.mock.responseDelay
998
+ });
999
+ }
1000
+ this.stateManager.on("stateChanged", (newState, prevState) => {
1001
+ this.emit("connectionStateChanged", newState, prevState);
1002
+ });
1003
+ this.heartbeatManager.on("timeout", () => {
1004
+ this.emit("heartbeat:timeout");
1005
+ this.handleHeartbeatTimeout();
1006
+ });
1007
+ this.heartbeatManager.on("pingSent", () => {
1008
+ this.emit("heartbeat:sent");
1009
+ });
1010
+ this.reconnectionManager.on("attemptScheduled", (attempt, delay) => {
1011
+ this.emit("reconnecting", attempt, delay);
1012
+ });
1013
+ this.reconnectionManager.on("success", () => {
1014
+ this.emit("reconnected");
1015
+ });
1016
+ this.reconnectionManager.on("maxAttemptsReached", (attempts) => {
1017
+ this.emit("reconnectionFailed", attempts);
1018
+ });
1019
+ this.reconnectionManager.on("debug", (message) => {
1020
+ this.emit("debug", message);
1021
+ });
1022
+ }
1023
+ /**
1024
+ * Connect to JMRI WebSocket server (or mock)
1025
+ */
1026
+ async connect() {
1027
+ if (this.stateManager.isConnected() || this.stateManager.isConnecting()) {
1028
+ return;
1029
+ }
1030
+ this.isManualDisconnect = false;
1031
+ this.stateManager.transition("connecting" /* CONNECTING */);
1032
+ if (this.mockManager) {
1033
+ return this.connectMock();
1034
+ }
1035
+ return new Promise(async (resolve, reject) => {
1036
+ try {
1037
+ this.ws = await createWebSocketAdapter(this.url);
1038
+ this.ws.on("open", () => {
1039
+ this.handleOpen();
1040
+ resolve();
1041
+ });
1042
+ this.ws.on("message", (data) => {
1043
+ this.handleMessage(data);
1044
+ });
1045
+ this.ws.on("close", (code, reason) => {
1046
+ if (this.stateManager.isConnecting()) {
1047
+ this.stateManager.transition("disconnected" /* DISCONNECTED */);
1048
+ const error = new Error(`WebSocket connection failed (code: ${code}${reason ? ", reason: " + reason : ""})`);
1049
+ this.emit("error", error);
1050
+ reject(error);
1051
+ this.handleClose(code, reason);
1052
+ } else {
1053
+ this.handleClose(code, reason);
1054
+ }
1055
+ });
1056
+ this.ws.on("error", (error) => {
1057
+ this.emit("error", error);
1058
+ if (this.stateManager.isConnecting()) {
1059
+ this.stateManager.transition("disconnected" /* DISCONNECTED */);
1060
+ reject(error);
1061
+ }
1062
+ });
1063
+ } catch (error) {
1064
+ this.stateManager.transition("disconnected" /* DISCONNECTED */);
1065
+ reject(error);
1066
+ }
1067
+ });
1068
+ }
1069
+ /**
1070
+ * Simulate connection in mock mode
1071
+ */
1072
+ async connectMock() {
1073
+ await this.delay(10);
1074
+ this.handleOpen();
1075
+ const helloResponse = await this.mockManager.getMockResponse({ type: "hello" });
1076
+ if (helloResponse) {
1077
+ this.processMessage(helloResponse);
1078
+ }
1079
+ }
1080
+ /**
1081
+ * Disconnect from JMRI WebSocket server
1082
+ */
1083
+ async disconnect() {
1084
+ if (this.stateManager.isDisconnected()) {
1085
+ return;
1086
+ }
1087
+ this.isManualDisconnect = true;
1088
+ this.reconnectionManager.stop();
1089
+ this.heartbeatManager.stop();
1090
+ if (this.stateManager.isConnected()) {
1091
+ try {
1092
+ await this.sendGoodbye();
1093
+ } catch (error) {
1094
+ }
1095
+ }
1096
+ if (this.ws) {
1097
+ this.ws.close();
1098
+ this.ws = void 0;
1099
+ }
1100
+ this.rejectAllPendingRequests(new Error("Client disconnected"));
1101
+ if (!this.stateManager.isDisconnected()) {
1102
+ this.stateManager.transition("disconnected" /* DISCONNECTED */);
1103
+ }
1104
+ }
1105
+ /**
1106
+ * Send message to JMRI (or mock)
1107
+ */
1108
+ send(message) {
1109
+ if (!this.stateManager.isConnected()) {
1110
+ this.messageQueue.enqueue(message);
1111
+ return;
1112
+ }
1113
+ if (this.mockManager) {
1114
+ this.emit("message:sent", message);
1115
+ return;
1116
+ }
1117
+ if (!this.ws) {
1118
+ throw new Error("WebSocket not initialized");
1119
+ }
1120
+ const json = JSON.stringify(message);
1121
+ this.ws.send(json);
1122
+ this.emit("message:sent", message);
1123
+ }
1124
+ /**
1125
+ * Send request and wait for response (or get mock response)
1126
+ */
1127
+ async request(message, timeout) {
1128
+ if (this.mockManager) {
1129
+ const response = await this.mockManager.getMockResponse(message);
1130
+ this.emit("message:sent", message);
1131
+ if (response) {
1132
+ this.processMessage(response);
1133
+ }
1134
+ return response;
1135
+ }
1136
+ const id = this.messageIdGen.next();
1137
+ message.id = id;
1138
+ return new Promise((resolve, reject) => {
1139
+ const timeoutMs = timeout || this.options.requestTimeout;
1140
+ const timeoutHandle = setTimeout(() => {
1141
+ this.pendingRequests.delete(id);
1142
+ reject(new Error(`Request timeout after ${timeoutMs}ms`));
1143
+ }, timeoutMs);
1144
+ const pendingRequest = {
1145
+ resolve,
1146
+ reject,
1147
+ timeout: timeoutHandle,
1148
+ messageType: message.type
1149
+ };
1150
+ if (message.type === "throttle" && message.data && "name" in message.data) {
1151
+ pendingRequest.matchKey = message.data.name;
1152
+ }
1153
+ this.pendingRequests.set(id, pendingRequest);
1154
+ try {
1155
+ this.send(message);
1156
+ } catch (error) {
1157
+ this.pendingRequests.delete(id);
1158
+ clearTimeout(timeoutHandle);
1159
+ reject(error);
1160
+ }
1161
+ });
1162
+ }
1163
+ /**
1164
+ * Get current connection state
1165
+ */
1166
+ getState() {
1167
+ return this.stateManager.getState();
1168
+ }
1169
+ /**
1170
+ * Check if connected
1171
+ */
1172
+ isConnected() {
1173
+ return this.stateManager.isConnected();
1174
+ }
1175
+ /**
1176
+ * Handle WebSocket open event
1177
+ */
1178
+ handleOpen() {
1179
+ this.stateManager.transition("connected" /* CONNECTED */);
1180
+ this.emit("connected");
1181
+ if (this.options.heartbeat.enabled) {
1182
+ this.heartbeatManager.start(() => this.sendPing());
1183
+ }
1184
+ const queuedMessages = this.messageQueue.flush();
1185
+ for (const message of queuedMessages) {
1186
+ this.send(message);
1187
+ }
1188
+ }
1189
+ /**
1190
+ * Handle WebSocket message event
1191
+ */
1192
+ handleMessage(data) {
1193
+ try {
1194
+ const message = JSON.parse(data);
1195
+ this.processMessage(message);
1196
+ } catch (error) {
1197
+ this.emit("error", new Error(`Failed to parse message: ${error}`));
1198
+ }
1199
+ }
1200
+ /**
1201
+ * Process a parsed message (called by both real and mock mode)
1202
+ */
1203
+ processMessage(message) {
1204
+ this.emit("message:received", message);
1205
+ if (message.type === "pong") {
1206
+ this.heartbeatManager.receivedPong();
1207
+ return;
1208
+ }
1209
+ if (message.type === "hello") {
1210
+ this.emit("hello", message.data);
1211
+ return;
1212
+ }
1213
+ if (message.id !== void 0) {
1214
+ const pending = this.pendingRequests.get(message.id);
1215
+ if (pending) {
1216
+ clearTimeout(pending.timeout);
1217
+ this.pendingRequests.delete(message.id);
1218
+ pending.resolve(message);
1219
+ return;
1220
+ }
1221
+ }
1222
+ if (message.id === void 0) {
1223
+ for (const [id, pending] of this.pendingRequests.entries()) {
1224
+ if (pending.messageType === message.type) {
1225
+ if (message.type === "throttle" && pending.matchKey) {
1226
+ const throttleName = message.data?.throttle || message.data?.name;
1227
+ if (throttleName === pending.matchKey) {
1228
+ clearTimeout(pending.timeout);
1229
+ this.pendingRequests.delete(id);
1230
+ pending.resolve(message);
1231
+ return;
1232
+ }
1233
+ } else {
1234
+ clearTimeout(pending.timeout);
1235
+ this.pendingRequests.delete(id);
1236
+ pending.resolve(message);
1237
+ return;
1238
+ }
1239
+ }
1240
+ }
1241
+ }
1242
+ this.emit("update", message);
1243
+ }
1244
+ /**
1245
+ * Handle WebSocket close event
1246
+ */
1247
+ handleClose(code, reason) {
1248
+ this.heartbeatManager.stop();
1249
+ const wasConnected = this.stateManager.isConnected();
1250
+ const isReconnecting = this.reconnectionManager.reconnecting();
1251
+ if (this.stateManager.isConnected() || this.stateManager.isConnecting()) {
1252
+ this.stateManager.transition("disconnected" /* DISCONNECTED */);
1253
+ }
1254
+ this.emit("disconnected", reason || `Connection closed (code: ${code})`);
1255
+ this.rejectAllPendingRequests(new Error("Connection closed"));
1256
+ if (!this.isManualDisconnect && (wasConnected || isReconnecting) && this.options.reconnection.enabled) {
1257
+ this.stateManager.forceState("reconnecting" /* RECONNECTING */);
1258
+ this.reconnectionManager.start(() => this.connect());
1259
+ }
1260
+ }
1261
+ /**
1262
+ * Handle heartbeat timeout
1263
+ */
1264
+ handleHeartbeatTimeout() {
1265
+ if (this.ws) {
1266
+ this.ws.close();
1267
+ }
1268
+ }
1269
+ /**
1270
+ * Send ping message
1271
+ */
1272
+ sendPing() {
1273
+ this.send({ type: "ping" });
1274
+ }
1275
+ /**
1276
+ * Send goodbye message
1277
+ */
1278
+ async sendGoodbye() {
1279
+ const message = { type: "goodbye" };
1280
+ this.send(message);
1281
+ await new Promise((resolve) => setTimeout(resolve, 100));
1282
+ }
1283
+ /**
1284
+ * Reject all pending requests
1285
+ */
1286
+ rejectAllPendingRequests(error) {
1287
+ for (const pending of this.pendingRequests.values()) {
1288
+ clearTimeout(pending.timeout);
1289
+ pending.reject(error);
1290
+ }
1291
+ this.pendingRequests.clear();
1292
+ }
1293
+ /**
1294
+ * Delay helper
1295
+ */
1296
+ delay(ms) {
1297
+ return new Promise((resolve) => setTimeout(resolve, ms));
1298
+ }
1299
+ };
1300
+
1301
+ // src/managers/power-manager.ts
1302
+ import { EventEmitter as EventEmitter6 } from "eventemitter3";
1303
+ var PowerManager = class extends EventEmitter6 {
1304
+ constructor(client) {
1305
+ super();
1306
+ this.currentState = 0 /* UNKNOWN */;
1307
+ this.client = client;
1308
+ this.client.on("update", (message) => {
1309
+ if (message.type === "power") {
1310
+ this.handlePowerUpdate(message);
1311
+ }
1312
+ });
1313
+ }
1314
+ /**
1315
+ * Get current track power state
1316
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
1317
+ */
1318
+ async getPower(prefix) {
1319
+ const message = {
1320
+ type: "power",
1321
+ ...prefix !== void 0 && { data: { state: 0 /* UNKNOWN */, prefix } }
1322
+ };
1323
+ const response = await this.client.request(message);
1324
+ if (response.data?.state !== void 0) {
1325
+ this.currentState = response.data.state;
1326
+ }
1327
+ return this.currentState;
1328
+ }
1329
+ /**
1330
+ * Set track power state
1331
+ * @param state - The desired power state
1332
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
1333
+ */
1334
+ async setPower(state, prefix) {
1335
+ const message = {
1336
+ type: "power",
1337
+ method: "post",
1338
+ data: { state, ...prefix !== void 0 && { prefix } }
1339
+ };
1340
+ await this.client.request(message);
1341
+ const oldState = this.currentState;
1342
+ this.currentState = state;
1343
+ if (oldState !== this.currentState) {
1344
+ this.emit("power:changed", this.currentState);
1345
+ }
1346
+ }
1347
+ /**
1348
+ * Turn track power on
1349
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
1350
+ */
1351
+ async powerOn(prefix) {
1352
+ await this.setPower(2 /* ON */, prefix);
1353
+ }
1354
+ /**
1355
+ * Turn track power off
1356
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
1357
+ */
1358
+ async powerOff(prefix) {
1359
+ await this.setPower(4 /* OFF */, prefix);
1360
+ }
1361
+ /**
1362
+ * Get cached power state (no network request)
1363
+ */
1364
+ getCachedState() {
1365
+ return this.currentState;
1366
+ }
1367
+ /**
1368
+ * Handle unsolicited power updates from JMRI
1369
+ */
1370
+ handlePowerUpdate(message) {
1371
+ if (message.data?.state !== void 0) {
1372
+ const oldState = this.currentState;
1373
+ this.currentState = message.data.state;
1374
+ if (oldState !== this.currentState) {
1375
+ this.emit("power:changed", this.currentState);
1376
+ }
1377
+ }
1378
+ }
1379
+ };
1380
+
1381
+ // src/managers/roster-manager.ts
1382
+ var RosterManager = class {
1383
+ constructor(client) {
1384
+ this.rosterCache = /* @__PURE__ */ new Map();
1385
+ this.client = client;
1386
+ }
1387
+ /**
1388
+ * Get all roster entries
1389
+ */
1390
+ async getRoster() {
1391
+ const message = {
1392
+ type: "roster",
1393
+ method: "list"
1394
+ };
1395
+ const response = await this.client.request(message);
1396
+ if (response.data) {
1397
+ this.updateCache(response.data);
1398
+ }
1399
+ return Array.from(this.rosterCache.values());
1400
+ }
1401
+ /**
1402
+ * Get roster entry by name
1403
+ */
1404
+ async getRosterEntryByName(name) {
1405
+ if (this.rosterCache.has(name)) {
1406
+ return this.rosterCache.get(name);
1407
+ }
1408
+ await this.getRoster();
1409
+ return this.rosterCache.get(name);
1410
+ }
1411
+ /**
1412
+ * Get roster entry by address
1413
+ */
1414
+ async getRosterEntryByAddress(address) {
1415
+ if (this.rosterCache.size === 0) {
1416
+ await this.getRoster();
1417
+ }
1418
+ const addressStr = address.toString();
1419
+ for (const wrapper of this.rosterCache.values()) {
1420
+ if (wrapper.data.address === addressStr) {
1421
+ return wrapper;
1422
+ }
1423
+ }
1424
+ return void 0;
1425
+ }
1426
+ /**
1427
+ * Search roster by partial name match
1428
+ */
1429
+ async searchRoster(query) {
1430
+ if (this.rosterCache.size === 0) {
1431
+ await this.getRoster();
1432
+ }
1433
+ const lowerQuery = query.toLowerCase();
1434
+ const results = [];
1435
+ for (const wrapper of this.rosterCache.values()) {
1436
+ const entry = wrapper.data;
1437
+ if (entry.name.toLowerCase().includes(lowerQuery) || entry.address.includes(query) || entry.road?.toLowerCase().includes(lowerQuery) || entry.number?.includes(query)) {
1438
+ results.push(wrapper);
1439
+ }
1440
+ }
1441
+ return results;
1442
+ }
1443
+ /**
1444
+ * Get all roster groups
1445
+ */
1446
+ async getRosterGroups() {
1447
+ const message = {
1448
+ type: "rosterGroup",
1449
+ method: "list"
1450
+ };
1451
+ const response = await this.client.request(message);
1452
+ if (!response.data) return [];
1453
+ return response.data.map((wrapper) => wrapper.data);
1454
+ }
1455
+ /**
1456
+ * Get roster entries belonging to a specific group
1457
+ */
1458
+ async getRosterEntriesByGroup(group) {
1459
+ const message = {
1460
+ type: "roster",
1461
+ method: "list",
1462
+ params: { group }
1463
+ };
1464
+ const response = await this.client.request(message);
1465
+ if (!response.data) return [];
1466
+ const entries = response.data;
1467
+ return entries.filter((e) => e.type === "rosterEntry");
1468
+ }
1469
+ /**
1470
+ * Get cached roster (no network request)
1471
+ */
1472
+ getCachedRoster() {
1473
+ return Array.from(this.rosterCache.values());
1474
+ }
1475
+ /**
1476
+ * Clear roster cache
1477
+ */
1478
+ clearCache() {
1479
+ this.rosterCache.clear();
1480
+ }
1481
+ /**
1482
+ * Update internal cache from roster data
1483
+ */
1484
+ updateCache(rosterData) {
1485
+ this.rosterCache.clear();
1486
+ if (Array.isArray(rosterData)) {
1487
+ for (const wrapper of rosterData) {
1488
+ if (wrapper.type === "rosterEntry" && wrapper.data) {
1489
+ this.rosterCache.set(wrapper.data.name, wrapper);
1490
+ }
1491
+ }
1492
+ } else {
1493
+ let id = 1;
1494
+ for (const [name, entry] of Object.entries(rosterData)) {
1495
+ const wrapper = {
1496
+ type: "rosterEntry",
1497
+ data: entry,
1498
+ id: id++
1499
+ };
1500
+ this.rosterCache.set(name, wrapper);
1501
+ }
1502
+ }
1503
+ }
1504
+ };
1505
+
1506
+ // src/managers/throttle-manager.ts
1507
+ import { EventEmitter as EventEmitter7 } from "eventemitter3";
1508
+
1509
+ // src/types/throttle.ts
1510
+ function isThrottleFunctionKey(key) {
1511
+ return /^F([0-9]|1[0-9]|2[0-8])$/.test(key);
1512
+ }
1513
+ function isValidSpeed(speed) {
1514
+ return typeof speed === "number" && speed >= 0 && speed <= 1;
1515
+ }
1516
+
1517
+ // src/managers/throttle-manager.ts
1518
+ var ThrottleManager = class extends EventEmitter7 {
1519
+ constructor(client) {
1520
+ super();
1521
+ this.throttles = /* @__PURE__ */ new Map();
1522
+ this.client = client;
1523
+ this.clientId = `jmri-client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
1524
+ this.client.on("update", (message) => {
1525
+ if (message.type === "throttle") {
1526
+ this.handleThrottleUpdate(message);
1527
+ }
1528
+ });
1529
+ this.client.on("disconnected", () => {
1530
+ this.handleDisconnect();
1531
+ });
1532
+ }
1533
+ /**
1534
+ * Acquire a throttle for a locomotive
1535
+ */
1536
+ async acquireThrottle(options) {
1537
+ const throttleName = `${this.clientId}-${options.address}`;
1538
+ const message = {
1539
+ type: "throttle",
1540
+ data: {
1541
+ name: throttleName,
1542
+ address: options.address,
1543
+ ...options.prefix !== void 0 && { prefix: options.prefix }
1544
+ }
1545
+ };
1546
+ const response = await this.client.request(message);
1547
+ const throttleId = response.data?.throttle || throttleName;
1548
+ if (!throttleId) {
1549
+ throw new Error("Failed to acquire throttle: no throttle ID returned");
1550
+ }
1551
+ const state = {
1552
+ id: throttleId,
1553
+ address: options.address,
1554
+ speed: 0,
1555
+ forward: true,
1556
+ functions: /* @__PURE__ */ new Map(),
1557
+ acquired: true
1558
+ };
1559
+ this.throttles.set(throttleId, state);
1560
+ this.emit("throttle:acquired", throttleId);
1561
+ return throttleId;
1562
+ }
1563
+ /**
1564
+ * Release a throttle
1565
+ */
1566
+ async releaseThrottle(throttleId) {
1567
+ const state = this.throttles.get(throttleId);
1568
+ if (!state) {
1569
+ throw new Error(`Throttle not found: ${throttleId}`);
1570
+ }
1571
+ const message = {
1572
+ type: "throttle",
1573
+ data: {
1574
+ throttle: throttleId,
1575
+ release: null
1576
+ }
1577
+ };
1578
+ await this.client.request(message);
1579
+ this.throttles.delete(throttleId);
1580
+ this.emit("throttle:released", throttleId);
1581
+ }
1582
+ /**
1583
+ * Set throttle speed (0.0 to 1.0)
1584
+ */
1585
+ async setSpeed(throttleId, speed) {
1586
+ const state = this.throttles.get(throttleId);
1587
+ if (!state) {
1588
+ throw new Error(`Throttle not found: ${throttleId}`);
1589
+ }
1590
+ if (!isValidSpeed(speed)) {
1591
+ throw new Error(`Invalid speed: ${speed}. Must be between 0.0 and 1.0`);
1592
+ }
1593
+ const message = {
1594
+ type: "throttle",
1595
+ data: {
1596
+ throttle: throttleId,
1597
+ speed
1598
+ }
1599
+ };
1600
+ this.client.send(message);
1601
+ state.speed = speed;
1602
+ this.emit("throttle:updated", throttleId, { speed });
1603
+ }
1604
+ /**
1605
+ * Set throttle direction
1606
+ */
1607
+ async setDirection(throttleId, forward) {
1608
+ const state = this.throttles.get(throttleId);
1609
+ if (!state) {
1610
+ throw new Error(`Throttle not found: ${throttleId}`);
1611
+ }
1612
+ const message = {
1613
+ type: "throttle",
1614
+ data: {
1615
+ throttle: throttleId,
1616
+ forward
1617
+ }
1618
+ };
1619
+ this.client.send(message);
1620
+ state.forward = forward;
1621
+ this.emit("throttle:updated", throttleId, { forward });
1622
+ }
1623
+ /**
1624
+ * Set throttle function (F0-F28)
1625
+ */
1626
+ async setFunction(throttleId, functionKey, value) {
1627
+ const state = this.throttles.get(throttleId);
1628
+ if (!state) {
1629
+ throw new Error(`Throttle not found: ${throttleId}`);
1630
+ }
1631
+ if (!isThrottleFunctionKey(functionKey)) {
1632
+ throw new Error(`Invalid function key: ${functionKey}`);
1633
+ }
1634
+ const data = {
1635
+ throttle: throttleId,
1636
+ [functionKey]: value
1637
+ };
1638
+ const message = {
1639
+ type: "throttle",
1640
+ data
1641
+ };
1642
+ this.client.send(message);
1643
+ state.functions.set(functionKey, value);
1644
+ this.emit("throttle:updated", throttleId, { [functionKey]: value });
1645
+ }
1646
+ /**
1647
+ * Emergency stop for a throttle (speed to 0)
1648
+ */
1649
+ async emergencyStop(throttleId) {
1650
+ await this.setSpeed(throttleId, 0);
1651
+ }
1652
+ /**
1653
+ * Set throttle to idle (speed to 0, maintain direction)
1654
+ */
1655
+ async idle(throttleId) {
1656
+ await this.setSpeed(throttleId, 0);
1657
+ }
1658
+ /**
1659
+ * Get throttle state
1660
+ */
1661
+ getThrottleState(throttleId) {
1662
+ return this.throttles.get(throttleId);
1663
+ }
1664
+ /**
1665
+ * Get all throttle IDs
1666
+ */
1667
+ getThrottleIds() {
1668
+ return Array.from(this.throttles.keys());
1669
+ }
1670
+ /**
1671
+ * Get all throttle states
1672
+ */
1673
+ getAllThrottles() {
1674
+ return Array.from(this.throttles.values());
1675
+ }
1676
+ /**
1677
+ * Release all throttles
1678
+ */
1679
+ async releaseAllThrottles() {
1680
+ const throttleIds = this.getThrottleIds();
1681
+ for (const throttleId of throttleIds) {
1682
+ try {
1683
+ await this.releaseThrottle(throttleId);
1684
+ } catch (error) {
1685
+ this.emit("error", error);
1686
+ }
1687
+ }
1688
+ }
1689
+ /**
1690
+ * Handle unsolicited throttle updates from JMRI
1691
+ */
1692
+ handleThrottleUpdate(message) {
1693
+ const throttleId = message.data?.throttle;
1694
+ if (!throttleId) {
1695
+ return;
1696
+ }
1697
+ const state = this.throttles.get(throttleId);
1698
+ if (!state) {
1699
+ this.emit("throttle:lost", throttleId);
1700
+ return;
1701
+ }
1702
+ if (message.data.speed !== void 0) {
1703
+ state.speed = message.data.speed;
1704
+ }
1705
+ if (message.data.forward !== void 0) {
1706
+ state.forward = message.data.forward;
1707
+ }
1708
+ for (let i = 0; i <= 28; i++) {
1709
+ const key = `F${i}`;
1710
+ if (message.data[key] !== void 0) {
1711
+ state.functions.set(key, message.data[key]);
1712
+ }
1713
+ }
1714
+ this.emit("throttle:updated", throttleId, message.data);
1715
+ }
1716
+ /**
1717
+ * Handle disconnect - mark all throttles as not acquired
1718
+ */
1719
+ handleDisconnect() {
1720
+ for (const state of this.throttles.values()) {
1721
+ state.acquired = false;
1722
+ }
1723
+ }
1724
+ };
1725
+
1726
+ // src/managers/turnout-manager.ts
1727
+ import { EventEmitter as EventEmitter8 } from "eventemitter3";
1728
+ var TurnoutManager = class extends EventEmitter8 {
1729
+ constructor(client) {
1730
+ super();
1731
+ this.turnouts = /* @__PURE__ */ new Map();
1732
+ this.client = client;
1733
+ this.client.on("update", (message) => {
1734
+ if (message.type === "turnout") {
1735
+ this.handleTurnoutUpdate(message);
1736
+ }
1737
+ });
1738
+ }
1739
+ /**
1740
+ * Get the current state of a turnout.
1741
+ * Also registers a server-side listener so subsequent changes are pushed.
1742
+ */
1743
+ async getTurnout(name) {
1744
+ const message = {
1745
+ type: "turnout",
1746
+ data: { name }
1747
+ };
1748
+ const response = await this.client.request(message);
1749
+ const state = response.data?.state ?? 0 /* UNKNOWN */;
1750
+ this.turnouts.set(name, state);
1751
+ return state;
1752
+ }
1753
+ /**
1754
+ * Set a turnout to the given state
1755
+ */
1756
+ async setTurnout(name, state) {
1757
+ const message = {
1758
+ type: "turnout",
1759
+ method: "post",
1760
+ data: { name, state }
1761
+ };
1762
+ await this.client.request(message);
1763
+ const oldState = this.turnouts.get(name);
1764
+ this.turnouts.set(name, state);
1765
+ if (oldState !== state) {
1766
+ this.emit("turnout:changed", name, state);
1767
+ }
1768
+ }
1769
+ /**
1770
+ * Throw a turnout (diverging route)
1771
+ */
1772
+ async throwTurnout(name) {
1773
+ return this.setTurnout(name, 4 /* THROWN */);
1774
+ }
1775
+ /**
1776
+ * Close a turnout (straight through / normal)
1777
+ */
1778
+ async closeTurnout(name) {
1779
+ return this.setTurnout(name, 2 /* CLOSED */);
1780
+ }
1781
+ /**
1782
+ * List all turnouts known to JMRI
1783
+ */
1784
+ async listTurnouts() {
1785
+ const message = {
1786
+ type: "turnout",
1787
+ method: "list"
1788
+ };
1789
+ const response = await this.client.request(message);
1790
+ const entries = Array.isArray(response?.data) ? response.data.map((r) => r.data ?? r) : [];
1791
+ for (const entry of entries) {
1792
+ if (entry.name && entry.state !== void 0) {
1793
+ this.turnouts.set(entry.name, entry.state);
1794
+ }
1795
+ }
1796
+ return entries;
1797
+ }
1798
+ /**
1799
+ * Get cached turnout state without a network request
1800
+ */
1801
+ getTurnoutState(name) {
1802
+ return this.turnouts.get(name);
1803
+ }
1804
+ /**
1805
+ * Get all cached turnout states
1806
+ */
1807
+ getCachedTurnouts() {
1808
+ return new Map(this.turnouts);
1809
+ }
1810
+ /**
1811
+ * Handle unsolicited turnout state updates from JMRI
1812
+ */
1813
+ handleTurnoutUpdate(message) {
1814
+ const name = message.data?.name;
1815
+ const state = message.data?.state;
1816
+ if (!name || state === void 0) return;
1817
+ const oldState = this.turnouts.get(name);
1818
+ this.turnouts.set(name, state);
1819
+ if (oldState !== state) {
1820
+ this.emit("turnout:changed", name, state);
1821
+ }
1822
+ }
1823
+ };
1824
+
1825
+ // src/managers/light-manager.ts
1826
+ import { EventEmitter as EventEmitter9 } from "eventemitter3";
1827
+ var LightManager = class extends EventEmitter9 {
1828
+ constructor(client) {
1829
+ super();
1830
+ this.lights = /* @__PURE__ */ new Map();
1831
+ this.client = client;
1832
+ this.client.on("update", (message) => {
1833
+ if (message.type === "light") {
1834
+ this.handleLightUpdate(message);
1835
+ }
1836
+ });
1837
+ }
1838
+ /**
1839
+ * Get the current state of a light.
1840
+ * Also registers a server-side listener so subsequent changes are pushed.
1841
+ */
1842
+ async getLight(name) {
1843
+ const message = {
1844
+ type: "light",
1845
+ data: { name }
1846
+ };
1847
+ const response = await this.client.request(message);
1848
+ const state = response.data?.state ?? 0 /* UNKNOWN */;
1849
+ this.lights.set(name, state);
1850
+ return state;
1851
+ }
1852
+ /**
1853
+ * Set a light to the given state
1854
+ */
1855
+ async setLight(name, state) {
1856
+ const message = {
1857
+ type: "light",
1858
+ method: "post",
1859
+ data: { name, state }
1860
+ };
1861
+ await this.client.request(message);
1862
+ const oldState = this.lights.get(name);
1863
+ this.lights.set(name, state);
1864
+ if (oldState !== state) {
1865
+ this.emit("light:changed", name, state);
1866
+ }
1867
+ }
1868
+ /**
1869
+ * Turn a light on
1870
+ */
1871
+ async turnOnLight(name) {
1872
+ return this.setLight(name, 2 /* ON */);
1873
+ }
1874
+ /**
1875
+ * Turn a light off
1876
+ */
1877
+ async turnOffLight(name) {
1878
+ return this.setLight(name, 4 /* OFF */);
1879
+ }
1880
+ /**
1881
+ * List all lights known to JMRI
1882
+ */
1883
+ async listLights() {
1884
+ const message = {
1885
+ type: "light",
1886
+ method: "list"
1887
+ };
1888
+ const response = await this.client.request(message);
1889
+ const entries = Array.isArray(response?.data) ? response.data.map((r) => r.data ?? r) : [];
1890
+ for (const entry of entries) {
1891
+ if (entry.name && entry.state !== void 0) {
1892
+ this.lights.set(entry.name, entry.state);
1893
+ }
1894
+ }
1895
+ return entries;
1896
+ }
1897
+ /**
1898
+ * Get cached light state without a network request
1899
+ */
1900
+ getLightState(name) {
1901
+ return this.lights.get(name);
1902
+ }
1903
+ /**
1904
+ * Get all cached light states
1905
+ */
1906
+ getCachedLights() {
1907
+ return new Map(this.lights);
1908
+ }
1909
+ /**
1910
+ * Handle unsolicited light state updates from JMRI
1911
+ */
1912
+ handleLightUpdate(message) {
1913
+ const name = message.data?.name;
1914
+ const state = message.data?.state;
1915
+ if (!name || state === void 0) return;
1916
+ const oldState = this.lights.get(name);
1917
+ this.lights.set(name, state);
1918
+ if (oldState !== state) {
1919
+ this.emit("light:changed", name, state);
1920
+ }
1921
+ }
1922
+ };
1923
+
1924
+ // src/managers/system-connections-manager.ts
1925
+ var SystemConnectionsManager = class {
1926
+ constructor(client) {
1927
+ this.client = client;
1928
+ }
1929
+ /**
1930
+ * List all available JMRI system connections and their prefixes.
1931
+ * Use the returned prefix values with power and throttle commands to
1932
+ * target a specific hardware connection when multiple are configured.
1933
+ */
1934
+ async getSystemConnections() {
1935
+ const message = {
1936
+ type: "systemConnections",
1937
+ method: "list"
1938
+ };
1939
+ const response = await this.client.request(message);
1940
+ if (!response.data) {
1941
+ return [];
1942
+ }
1943
+ return Array.isArray(response.data) ? response.data : [response.data];
1944
+ }
1945
+ };
1946
+
1947
+ // src/types/client-options.ts
1948
+ var DEFAULT_CLIENT_OPTIONS = {
1949
+ host: "localhost",
1950
+ port: 12080,
1951
+ protocol: "ws",
1952
+ autoConnect: true,
1953
+ reconnection: {
1954
+ enabled: true,
1955
+ maxAttempts: 0,
1956
+ // infinite
1957
+ initialDelay: 1e3,
1958
+ maxDelay: 3e4,
1959
+ multiplier: 1.5,
1960
+ jitter: true
1961
+ },
1962
+ heartbeat: {
1963
+ enabled: true,
1964
+ interval: 3e4,
1965
+ timeout: 5e3
1966
+ },
1967
+ messageQueueSize: 100,
1968
+ requestTimeout: 1e4,
1969
+ mock: {
1970
+ enabled: false,
1971
+ responseDelay: 50
1972
+ }
1973
+ };
1974
+ function mergeOptions(userOptions) {
1975
+ const options = { ...DEFAULT_CLIENT_OPTIONS };
1976
+ if (!userOptions) {
1977
+ return options;
1978
+ }
1979
+ if (userOptions.host !== void 0) options.host = userOptions.host;
1980
+ if (userOptions.port !== void 0) options.port = userOptions.port;
1981
+ if (userOptions.protocol !== void 0) options.protocol = userOptions.protocol;
1982
+ if (userOptions.autoConnect !== void 0) options.autoConnect = userOptions.autoConnect;
1983
+ if (userOptions.messageQueueSize !== void 0) options.messageQueueSize = userOptions.messageQueueSize;
1984
+ if (userOptions.requestTimeout !== void 0) options.requestTimeout = userOptions.requestTimeout;
1985
+ if (userOptions.reconnection) {
1986
+ options.reconnection = { ...DEFAULT_CLIENT_OPTIONS.reconnection, ...userOptions.reconnection };
1987
+ }
1988
+ if (userOptions.heartbeat) {
1989
+ options.heartbeat = { ...DEFAULT_CLIENT_OPTIONS.heartbeat, ...userOptions.heartbeat };
1990
+ }
1991
+ if (userOptions.mock) {
1992
+ options.mock = { ...DEFAULT_CLIENT_OPTIONS.mock, ...userOptions.mock };
1993
+ }
1994
+ return options;
1995
+ }
1996
+
1997
+ // src/client.ts
1998
+ var JmriClient = class extends EventEmitter10 {
1999
+ /**
2000
+ * Create a new JMRI client
2001
+ *
2002
+ * @param options - Client configuration options
2003
+ *
2004
+ * @example
2005
+ * ```typescript
2006
+ * const client = new JmriClient({
2007
+ * host: 'jmri.local',
2008
+ * port: 12080
2009
+ * });
2010
+ *
2011
+ * client.on('connected', () => console.log('Connected!'));
2012
+ * client.on('power:changed', (state) => console.log('Power:', state));
2013
+ * ```
2014
+ */
2015
+ constructor(options) {
2016
+ super();
2017
+ this.options = mergeOptions(options);
2018
+ this.wsClient = new WebSocketClient(this.options);
2019
+ this.powerManager = new PowerManager(this.wsClient);
2020
+ this.rosterManager = new RosterManager(this.wsClient);
2021
+ this.throttleManager = new ThrottleManager(this.wsClient);
2022
+ this.turnoutManager = new TurnoutManager(this.wsClient);
2023
+ this.lightManager = new LightManager(this.wsClient);
2024
+ this.systemConnectionsManager = new SystemConnectionsManager(this.wsClient);
2025
+ this.wsClient.on("connected", () => this.emit("connected"));
2026
+ this.wsClient.on("disconnected", (reason) => this.emit("disconnected", reason));
2027
+ this.wsClient.on(
2028
+ "reconnecting",
2029
+ (attempt, delay) => this.emit("reconnecting", attempt, delay)
2030
+ );
2031
+ this.wsClient.on("reconnected", () => this.emit("reconnected"));
2032
+ this.wsClient.on(
2033
+ "reconnectionFailed",
2034
+ (attempts) => this.emit("reconnectionFailed", attempts)
2035
+ );
2036
+ this.wsClient.on(
2037
+ "connectionStateChanged",
2038
+ (state) => this.emit("connectionStateChanged", state)
2039
+ );
2040
+ this.wsClient.on("error", (error) => this.emit("error", error));
2041
+ this.wsClient.on("heartbeat:sent", () => this.emit("heartbeat:sent"));
2042
+ this.wsClient.on("heartbeat:timeout", () => this.emit("heartbeat:timeout"));
2043
+ this.wsClient.on("hello", (data) => this.emit("hello", data));
2044
+ this.powerManager.on(
2045
+ "power:changed",
2046
+ (state) => this.emit("power:changed", state)
2047
+ );
2048
+ this.turnoutManager.on(
2049
+ "turnout:changed",
2050
+ (name, state) => this.emit("turnout:changed", name, state)
2051
+ );
2052
+ this.lightManager.on(
2053
+ "light:changed",
2054
+ (name, state) => this.emit("light:changed", name, state)
2055
+ );
2056
+ this.throttleManager.on(
2057
+ "throttle:acquired",
2058
+ (id) => this.emit("throttle:acquired", id)
2059
+ );
2060
+ this.throttleManager.on(
2061
+ "throttle:updated",
2062
+ (id, data) => this.emit("throttle:updated", id, data)
2063
+ );
2064
+ this.throttleManager.on(
2065
+ "throttle:released",
2066
+ (id) => this.emit("throttle:released", id)
2067
+ );
2068
+ this.throttleManager.on(
2069
+ "throttle:lost",
2070
+ (id) => this.emit("throttle:lost", id)
2071
+ );
2072
+ if (this.options.autoConnect) {
2073
+ this.connect().catch((error) => {
2074
+ this.emit("error", error);
2075
+ });
2076
+ }
2077
+ }
2078
+ /**
2079
+ * Connect to JMRI server
2080
+ */
2081
+ async connect() {
2082
+ return this.wsClient.connect();
2083
+ }
2084
+ /**
2085
+ * Disconnect from JMRI server
2086
+ * Releases all throttles and closes connection
2087
+ */
2088
+ async disconnect() {
2089
+ await this.throttleManager.releaseAllThrottles();
2090
+ return this.wsClient.disconnect();
2091
+ }
2092
+ /**
2093
+ * Get current connection state
2094
+ */
2095
+ getConnectionState() {
2096
+ return this.wsClient.getState();
2097
+ }
2098
+ /**
2099
+ * Check if connected to JMRI
2100
+ */
2101
+ isConnected() {
2102
+ return this.wsClient.isConnected();
2103
+ }
2104
+ // ============================================================================
2105
+ // Power Control
2106
+ // ============================================================================
2107
+ /**
2108
+ * Get current track power state
2109
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
2110
+ */
2111
+ async getPower(prefix) {
2112
+ return this.powerManager.getPower(prefix);
2113
+ }
2114
+ /**
2115
+ * Set track power state
2116
+ * @param state - The desired power state
2117
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
2118
+ */
2119
+ async setPower(state, prefix) {
2120
+ return this.powerManager.setPower(state, prefix);
2121
+ }
2122
+ /**
2123
+ * Turn track power on
2124
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
2125
+ */
2126
+ async powerOn(prefix) {
2127
+ return this.powerManager.powerOn(prefix);
2128
+ }
2129
+ /**
2130
+ * Turn track power off
2131
+ * @param prefix - Optional JMRI connection prefix to target a specific hardware connection
2132
+ */
2133
+ async powerOff(prefix) {
2134
+ return this.powerManager.powerOff(prefix);
2135
+ }
2136
+ // ============================================================================
2137
+ // System Connections
2138
+ // ============================================================================
2139
+ /**
2140
+ * List all available JMRI system connections and their prefixes.
2141
+ * Use the returned prefix values with power and throttle commands to
2142
+ * target a specific hardware connection when multiple are configured.
2143
+ *
2144
+ * @example
2145
+ * ```typescript
2146
+ * const connections = await client.getSystemConnections();
2147
+ * // [{ name: 'LocoNet', prefix: 'L' }, { name: 'DCC++', prefix: 'D' }]
2148
+ * await client.powerOn('L'); // power on via LocoNet only
2149
+ * ```
2150
+ */
2151
+ async getSystemConnections() {
2152
+ return this.systemConnectionsManager.getSystemConnections();
2153
+ }
2154
+ // ============================================================================
2155
+ // Roster Management
2156
+ // ============================================================================
2157
+ /**
2158
+ * Get all roster entries
2159
+ */
2160
+ async getRoster() {
2161
+ return this.rosterManager.getRoster();
2162
+ }
2163
+ /**
2164
+ * Get roster entry by name
2165
+ */
2166
+ async getRosterEntryByName(name) {
2167
+ return this.rosterManager.getRosterEntryByName(name);
2168
+ }
2169
+ /**
2170
+ * Get roster entry by address
2171
+ */
2172
+ async getRosterEntryByAddress(address) {
2173
+ return this.rosterManager.getRosterEntryByAddress(address);
2174
+ }
2175
+ /**
2176
+ * Search roster by partial name match
2177
+ */
2178
+ async searchRoster(query) {
2179
+ return this.rosterManager.searchRoster(query);
2180
+ }
2181
+ /**
2182
+ * Get all roster groups
2183
+ */
2184
+ async getRosterGroups() {
2185
+ return this.rosterManager.getRosterGroups();
2186
+ }
2187
+ /**
2188
+ * Get roster entries belonging to a specific group
2189
+ */
2190
+ async getRosterEntriesByGroup(group) {
2191
+ return this.rosterManager.getRosterEntriesByGroup(group);
2192
+ }
2193
+ // ============================================================================
2194
+ // Turnout Control
2195
+ // ============================================================================
2196
+ /**
2197
+ * Get the current state of a turnout
2198
+ */
2199
+ async getTurnout(name) {
2200
+ return this.turnoutManager.getTurnout(name);
2201
+ }
2202
+ /**
2203
+ * Set a turnout to the given state
2204
+ */
2205
+ async setTurnout(name, state) {
2206
+ return this.turnoutManager.setTurnout(name, state);
2207
+ }
2208
+ /**
2209
+ * Throw a turnout (diverging route)
2210
+ */
2211
+ async throwTurnout(name) {
2212
+ return this.turnoutManager.throwTurnout(name);
2213
+ }
2214
+ /**
2215
+ * Close a turnout (straight through / normal)
2216
+ */
2217
+ async closeTurnout(name) {
2218
+ return this.turnoutManager.closeTurnout(name);
2219
+ }
2220
+ /**
2221
+ * List all turnouts known to JMRI
2222
+ */
2223
+ async listTurnouts() {
2224
+ return this.turnoutManager.listTurnouts();
2225
+ }
2226
+ /**
2227
+ * Get cached turnout state without a network request
2228
+ */
2229
+ getTurnoutState(name) {
2230
+ return this.turnoutManager.getTurnoutState(name);
2231
+ }
2232
+ /**
2233
+ * Get all cached turnout states
2234
+ */
2235
+ getCachedTurnouts() {
2236
+ return this.turnoutManager.getCachedTurnouts();
2237
+ }
2238
+ // ============================================================================
2239
+ // Light Control
2240
+ // ============================================================================
2241
+ /**
2242
+ * Get the current state of a light
2243
+ */
2244
+ async getLight(name) {
2245
+ return this.lightManager.getLight(name);
2246
+ }
2247
+ /**
2248
+ * Set a light to the given state
2249
+ */
2250
+ async setLight(name, state) {
2251
+ return this.lightManager.setLight(name, state);
2252
+ }
2253
+ /**
2254
+ * Turn a light on
2255
+ */
2256
+ async turnOnLight(name) {
2257
+ return this.lightManager.turnOnLight(name);
2258
+ }
2259
+ /**
2260
+ * Turn a light off
2261
+ */
2262
+ async turnOffLight(name) {
2263
+ return this.lightManager.turnOffLight(name);
2264
+ }
2265
+ /**
2266
+ * List all lights known to JMRI
2267
+ */
2268
+ async listLights() {
2269
+ return this.lightManager.listLights();
2270
+ }
2271
+ /**
2272
+ * Get cached light state without a network request
2273
+ */
2274
+ getLightState(name) {
2275
+ return this.lightManager.getLightState(name);
2276
+ }
2277
+ /**
2278
+ * Get all cached light states
2279
+ */
2280
+ getCachedLights() {
2281
+ return this.lightManager.getCachedLights();
2282
+ }
2283
+ // ============================================================================
2284
+ // Throttle Control
2285
+ // ============================================================================
2286
+ /**
2287
+ * Acquire a throttle for a locomotive
2288
+ *
2289
+ * @param options - Throttle acquisition options
2290
+ * @returns Throttle ID for use in other throttle methods
2291
+ *
2292
+ * @example
2293
+ * ```typescript
2294
+ * const throttleId = await client.acquireThrottle({ address: 3 });
2295
+ * await client.setThrottleSpeed(throttleId, 0.5); // Half speed
2296
+ * ```
2297
+ */
2298
+ async acquireThrottle(options) {
2299
+ return this.throttleManager.acquireThrottle(options);
2300
+ }
2301
+ /**
2302
+ * Release a throttle
2303
+ */
2304
+ async releaseThrottle(throttleId) {
2305
+ return this.throttleManager.releaseThrottle(throttleId);
2306
+ }
2307
+ /**
2308
+ * Set throttle speed (0.0 to 1.0)
2309
+ *
2310
+ * @param throttleId - Throttle ID from acquireThrottle
2311
+ * @param speed - Speed value between 0.0 (stopped) and 1.0 (full speed)
2312
+ */
2313
+ async setThrottleSpeed(throttleId, speed) {
2314
+ return this.throttleManager.setSpeed(throttleId, speed);
2315
+ }
2316
+ /**
2317
+ * Set throttle direction
2318
+ *
2319
+ * @param throttleId - Throttle ID from acquireThrottle
2320
+ * @param forward - True for forward, false for reverse
2321
+ */
2322
+ async setThrottleDirection(throttleId, forward) {
2323
+ return this.throttleManager.setDirection(throttleId, forward);
2324
+ }
2325
+ /**
2326
+ * Set throttle function (F0-F28)
2327
+ *
2328
+ * @param throttleId - Throttle ID from acquireThrottle
2329
+ * @param functionKey - Function key (F0-F28)
2330
+ * @param value - True to activate, false to deactivate
2331
+ *
2332
+ * @example
2333
+ * ```typescript
2334
+ * await client.setThrottleFunction(throttleId, 'F0', true); // Headlight on
2335
+ * await client.setThrottleFunction(throttleId, 'F2', true); // Horn
2336
+ * ```
2337
+ */
2338
+ async setThrottleFunction(throttleId, functionKey, value) {
2339
+ return this.throttleManager.setFunction(throttleId, functionKey, value);
2340
+ }
2341
+ /**
2342
+ * Emergency stop for a throttle (speed to 0)
2343
+ */
2344
+ async emergencyStop(throttleId) {
2345
+ return this.throttleManager.emergencyStop(throttleId);
2346
+ }
2347
+ /**
2348
+ * Set throttle to idle (speed to 0, maintain direction)
2349
+ */
2350
+ async idleThrottle(throttleId) {
2351
+ return this.throttleManager.idle(throttleId);
2352
+ }
2353
+ /**
2354
+ * Get throttle state
2355
+ */
2356
+ getThrottleState(throttleId) {
2357
+ return this.throttleManager.getThrottleState(throttleId);
2358
+ }
2359
+ /**
2360
+ * Get all throttle IDs
2361
+ */
2362
+ getThrottleIds() {
2363
+ return this.throttleManager.getThrottleIds();
2364
+ }
2365
+ /**
2366
+ * Get all throttle states
2367
+ */
2368
+ getAllThrottles() {
2369
+ return this.throttleManager.getAllThrottles();
2370
+ }
2371
+ /**
2372
+ * Release all throttles
2373
+ */
2374
+ async releaseAllThrottles() {
2375
+ return this.throttleManager.releaseAllThrottles();
2376
+ }
2377
+ };
2378
+ export {
2379
+ ConnectionState,
2380
+ JmriClient,
2381
+ LightState,
2382
+ MockResponseManager,
2383
+ PowerState,
2384
+ TurnoutState,
2385
+ WebSocketClient,
2386
+ isThrottleFunctionKey,
2387
+ isValidSpeed,
2388
+ lightStateToString,
2389
+ mockData,
2390
+ mockResponseManager,
2391
+ powerStateToString,
2392
+ turnoutStateToString
2393
+ };