jmri-client 4.2.0-beta.2 → 5.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cjs/index.js +2382 -31
- package/dist/esm/index.js +2333 -17
- package/docs/BROWSER.md +4 -4
- package/docs/MIGRATION.md +30 -1
- package/package.json +17 -18
- package/dist/cjs/client.js +0 -366
- package/dist/cjs/core/connection-state-manager.js +0 -84
- package/dist/cjs/core/heartbeat-manager.js +0 -79
- package/dist/cjs/core/index.js +0 -25
- package/dist/cjs/core/message-queue.js +0 -59
- package/dist/cjs/core/reconnection-manager.js +0 -97
- package/dist/cjs/core/websocket-adapter.js +0 -135
- package/dist/cjs/core/websocket-client.js +0 -388
- package/dist/cjs/managers/index.js +0 -25
- package/dist/cjs/managers/light-manager.js +0 -111
- package/dist/cjs/managers/power-manager.js +0 -90
- package/dist/cjs/managers/roster-manager.js +0 -118
- package/dist/cjs/managers/system-connections-manager.js +0 -28
- package/dist/cjs/managers/throttle-manager.js +0 -233
- package/dist/cjs/managers/turnout-manager.js +0 -111
- package/dist/cjs/mocks/index.js +0 -12
- package/dist/cjs/mocks/mock-data.js +0 -237
- package/dist/cjs/mocks/mock-response-manager.js +0 -290
- package/dist/cjs/types/client-options.js +0 -66
- package/dist/cjs/types/events.js +0 -16
- package/dist/cjs/types/index.js +0 -23
- package/dist/cjs/types/jmri-messages.js +0 -95
- package/dist/cjs/types/throttle.js +0 -19
- package/dist/cjs/utils/exponential-backoff.js +0 -40
- package/dist/cjs/utils/index.js +0 -21
- package/dist/cjs/utils/message-id.js +0 -40
- package/dist/esm/client.js +0 -362
- package/dist/esm/core/connection-state-manager.js +0 -80
- package/dist/esm/core/heartbeat-manager.js +0 -75
- package/dist/esm/core/index.js +0 -9
- package/dist/esm/core/message-queue.js +0 -55
- package/dist/esm/core/reconnection-manager.js +0 -93
- package/dist/esm/core/websocket-adapter.js +0 -98
- package/dist/esm/core/websocket-client.js +0 -384
- package/dist/esm/managers/index.js +0 -9
- package/dist/esm/managers/light-manager.js +0 -107
- package/dist/esm/managers/power-manager.js +0 -86
- package/dist/esm/managers/roster-manager.js +0 -114
- package/dist/esm/managers/system-connections-manager.js +0 -24
- package/dist/esm/managers/throttle-manager.js +0 -229
- package/dist/esm/managers/turnout-manager.js +0 -107
- package/dist/esm/mocks/index.js +0 -6
- package/dist/esm/mocks/mock-data.js +0 -234
- package/dist/esm/mocks/mock-response-manager.js +0 -286
- package/dist/esm/types/client-options.js +0 -62
- package/dist/esm/types/events.js +0 -13
- package/dist/esm/types/index.js +0 -7
- package/dist/esm/types/jmri-messages.js +0 -89
- package/dist/esm/types/throttle.js +0 -15
- package/dist/esm/utils/exponential-backoff.js +0 -36
- package/dist/esm/utils/index.js +0 -5
- package/dist/esm/utils/message-id.js +0 -36
package/dist/esm/index.js
CHANGED
|
@@ -1,17 +1,2333 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
//
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
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": [],
|
|
536
|
+
"rosterGroups": []
|
|
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": [],
|
|
567
|
+
"rosterGroups": []
|
|
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": [],
|
|
599
|
+
"rosterGroups": []
|
|
600
|
+
},
|
|
601
|
+
"id": 3
|
|
602
|
+
}
|
|
603
|
+
]
|
|
604
|
+
},
|
|
605
|
+
"throttle": {
|
|
606
|
+
"acquire": {
|
|
607
|
+
"success": {
|
|
608
|
+
"type": "throttle",
|
|
609
|
+
"data": {
|
|
610
|
+
"throttle": "{THROTTLE_ID}",
|
|
611
|
+
"address": "{ADDRESS}",
|
|
612
|
+
"speed": 0,
|
|
613
|
+
"forward": true,
|
|
614
|
+
"F0": false,
|
|
615
|
+
"F1": false,
|
|
616
|
+
"F2": false,
|
|
617
|
+
"F3": false,
|
|
618
|
+
"F4": false
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
},
|
|
622
|
+
"release": {
|
|
623
|
+
"success": {
|
|
624
|
+
"type": "throttle",
|
|
625
|
+
"data": {}
|
|
626
|
+
}
|
|
627
|
+
},
|
|
628
|
+
"control": {
|
|
629
|
+
"speed": {
|
|
630
|
+
"type": "throttle",
|
|
631
|
+
"data": {
|
|
632
|
+
"throttle": "{THROTTLE_ID}",
|
|
633
|
+
"speed": "{SPEED}"
|
|
634
|
+
}
|
|
635
|
+
},
|
|
636
|
+
"direction": {
|
|
637
|
+
"type": "throttle",
|
|
638
|
+
"data": {
|
|
639
|
+
"throttle": "{THROTTLE_ID}",
|
|
640
|
+
"forward": "{FORWARD}"
|
|
641
|
+
}
|
|
642
|
+
},
|
|
643
|
+
"function": {
|
|
644
|
+
"type": "throttle",
|
|
645
|
+
"data": {
|
|
646
|
+
"throttle": "{THROTTLE_ID}",
|
|
647
|
+
"{FUNCTION}": "{VALUE}"
|
|
648
|
+
}
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
},
|
|
652
|
+
"light": {
|
|
653
|
+
"list": [
|
|
654
|
+
{ "type": "light", "data": { "name": "IL1", "userName": "Yard Light", "comment": null, "properties": [], "state": 4 } },
|
|
655
|
+
{ "type": "light", "data": { "name": "IL2", "userName": "Platform Light", "comment": null, "properties": [], "state": 4 } },
|
|
656
|
+
{ "type": "light", "data": { "name": "IL3", "userName": "Signal Lamp", "comment": null, "properties": [], "state": 2 } }
|
|
657
|
+
]
|
|
658
|
+
},
|
|
659
|
+
"turnout": {
|
|
660
|
+
"list": [
|
|
661
|
+
{ "type": "turnout", "data": { "name": "LT1", "userName": "Main Diverge", "state": 2 } },
|
|
662
|
+
{ "type": "turnout", "data": { "name": "LT2", "userName": "Yard Lead", "state": 2 } },
|
|
663
|
+
{ "type": "turnout", "data": { "name": "LT3", "userName": "Siding Entry", "state": 4 } }
|
|
664
|
+
]
|
|
665
|
+
},
|
|
666
|
+
"ping": {
|
|
667
|
+
"type": "ping"
|
|
668
|
+
},
|
|
669
|
+
"pong": {
|
|
670
|
+
"type": "pong"
|
|
671
|
+
},
|
|
672
|
+
"goodbye": {
|
|
673
|
+
"type": "goodbye"
|
|
674
|
+
},
|
|
675
|
+
"error": {
|
|
676
|
+
"throttleNotFound": {
|
|
677
|
+
"type": "error",
|
|
678
|
+
"data": {
|
|
679
|
+
"code": 404,
|
|
680
|
+
"message": "Throttle not found"
|
|
681
|
+
}
|
|
682
|
+
},
|
|
683
|
+
"invalidSpeed": {
|
|
684
|
+
"type": "error",
|
|
685
|
+
"data": {
|
|
686
|
+
"code": 400,
|
|
687
|
+
"message": "Invalid speed value"
|
|
688
|
+
}
|
|
689
|
+
},
|
|
690
|
+
"connectionError": {
|
|
691
|
+
"type": "error",
|
|
692
|
+
"data": {
|
|
693
|
+
"code": 500,
|
|
694
|
+
"message": "Connection error"
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
};
|
|
699
|
+
|
|
700
|
+
// src/mocks/mock-response-manager.ts
|
|
701
|
+
var MockResponseManager = class {
|
|
702
|
+
constructor(options = {}) {
|
|
703
|
+
this.throttles = /* @__PURE__ */ new Map();
|
|
704
|
+
this.lights = /* @__PURE__ */ new Map([
|
|
705
|
+
["IL1", 4 /* OFF */],
|
|
706
|
+
["IL2", 4 /* OFF */],
|
|
707
|
+
["IL3", 2 /* ON */]
|
|
708
|
+
]);
|
|
709
|
+
this.turnouts = /* @__PURE__ */ new Map([
|
|
710
|
+
["LT1", 2 /* CLOSED */],
|
|
711
|
+
["LT2", 2 /* CLOSED */],
|
|
712
|
+
["LT3", 4 /* THROWN */]
|
|
713
|
+
]);
|
|
714
|
+
this.responseDelay = options.responseDelay ?? 50;
|
|
715
|
+
this.powerState = options.initialPowerState ?? 4 /* OFF */;
|
|
716
|
+
}
|
|
717
|
+
/**
|
|
718
|
+
* Get a mock response for a given message
|
|
719
|
+
*/
|
|
720
|
+
async getMockResponse(message) {
|
|
721
|
+
if (this.responseDelay > 0) {
|
|
722
|
+
await this.delay(this.responseDelay);
|
|
723
|
+
}
|
|
724
|
+
switch (message.type) {
|
|
725
|
+
case "hello":
|
|
726
|
+
return this.getHelloResponse();
|
|
727
|
+
case "power":
|
|
728
|
+
return this.getPowerResponse(message);
|
|
729
|
+
case "roster":
|
|
730
|
+
return this.getRosterResponse(message);
|
|
731
|
+
case "throttle":
|
|
732
|
+
return this.getThrottleResponse(message);
|
|
733
|
+
case "light":
|
|
734
|
+
return this.getLightResponse(message);
|
|
735
|
+
case "turnout":
|
|
736
|
+
return this.getTurnoutResponse(message);
|
|
737
|
+
case "ping":
|
|
738
|
+
return this.getPingResponse();
|
|
739
|
+
case "goodbye":
|
|
740
|
+
return this.getGoodbyeResponse();
|
|
741
|
+
default:
|
|
742
|
+
return null;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
/**
|
|
746
|
+
* Get hello response (connection establishment)
|
|
747
|
+
*/
|
|
748
|
+
getHelloResponse() {
|
|
749
|
+
return JSON.parse(JSON.stringify(mockData.hello));
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* Get power response
|
|
753
|
+
*/
|
|
754
|
+
getPowerResponse(message) {
|
|
755
|
+
if (message.data?.state !== void 0) {
|
|
756
|
+
this.powerState = message.data.state;
|
|
757
|
+
return {
|
|
758
|
+
type: "power",
|
|
759
|
+
data: { state: this.powerState }
|
|
760
|
+
};
|
|
761
|
+
}
|
|
762
|
+
return {
|
|
763
|
+
type: "power",
|
|
764
|
+
data: { state: this.powerState }
|
|
765
|
+
};
|
|
766
|
+
}
|
|
767
|
+
/**
|
|
768
|
+
* Get roster response
|
|
769
|
+
*/
|
|
770
|
+
getRosterResponse(message) {
|
|
771
|
+
if (message.type === "roster" && message.method === "list") {
|
|
772
|
+
return {
|
|
773
|
+
type: "roster",
|
|
774
|
+
data: JSON.parse(JSON.stringify(mockData.roster.list))
|
|
775
|
+
};
|
|
776
|
+
}
|
|
777
|
+
return {
|
|
778
|
+
type: "roster",
|
|
779
|
+
data: []
|
|
780
|
+
};
|
|
781
|
+
}
|
|
782
|
+
/**
|
|
783
|
+
* Get throttle response
|
|
784
|
+
*/
|
|
785
|
+
getThrottleResponse(message) {
|
|
786
|
+
const data = message.data || {};
|
|
787
|
+
if (data.address !== void 0 && !data.throttle) {
|
|
788
|
+
const throttleId = data.name || `MOCK-${data.address}`;
|
|
789
|
+
const throttleState = {
|
|
790
|
+
throttle: throttleId,
|
|
791
|
+
address: data.address,
|
|
792
|
+
speed: 0,
|
|
793
|
+
forward: true,
|
|
794
|
+
F0: false,
|
|
795
|
+
F1: false,
|
|
796
|
+
F2: false,
|
|
797
|
+
F3: false,
|
|
798
|
+
F4: false
|
|
799
|
+
};
|
|
800
|
+
this.throttles.set(throttleId, throttleState);
|
|
801
|
+
return {
|
|
802
|
+
type: "throttle",
|
|
803
|
+
data: { ...throttleState }
|
|
804
|
+
};
|
|
805
|
+
}
|
|
806
|
+
if (data.release !== void 0 && data.throttle) {
|
|
807
|
+
this.throttles.delete(data.throttle);
|
|
808
|
+
return {
|
|
809
|
+
type: "throttle",
|
|
810
|
+
data: {}
|
|
811
|
+
};
|
|
812
|
+
}
|
|
813
|
+
if (data.throttle) {
|
|
814
|
+
const throttleState = this.throttles.get(data.throttle);
|
|
815
|
+
if (!throttleState) {
|
|
816
|
+
const newState = {
|
|
817
|
+
throttle: data.throttle,
|
|
818
|
+
address: 0,
|
|
819
|
+
speed: 0,
|
|
820
|
+
forward: true
|
|
821
|
+
};
|
|
822
|
+
this.throttles.set(data.throttle, newState);
|
|
823
|
+
return {
|
|
824
|
+
type: "throttle",
|
|
825
|
+
data: { ...newState }
|
|
826
|
+
};
|
|
827
|
+
}
|
|
828
|
+
if (data.speed !== void 0) {
|
|
829
|
+
throttleState.speed = data.speed;
|
|
830
|
+
}
|
|
831
|
+
if (data.forward !== void 0) {
|
|
832
|
+
throttleState.forward = data.forward;
|
|
833
|
+
}
|
|
834
|
+
for (let i = 0; i <= 28; i++) {
|
|
835
|
+
const key = `F${i}`;
|
|
836
|
+
if (data[key] !== void 0) {
|
|
837
|
+
throttleState[key] = data[key];
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
return {
|
|
841
|
+
type: "throttle",
|
|
842
|
+
data: {}
|
|
843
|
+
};
|
|
844
|
+
}
|
|
845
|
+
return {
|
|
846
|
+
type: "throttle",
|
|
847
|
+
data: {}
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
/**
|
|
851
|
+
* Get light response
|
|
852
|
+
*/
|
|
853
|
+
getLightResponse(message) {
|
|
854
|
+
if (message.method === "list") {
|
|
855
|
+
return {
|
|
856
|
+
type: "light",
|
|
857
|
+
data: JSON.parse(JSON.stringify(mockData.light.list))
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
const name = message.data?.name;
|
|
861
|
+
if (!name) {
|
|
862
|
+
return { type: "light", data: { name: "", state: 0 /* UNKNOWN */ } };
|
|
863
|
+
}
|
|
864
|
+
if (message.method === "post" && message.data?.state !== void 0) {
|
|
865
|
+
this.lights.set(name, message.data.state);
|
|
866
|
+
}
|
|
867
|
+
const state = this.lights.get(name) ?? 0 /* UNKNOWN */;
|
|
868
|
+
return { type: "light", data: { name, state } };
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* Get turnout response
|
|
872
|
+
*/
|
|
873
|
+
getTurnoutResponse(message) {
|
|
874
|
+
if (message.method === "list") {
|
|
875
|
+
return {
|
|
876
|
+
type: "turnout",
|
|
877
|
+
data: JSON.parse(JSON.stringify(mockData.turnout.list))
|
|
878
|
+
};
|
|
879
|
+
}
|
|
880
|
+
const name = message.data?.name;
|
|
881
|
+
if (!name) {
|
|
882
|
+
return { type: "turnout", data: { name: "", state: 0 /* UNKNOWN */ } };
|
|
883
|
+
}
|
|
884
|
+
if (message.method === "post" && message.data?.state !== void 0) {
|
|
885
|
+
this.turnouts.set(name, message.data.state);
|
|
886
|
+
}
|
|
887
|
+
const state = this.turnouts.get(name) ?? 0 /* UNKNOWN */;
|
|
888
|
+
return { type: "turnout", data: { name, state } };
|
|
889
|
+
}
|
|
890
|
+
/**
|
|
891
|
+
* Get ping response (pong)
|
|
892
|
+
*/
|
|
893
|
+
getPingResponse() {
|
|
894
|
+
return JSON.parse(JSON.stringify(mockData.pong));
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Get goodbye response
|
|
898
|
+
*/
|
|
899
|
+
getGoodbyeResponse() {
|
|
900
|
+
return JSON.parse(JSON.stringify(mockData.goodbye));
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Get current power state
|
|
904
|
+
*/
|
|
905
|
+
getPowerState() {
|
|
906
|
+
return this.powerState;
|
|
907
|
+
}
|
|
908
|
+
/**
|
|
909
|
+
* Set power state (for testing)
|
|
910
|
+
*/
|
|
911
|
+
setPowerState(state) {
|
|
912
|
+
this.powerState = state;
|
|
913
|
+
}
|
|
914
|
+
/**
|
|
915
|
+
* Get all throttles (for testing)
|
|
916
|
+
*/
|
|
917
|
+
getThrottles() {
|
|
918
|
+
return this.throttles;
|
|
919
|
+
}
|
|
920
|
+
/**
|
|
921
|
+
* Get all light states (for testing)
|
|
922
|
+
*/
|
|
923
|
+
getLights() {
|
|
924
|
+
return this.lights;
|
|
925
|
+
}
|
|
926
|
+
/**
|
|
927
|
+
* Get all turnout states (for testing)
|
|
928
|
+
*/
|
|
929
|
+
getTurnouts() {
|
|
930
|
+
return this.turnouts;
|
|
931
|
+
}
|
|
932
|
+
/**
|
|
933
|
+
* Reset all state (for testing)
|
|
934
|
+
*/
|
|
935
|
+
reset() {
|
|
936
|
+
this.powerState = 4 /* OFF */;
|
|
937
|
+
this.throttles.clear();
|
|
938
|
+
this.lights = /* @__PURE__ */ new Map([
|
|
939
|
+
["IL1", 4 /* OFF */],
|
|
940
|
+
["IL2", 4 /* OFF */],
|
|
941
|
+
["IL3", 2 /* ON */]
|
|
942
|
+
]);
|
|
943
|
+
this.turnouts = /* @__PURE__ */ new Map([
|
|
944
|
+
["LT1", 2 /* CLOSED */],
|
|
945
|
+
["LT2", 2 /* CLOSED */],
|
|
946
|
+
["LT3", 4 /* THROWN */]
|
|
947
|
+
]);
|
|
948
|
+
}
|
|
949
|
+
/**
|
|
950
|
+
* Delay helper
|
|
951
|
+
*/
|
|
952
|
+
delay(ms) {
|
|
953
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
var mockResponseManager = new MockResponseManager({ responseDelay: 0 });
|
|
957
|
+
|
|
958
|
+
// src/core/websocket-client.ts
|
|
959
|
+
var WebSocketClient = class extends EventEmitter5 {
|
|
960
|
+
constructor(options) {
|
|
961
|
+
super();
|
|
962
|
+
// Request/response tracking
|
|
963
|
+
this.pendingRequests = /* @__PURE__ */ new Map();
|
|
964
|
+
// Connection state
|
|
965
|
+
this.isManualDisconnect = false;
|
|
966
|
+
this.options = options;
|
|
967
|
+
this.url = `${options.protocol}://${options.host}:${options.port}/json/`;
|
|
968
|
+
this.messageIdGen = new MessageIdGenerator();
|
|
969
|
+
this.messageQueue = new MessageQueue(options.messageQueueSize);
|
|
970
|
+
this.stateManager = new ConnectionStateManager();
|
|
971
|
+
this.heartbeatManager = new HeartbeatManager(options.heartbeat);
|
|
972
|
+
this.reconnectionManager = new ReconnectionManager(options.reconnection);
|
|
973
|
+
if (options.mock.enabled) {
|
|
974
|
+
this.mockManager = new MockResponseManager({
|
|
975
|
+
responseDelay: options.mock.responseDelay
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
this.stateManager.on("stateChanged", (newState, prevState) => {
|
|
979
|
+
this.emit("connectionStateChanged", newState, prevState);
|
|
980
|
+
});
|
|
981
|
+
this.heartbeatManager.on("timeout", () => {
|
|
982
|
+
this.emit("heartbeat:timeout");
|
|
983
|
+
this.handleHeartbeatTimeout();
|
|
984
|
+
});
|
|
985
|
+
this.heartbeatManager.on("pingSent", () => {
|
|
986
|
+
this.emit("heartbeat:sent");
|
|
987
|
+
});
|
|
988
|
+
this.reconnectionManager.on("attemptScheduled", (attempt, delay) => {
|
|
989
|
+
this.emit("reconnecting", attempt, delay);
|
|
990
|
+
});
|
|
991
|
+
this.reconnectionManager.on("success", () => {
|
|
992
|
+
this.emit("reconnected");
|
|
993
|
+
});
|
|
994
|
+
this.reconnectionManager.on("maxAttemptsReached", (attempts) => {
|
|
995
|
+
this.emit("reconnectionFailed", attempts);
|
|
996
|
+
});
|
|
997
|
+
this.reconnectionManager.on("debug", (message) => {
|
|
998
|
+
this.emit("debug", message);
|
|
999
|
+
});
|
|
1000
|
+
}
|
|
1001
|
+
/**
|
|
1002
|
+
* Connect to JMRI WebSocket server (or mock)
|
|
1003
|
+
*/
|
|
1004
|
+
async connect() {
|
|
1005
|
+
if (this.stateManager.isConnected() || this.stateManager.isConnecting()) {
|
|
1006
|
+
return;
|
|
1007
|
+
}
|
|
1008
|
+
this.isManualDisconnect = false;
|
|
1009
|
+
this.stateManager.transition("connecting" /* CONNECTING */);
|
|
1010
|
+
if (this.mockManager) {
|
|
1011
|
+
return this.connectMock();
|
|
1012
|
+
}
|
|
1013
|
+
return new Promise(async (resolve, reject) => {
|
|
1014
|
+
try {
|
|
1015
|
+
this.ws = await createWebSocketAdapter(this.url);
|
|
1016
|
+
this.ws.on("open", () => {
|
|
1017
|
+
this.handleOpen();
|
|
1018
|
+
resolve();
|
|
1019
|
+
});
|
|
1020
|
+
this.ws.on("message", (data) => {
|
|
1021
|
+
this.handleMessage(data);
|
|
1022
|
+
});
|
|
1023
|
+
this.ws.on("close", (code, reason) => {
|
|
1024
|
+
if (this.stateManager.isConnecting()) {
|
|
1025
|
+
this.stateManager.transition("disconnected" /* DISCONNECTED */);
|
|
1026
|
+
const error = new Error(`WebSocket connection failed (code: ${code}${reason ? ", reason: " + reason : ""})`);
|
|
1027
|
+
this.emit("error", error);
|
|
1028
|
+
reject(error);
|
|
1029
|
+
this.handleClose(code, reason);
|
|
1030
|
+
} else {
|
|
1031
|
+
this.handleClose(code, reason);
|
|
1032
|
+
}
|
|
1033
|
+
});
|
|
1034
|
+
this.ws.on("error", (error) => {
|
|
1035
|
+
this.emit("error", error);
|
|
1036
|
+
if (this.stateManager.isConnecting()) {
|
|
1037
|
+
this.stateManager.transition("disconnected" /* DISCONNECTED */);
|
|
1038
|
+
reject(error);
|
|
1039
|
+
}
|
|
1040
|
+
});
|
|
1041
|
+
} catch (error) {
|
|
1042
|
+
this.stateManager.transition("disconnected" /* DISCONNECTED */);
|
|
1043
|
+
reject(error);
|
|
1044
|
+
}
|
|
1045
|
+
});
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Simulate connection in mock mode
|
|
1049
|
+
*/
|
|
1050
|
+
async connectMock() {
|
|
1051
|
+
await this.delay(10);
|
|
1052
|
+
this.handleOpen();
|
|
1053
|
+
const helloResponse = await this.mockManager.getMockResponse({ type: "hello" });
|
|
1054
|
+
if (helloResponse) {
|
|
1055
|
+
this.processMessage(helloResponse);
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Disconnect from JMRI WebSocket server
|
|
1060
|
+
*/
|
|
1061
|
+
async disconnect() {
|
|
1062
|
+
if (this.stateManager.isDisconnected()) {
|
|
1063
|
+
return;
|
|
1064
|
+
}
|
|
1065
|
+
this.isManualDisconnect = true;
|
|
1066
|
+
this.reconnectionManager.stop();
|
|
1067
|
+
this.heartbeatManager.stop();
|
|
1068
|
+
if (this.stateManager.isConnected()) {
|
|
1069
|
+
try {
|
|
1070
|
+
await this.sendGoodbye();
|
|
1071
|
+
} catch (error) {
|
|
1072
|
+
}
|
|
1073
|
+
}
|
|
1074
|
+
if (this.ws) {
|
|
1075
|
+
this.ws.close();
|
|
1076
|
+
this.ws = void 0;
|
|
1077
|
+
}
|
|
1078
|
+
this.rejectAllPendingRequests(new Error("Client disconnected"));
|
|
1079
|
+
if (!this.stateManager.isDisconnected()) {
|
|
1080
|
+
this.stateManager.transition("disconnected" /* DISCONNECTED */);
|
|
1081
|
+
}
|
|
1082
|
+
}
|
|
1083
|
+
/**
|
|
1084
|
+
* Send message to JMRI (or mock)
|
|
1085
|
+
*/
|
|
1086
|
+
send(message) {
|
|
1087
|
+
if (!this.stateManager.isConnected()) {
|
|
1088
|
+
this.messageQueue.enqueue(message);
|
|
1089
|
+
return;
|
|
1090
|
+
}
|
|
1091
|
+
if (this.mockManager) {
|
|
1092
|
+
this.emit("message:sent", message);
|
|
1093
|
+
return;
|
|
1094
|
+
}
|
|
1095
|
+
if (!this.ws) {
|
|
1096
|
+
throw new Error("WebSocket not initialized");
|
|
1097
|
+
}
|
|
1098
|
+
const json = JSON.stringify(message);
|
|
1099
|
+
this.ws.send(json);
|
|
1100
|
+
this.emit("message:sent", message);
|
|
1101
|
+
}
|
|
1102
|
+
/**
|
|
1103
|
+
* Send request and wait for response (or get mock response)
|
|
1104
|
+
*/
|
|
1105
|
+
async request(message, timeout) {
|
|
1106
|
+
if (this.mockManager) {
|
|
1107
|
+
const response = await this.mockManager.getMockResponse(message);
|
|
1108
|
+
this.emit("message:sent", message);
|
|
1109
|
+
if (response) {
|
|
1110
|
+
this.processMessage(response);
|
|
1111
|
+
}
|
|
1112
|
+
return response;
|
|
1113
|
+
}
|
|
1114
|
+
const id = this.messageIdGen.next();
|
|
1115
|
+
message.id = id;
|
|
1116
|
+
return new Promise((resolve, reject) => {
|
|
1117
|
+
const timeoutMs = timeout || this.options.requestTimeout;
|
|
1118
|
+
const timeoutHandle = setTimeout(() => {
|
|
1119
|
+
this.pendingRequests.delete(id);
|
|
1120
|
+
reject(new Error(`Request timeout after ${timeoutMs}ms`));
|
|
1121
|
+
}, timeoutMs);
|
|
1122
|
+
const pendingRequest = {
|
|
1123
|
+
resolve,
|
|
1124
|
+
reject,
|
|
1125
|
+
timeout: timeoutHandle,
|
|
1126
|
+
messageType: message.type
|
|
1127
|
+
};
|
|
1128
|
+
if (message.type === "throttle" && message.data && "name" in message.data) {
|
|
1129
|
+
pendingRequest.matchKey = message.data.name;
|
|
1130
|
+
}
|
|
1131
|
+
this.pendingRequests.set(id, pendingRequest);
|
|
1132
|
+
try {
|
|
1133
|
+
this.send(message);
|
|
1134
|
+
} catch (error) {
|
|
1135
|
+
this.pendingRequests.delete(id);
|
|
1136
|
+
clearTimeout(timeoutHandle);
|
|
1137
|
+
reject(error);
|
|
1138
|
+
}
|
|
1139
|
+
});
|
|
1140
|
+
}
|
|
1141
|
+
/**
|
|
1142
|
+
* Get current connection state
|
|
1143
|
+
*/
|
|
1144
|
+
getState() {
|
|
1145
|
+
return this.stateManager.getState();
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Check if connected
|
|
1149
|
+
*/
|
|
1150
|
+
isConnected() {
|
|
1151
|
+
return this.stateManager.isConnected();
|
|
1152
|
+
}
|
|
1153
|
+
/**
|
|
1154
|
+
* Handle WebSocket open event
|
|
1155
|
+
*/
|
|
1156
|
+
handleOpen() {
|
|
1157
|
+
this.stateManager.transition("connected" /* CONNECTED */);
|
|
1158
|
+
this.emit("connected");
|
|
1159
|
+
if (this.options.heartbeat.enabled) {
|
|
1160
|
+
this.heartbeatManager.start(() => this.sendPing());
|
|
1161
|
+
}
|
|
1162
|
+
const queuedMessages = this.messageQueue.flush();
|
|
1163
|
+
for (const message of queuedMessages) {
|
|
1164
|
+
this.send(message);
|
|
1165
|
+
}
|
|
1166
|
+
}
|
|
1167
|
+
/**
|
|
1168
|
+
* Handle WebSocket message event
|
|
1169
|
+
*/
|
|
1170
|
+
handleMessage(data) {
|
|
1171
|
+
try {
|
|
1172
|
+
const message = JSON.parse(data);
|
|
1173
|
+
this.processMessage(message);
|
|
1174
|
+
} catch (error) {
|
|
1175
|
+
this.emit("error", new Error(`Failed to parse message: ${error}`));
|
|
1176
|
+
}
|
|
1177
|
+
}
|
|
1178
|
+
/**
|
|
1179
|
+
* Process a parsed message (called by both real and mock mode)
|
|
1180
|
+
*/
|
|
1181
|
+
processMessage(message) {
|
|
1182
|
+
this.emit("message:received", message);
|
|
1183
|
+
if (message.type === "pong") {
|
|
1184
|
+
this.heartbeatManager.receivedPong();
|
|
1185
|
+
return;
|
|
1186
|
+
}
|
|
1187
|
+
if (message.type === "hello") {
|
|
1188
|
+
this.emit("hello", message.data);
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (message.id !== void 0) {
|
|
1192
|
+
const pending = this.pendingRequests.get(message.id);
|
|
1193
|
+
if (pending) {
|
|
1194
|
+
clearTimeout(pending.timeout);
|
|
1195
|
+
this.pendingRequests.delete(message.id);
|
|
1196
|
+
pending.resolve(message);
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
}
|
|
1200
|
+
if (message.id === void 0) {
|
|
1201
|
+
for (const [id, pending] of this.pendingRequests.entries()) {
|
|
1202
|
+
if (pending.messageType === message.type) {
|
|
1203
|
+
if (message.type === "throttle" && pending.matchKey) {
|
|
1204
|
+
const throttleName = message.data?.throttle || message.data?.name;
|
|
1205
|
+
if (throttleName === pending.matchKey) {
|
|
1206
|
+
clearTimeout(pending.timeout);
|
|
1207
|
+
this.pendingRequests.delete(id);
|
|
1208
|
+
pending.resolve(message);
|
|
1209
|
+
return;
|
|
1210
|
+
}
|
|
1211
|
+
} else {
|
|
1212
|
+
clearTimeout(pending.timeout);
|
|
1213
|
+
this.pendingRequests.delete(id);
|
|
1214
|
+
pending.resolve(message);
|
|
1215
|
+
return;
|
|
1216
|
+
}
|
|
1217
|
+
}
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
this.emit("update", message);
|
|
1221
|
+
}
|
|
1222
|
+
/**
|
|
1223
|
+
* Handle WebSocket close event
|
|
1224
|
+
*/
|
|
1225
|
+
handleClose(code, reason) {
|
|
1226
|
+
this.heartbeatManager.stop();
|
|
1227
|
+
const wasConnected = this.stateManager.isConnected();
|
|
1228
|
+
const isReconnecting = this.reconnectionManager.reconnecting();
|
|
1229
|
+
if (this.stateManager.isConnected() || this.stateManager.isConnecting()) {
|
|
1230
|
+
this.stateManager.transition("disconnected" /* DISCONNECTED */);
|
|
1231
|
+
}
|
|
1232
|
+
this.emit("disconnected", reason || `Connection closed (code: ${code})`);
|
|
1233
|
+
this.rejectAllPendingRequests(new Error("Connection closed"));
|
|
1234
|
+
if (!this.isManualDisconnect && (wasConnected || isReconnecting) && this.options.reconnection.enabled) {
|
|
1235
|
+
this.stateManager.forceState("reconnecting" /* RECONNECTING */);
|
|
1236
|
+
this.reconnectionManager.start(() => this.connect());
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* Handle heartbeat timeout
|
|
1241
|
+
*/
|
|
1242
|
+
handleHeartbeatTimeout() {
|
|
1243
|
+
if (this.ws) {
|
|
1244
|
+
this.ws.close();
|
|
1245
|
+
}
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* Send ping message
|
|
1249
|
+
*/
|
|
1250
|
+
sendPing() {
|
|
1251
|
+
this.send({ type: "ping" });
|
|
1252
|
+
}
|
|
1253
|
+
/**
|
|
1254
|
+
* Send goodbye message
|
|
1255
|
+
*/
|
|
1256
|
+
async sendGoodbye() {
|
|
1257
|
+
const message = { type: "goodbye" };
|
|
1258
|
+
this.send(message);
|
|
1259
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
1260
|
+
}
|
|
1261
|
+
/**
|
|
1262
|
+
* Reject all pending requests
|
|
1263
|
+
*/
|
|
1264
|
+
rejectAllPendingRequests(error) {
|
|
1265
|
+
for (const pending of this.pendingRequests.values()) {
|
|
1266
|
+
clearTimeout(pending.timeout);
|
|
1267
|
+
pending.reject(error);
|
|
1268
|
+
}
|
|
1269
|
+
this.pendingRequests.clear();
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Delay helper
|
|
1273
|
+
*/
|
|
1274
|
+
delay(ms) {
|
|
1275
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1276
|
+
}
|
|
1277
|
+
};
|
|
1278
|
+
|
|
1279
|
+
// src/managers/power-manager.ts
|
|
1280
|
+
import { EventEmitter as EventEmitter6 } from "eventemitter3";
|
|
1281
|
+
var PowerManager = class extends EventEmitter6 {
|
|
1282
|
+
constructor(client) {
|
|
1283
|
+
super();
|
|
1284
|
+
this.currentState = 0 /* UNKNOWN */;
|
|
1285
|
+
this.client = client;
|
|
1286
|
+
this.client.on("update", (message) => {
|
|
1287
|
+
if (message.type === "power") {
|
|
1288
|
+
this.handlePowerUpdate(message);
|
|
1289
|
+
}
|
|
1290
|
+
});
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* Get current track power state
|
|
1294
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
1295
|
+
*/
|
|
1296
|
+
async getPower(prefix) {
|
|
1297
|
+
const message = {
|
|
1298
|
+
type: "power",
|
|
1299
|
+
...prefix !== void 0 && { data: { state: 0 /* UNKNOWN */, prefix } }
|
|
1300
|
+
};
|
|
1301
|
+
const response = await this.client.request(message);
|
|
1302
|
+
if (response.data?.state !== void 0) {
|
|
1303
|
+
this.currentState = response.data.state;
|
|
1304
|
+
}
|
|
1305
|
+
return this.currentState;
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Set track power state
|
|
1309
|
+
* @param state - The desired power state
|
|
1310
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
1311
|
+
*/
|
|
1312
|
+
async setPower(state, prefix) {
|
|
1313
|
+
const message = {
|
|
1314
|
+
type: "power",
|
|
1315
|
+
method: "post",
|
|
1316
|
+
data: { state, ...prefix !== void 0 && { prefix } }
|
|
1317
|
+
};
|
|
1318
|
+
await this.client.request(message);
|
|
1319
|
+
const oldState = this.currentState;
|
|
1320
|
+
this.currentState = state;
|
|
1321
|
+
if (oldState !== this.currentState) {
|
|
1322
|
+
this.emit("power:changed", this.currentState);
|
|
1323
|
+
}
|
|
1324
|
+
}
|
|
1325
|
+
/**
|
|
1326
|
+
* Turn track power on
|
|
1327
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
1328
|
+
*/
|
|
1329
|
+
async powerOn(prefix) {
|
|
1330
|
+
await this.setPower(2 /* ON */, prefix);
|
|
1331
|
+
}
|
|
1332
|
+
/**
|
|
1333
|
+
* Turn track power off
|
|
1334
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
1335
|
+
*/
|
|
1336
|
+
async powerOff(prefix) {
|
|
1337
|
+
await this.setPower(4 /* OFF */, prefix);
|
|
1338
|
+
}
|
|
1339
|
+
/**
|
|
1340
|
+
* Get cached power state (no network request)
|
|
1341
|
+
*/
|
|
1342
|
+
getCachedState() {
|
|
1343
|
+
return this.currentState;
|
|
1344
|
+
}
|
|
1345
|
+
/**
|
|
1346
|
+
* Handle unsolicited power updates from JMRI
|
|
1347
|
+
*/
|
|
1348
|
+
handlePowerUpdate(message) {
|
|
1349
|
+
if (message.data?.state !== void 0) {
|
|
1350
|
+
const oldState = this.currentState;
|
|
1351
|
+
this.currentState = message.data.state;
|
|
1352
|
+
if (oldState !== this.currentState) {
|
|
1353
|
+
this.emit("power:changed", this.currentState);
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
};
|
|
1358
|
+
|
|
1359
|
+
// src/managers/roster-manager.ts
|
|
1360
|
+
var RosterManager = class {
|
|
1361
|
+
constructor(client) {
|
|
1362
|
+
this.rosterCache = /* @__PURE__ */ new Map();
|
|
1363
|
+
this.client = client;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* Get all roster entries
|
|
1367
|
+
*/
|
|
1368
|
+
async getRoster() {
|
|
1369
|
+
const message = {
|
|
1370
|
+
type: "roster",
|
|
1371
|
+
method: "list"
|
|
1372
|
+
};
|
|
1373
|
+
const response = await this.client.request(message);
|
|
1374
|
+
if (response.data) {
|
|
1375
|
+
this.updateCache(response.data);
|
|
1376
|
+
}
|
|
1377
|
+
return Array.from(this.rosterCache.values());
|
|
1378
|
+
}
|
|
1379
|
+
/**
|
|
1380
|
+
* Get roster entry by name
|
|
1381
|
+
*/
|
|
1382
|
+
async getRosterEntryByName(name) {
|
|
1383
|
+
if (this.rosterCache.has(name)) {
|
|
1384
|
+
return this.rosterCache.get(name);
|
|
1385
|
+
}
|
|
1386
|
+
await this.getRoster();
|
|
1387
|
+
return this.rosterCache.get(name);
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* Get roster entry by address
|
|
1391
|
+
*/
|
|
1392
|
+
async getRosterEntryByAddress(address) {
|
|
1393
|
+
if (this.rosterCache.size === 0) {
|
|
1394
|
+
await this.getRoster();
|
|
1395
|
+
}
|
|
1396
|
+
const addressStr = address.toString();
|
|
1397
|
+
for (const wrapper of this.rosterCache.values()) {
|
|
1398
|
+
if (wrapper.data.address === addressStr) {
|
|
1399
|
+
return wrapper;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
return void 0;
|
|
1403
|
+
}
|
|
1404
|
+
/**
|
|
1405
|
+
* Search roster by partial name match
|
|
1406
|
+
*/
|
|
1407
|
+
async searchRoster(query) {
|
|
1408
|
+
if (this.rosterCache.size === 0) {
|
|
1409
|
+
await this.getRoster();
|
|
1410
|
+
}
|
|
1411
|
+
const lowerQuery = query.toLowerCase();
|
|
1412
|
+
const results = [];
|
|
1413
|
+
for (const wrapper of this.rosterCache.values()) {
|
|
1414
|
+
const entry = wrapper.data;
|
|
1415
|
+
if (entry.name.toLowerCase().includes(lowerQuery) || entry.address.includes(query) || entry.road?.toLowerCase().includes(lowerQuery) || entry.number?.includes(query)) {
|
|
1416
|
+
results.push(wrapper);
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return results;
|
|
1420
|
+
}
|
|
1421
|
+
/**
|
|
1422
|
+
* Get cached roster (no network request)
|
|
1423
|
+
*/
|
|
1424
|
+
getCachedRoster() {
|
|
1425
|
+
return Array.from(this.rosterCache.values());
|
|
1426
|
+
}
|
|
1427
|
+
/**
|
|
1428
|
+
* Clear roster cache
|
|
1429
|
+
*/
|
|
1430
|
+
clearCache() {
|
|
1431
|
+
this.rosterCache.clear();
|
|
1432
|
+
}
|
|
1433
|
+
/**
|
|
1434
|
+
* Update internal cache from roster data
|
|
1435
|
+
*/
|
|
1436
|
+
updateCache(rosterData) {
|
|
1437
|
+
this.rosterCache.clear();
|
|
1438
|
+
if (Array.isArray(rosterData)) {
|
|
1439
|
+
for (const wrapper of rosterData) {
|
|
1440
|
+
if (wrapper.type === "rosterEntry" && wrapper.data) {
|
|
1441
|
+
this.rosterCache.set(wrapper.data.name, wrapper);
|
|
1442
|
+
}
|
|
1443
|
+
}
|
|
1444
|
+
} else {
|
|
1445
|
+
let id = 1;
|
|
1446
|
+
for (const [name, entry] of Object.entries(rosterData)) {
|
|
1447
|
+
const wrapper = {
|
|
1448
|
+
type: "rosterEntry",
|
|
1449
|
+
data: entry,
|
|
1450
|
+
id: id++
|
|
1451
|
+
};
|
|
1452
|
+
this.rosterCache.set(name, wrapper);
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
}
|
|
1456
|
+
};
|
|
1457
|
+
|
|
1458
|
+
// src/managers/throttle-manager.ts
|
|
1459
|
+
import { EventEmitter as EventEmitter7 } from "eventemitter3";
|
|
1460
|
+
|
|
1461
|
+
// src/types/throttle.ts
|
|
1462
|
+
function isThrottleFunctionKey(key) {
|
|
1463
|
+
return /^F([0-9]|1[0-9]|2[0-8])$/.test(key);
|
|
1464
|
+
}
|
|
1465
|
+
function isValidSpeed(speed) {
|
|
1466
|
+
return typeof speed === "number" && speed >= 0 && speed <= 1;
|
|
1467
|
+
}
|
|
1468
|
+
|
|
1469
|
+
// src/managers/throttle-manager.ts
|
|
1470
|
+
var ThrottleManager = class extends EventEmitter7 {
|
|
1471
|
+
constructor(client) {
|
|
1472
|
+
super();
|
|
1473
|
+
this.throttles = /* @__PURE__ */ new Map();
|
|
1474
|
+
this.client = client;
|
|
1475
|
+
this.clientId = `jmri-client-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
|
1476
|
+
this.client.on("update", (message) => {
|
|
1477
|
+
if (message.type === "throttle") {
|
|
1478
|
+
this.handleThrottleUpdate(message);
|
|
1479
|
+
}
|
|
1480
|
+
});
|
|
1481
|
+
this.client.on("disconnected", () => {
|
|
1482
|
+
this.handleDisconnect();
|
|
1483
|
+
});
|
|
1484
|
+
}
|
|
1485
|
+
/**
|
|
1486
|
+
* Acquire a throttle for a locomotive
|
|
1487
|
+
*/
|
|
1488
|
+
async acquireThrottle(options) {
|
|
1489
|
+
const throttleName = `${this.clientId}-${options.address}`;
|
|
1490
|
+
const message = {
|
|
1491
|
+
type: "throttle",
|
|
1492
|
+
data: {
|
|
1493
|
+
name: throttleName,
|
|
1494
|
+
address: options.address,
|
|
1495
|
+
...options.prefix !== void 0 && { prefix: options.prefix }
|
|
1496
|
+
}
|
|
1497
|
+
};
|
|
1498
|
+
const response = await this.client.request(message);
|
|
1499
|
+
const throttleId = response.data?.throttle || throttleName;
|
|
1500
|
+
if (!throttleId) {
|
|
1501
|
+
throw new Error("Failed to acquire throttle: no throttle ID returned");
|
|
1502
|
+
}
|
|
1503
|
+
const state = {
|
|
1504
|
+
id: throttleId,
|
|
1505
|
+
address: options.address,
|
|
1506
|
+
speed: 0,
|
|
1507
|
+
forward: true,
|
|
1508
|
+
functions: /* @__PURE__ */ new Map(),
|
|
1509
|
+
acquired: true
|
|
1510
|
+
};
|
|
1511
|
+
this.throttles.set(throttleId, state);
|
|
1512
|
+
this.emit("throttle:acquired", throttleId);
|
|
1513
|
+
return throttleId;
|
|
1514
|
+
}
|
|
1515
|
+
/**
|
|
1516
|
+
* Release a throttle
|
|
1517
|
+
*/
|
|
1518
|
+
async releaseThrottle(throttleId) {
|
|
1519
|
+
const state = this.throttles.get(throttleId);
|
|
1520
|
+
if (!state) {
|
|
1521
|
+
throw new Error(`Throttle not found: ${throttleId}`);
|
|
1522
|
+
}
|
|
1523
|
+
const message = {
|
|
1524
|
+
type: "throttle",
|
|
1525
|
+
data: {
|
|
1526
|
+
throttle: throttleId,
|
|
1527
|
+
release: null
|
|
1528
|
+
}
|
|
1529
|
+
};
|
|
1530
|
+
await this.client.request(message);
|
|
1531
|
+
this.throttles.delete(throttleId);
|
|
1532
|
+
this.emit("throttle:released", throttleId);
|
|
1533
|
+
}
|
|
1534
|
+
/**
|
|
1535
|
+
* Set throttle speed (0.0 to 1.0)
|
|
1536
|
+
*/
|
|
1537
|
+
async setSpeed(throttleId, speed) {
|
|
1538
|
+
const state = this.throttles.get(throttleId);
|
|
1539
|
+
if (!state) {
|
|
1540
|
+
throw new Error(`Throttle not found: ${throttleId}`);
|
|
1541
|
+
}
|
|
1542
|
+
if (!isValidSpeed(speed)) {
|
|
1543
|
+
throw new Error(`Invalid speed: ${speed}. Must be between 0.0 and 1.0`);
|
|
1544
|
+
}
|
|
1545
|
+
const message = {
|
|
1546
|
+
type: "throttle",
|
|
1547
|
+
data: {
|
|
1548
|
+
throttle: throttleId,
|
|
1549
|
+
speed
|
|
1550
|
+
}
|
|
1551
|
+
};
|
|
1552
|
+
this.client.send(message);
|
|
1553
|
+
state.speed = speed;
|
|
1554
|
+
this.emit("throttle:updated", throttleId, { speed });
|
|
1555
|
+
}
|
|
1556
|
+
/**
|
|
1557
|
+
* Set throttle direction
|
|
1558
|
+
*/
|
|
1559
|
+
async setDirection(throttleId, forward) {
|
|
1560
|
+
const state = this.throttles.get(throttleId);
|
|
1561
|
+
if (!state) {
|
|
1562
|
+
throw new Error(`Throttle not found: ${throttleId}`);
|
|
1563
|
+
}
|
|
1564
|
+
const message = {
|
|
1565
|
+
type: "throttle",
|
|
1566
|
+
data: {
|
|
1567
|
+
throttle: throttleId,
|
|
1568
|
+
forward
|
|
1569
|
+
}
|
|
1570
|
+
};
|
|
1571
|
+
this.client.send(message);
|
|
1572
|
+
state.forward = forward;
|
|
1573
|
+
this.emit("throttle:updated", throttleId, { forward });
|
|
1574
|
+
}
|
|
1575
|
+
/**
|
|
1576
|
+
* Set throttle function (F0-F28)
|
|
1577
|
+
*/
|
|
1578
|
+
async setFunction(throttleId, functionKey, value) {
|
|
1579
|
+
const state = this.throttles.get(throttleId);
|
|
1580
|
+
if (!state) {
|
|
1581
|
+
throw new Error(`Throttle not found: ${throttleId}`);
|
|
1582
|
+
}
|
|
1583
|
+
if (!isThrottleFunctionKey(functionKey)) {
|
|
1584
|
+
throw new Error(`Invalid function key: ${functionKey}`);
|
|
1585
|
+
}
|
|
1586
|
+
const data = {
|
|
1587
|
+
throttle: throttleId,
|
|
1588
|
+
[functionKey]: value
|
|
1589
|
+
};
|
|
1590
|
+
const message = {
|
|
1591
|
+
type: "throttle",
|
|
1592
|
+
data
|
|
1593
|
+
};
|
|
1594
|
+
this.client.send(message);
|
|
1595
|
+
state.functions.set(functionKey, value);
|
|
1596
|
+
this.emit("throttle:updated", throttleId, { [functionKey]: value });
|
|
1597
|
+
}
|
|
1598
|
+
/**
|
|
1599
|
+
* Emergency stop for a throttle (speed to 0)
|
|
1600
|
+
*/
|
|
1601
|
+
async emergencyStop(throttleId) {
|
|
1602
|
+
await this.setSpeed(throttleId, 0);
|
|
1603
|
+
}
|
|
1604
|
+
/**
|
|
1605
|
+
* Set throttle to idle (speed to 0, maintain direction)
|
|
1606
|
+
*/
|
|
1607
|
+
async idle(throttleId) {
|
|
1608
|
+
await this.setSpeed(throttleId, 0);
|
|
1609
|
+
}
|
|
1610
|
+
/**
|
|
1611
|
+
* Get throttle state
|
|
1612
|
+
*/
|
|
1613
|
+
getThrottleState(throttleId) {
|
|
1614
|
+
return this.throttles.get(throttleId);
|
|
1615
|
+
}
|
|
1616
|
+
/**
|
|
1617
|
+
* Get all throttle IDs
|
|
1618
|
+
*/
|
|
1619
|
+
getThrottleIds() {
|
|
1620
|
+
return Array.from(this.throttles.keys());
|
|
1621
|
+
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Get all throttle states
|
|
1624
|
+
*/
|
|
1625
|
+
getAllThrottles() {
|
|
1626
|
+
return Array.from(this.throttles.values());
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Release all throttles
|
|
1630
|
+
*/
|
|
1631
|
+
async releaseAllThrottles() {
|
|
1632
|
+
const throttleIds = this.getThrottleIds();
|
|
1633
|
+
for (const throttleId of throttleIds) {
|
|
1634
|
+
try {
|
|
1635
|
+
await this.releaseThrottle(throttleId);
|
|
1636
|
+
} catch (error) {
|
|
1637
|
+
this.emit("error", error);
|
|
1638
|
+
}
|
|
1639
|
+
}
|
|
1640
|
+
}
|
|
1641
|
+
/**
|
|
1642
|
+
* Handle unsolicited throttle updates from JMRI
|
|
1643
|
+
*/
|
|
1644
|
+
handleThrottleUpdate(message) {
|
|
1645
|
+
const throttleId = message.data?.throttle;
|
|
1646
|
+
if (!throttleId) {
|
|
1647
|
+
return;
|
|
1648
|
+
}
|
|
1649
|
+
const state = this.throttles.get(throttleId);
|
|
1650
|
+
if (!state) {
|
|
1651
|
+
this.emit("throttle:lost", throttleId);
|
|
1652
|
+
return;
|
|
1653
|
+
}
|
|
1654
|
+
if (message.data.speed !== void 0) {
|
|
1655
|
+
state.speed = message.data.speed;
|
|
1656
|
+
}
|
|
1657
|
+
if (message.data.forward !== void 0) {
|
|
1658
|
+
state.forward = message.data.forward;
|
|
1659
|
+
}
|
|
1660
|
+
for (let i = 0; i <= 28; i++) {
|
|
1661
|
+
const key = `F${i}`;
|
|
1662
|
+
if (message.data[key] !== void 0) {
|
|
1663
|
+
state.functions.set(key, message.data[key]);
|
|
1664
|
+
}
|
|
1665
|
+
}
|
|
1666
|
+
this.emit("throttle:updated", throttleId, message.data);
|
|
1667
|
+
}
|
|
1668
|
+
/**
|
|
1669
|
+
* Handle disconnect - mark all throttles as not acquired
|
|
1670
|
+
*/
|
|
1671
|
+
handleDisconnect() {
|
|
1672
|
+
for (const state of this.throttles.values()) {
|
|
1673
|
+
state.acquired = false;
|
|
1674
|
+
}
|
|
1675
|
+
}
|
|
1676
|
+
};
|
|
1677
|
+
|
|
1678
|
+
// src/managers/turnout-manager.ts
|
|
1679
|
+
import { EventEmitter as EventEmitter8 } from "eventemitter3";
|
|
1680
|
+
var TurnoutManager = class extends EventEmitter8 {
|
|
1681
|
+
constructor(client) {
|
|
1682
|
+
super();
|
|
1683
|
+
this.turnouts = /* @__PURE__ */ new Map();
|
|
1684
|
+
this.client = client;
|
|
1685
|
+
this.client.on("update", (message) => {
|
|
1686
|
+
if (message.type === "turnout") {
|
|
1687
|
+
this.handleTurnoutUpdate(message);
|
|
1688
|
+
}
|
|
1689
|
+
});
|
|
1690
|
+
}
|
|
1691
|
+
/**
|
|
1692
|
+
* Get the current state of a turnout.
|
|
1693
|
+
* Also registers a server-side listener so subsequent changes are pushed.
|
|
1694
|
+
*/
|
|
1695
|
+
async getTurnout(name) {
|
|
1696
|
+
const message = {
|
|
1697
|
+
type: "turnout",
|
|
1698
|
+
data: { name }
|
|
1699
|
+
};
|
|
1700
|
+
const response = await this.client.request(message);
|
|
1701
|
+
const state = response.data?.state ?? 0 /* UNKNOWN */;
|
|
1702
|
+
this.turnouts.set(name, state);
|
|
1703
|
+
return state;
|
|
1704
|
+
}
|
|
1705
|
+
/**
|
|
1706
|
+
* Set a turnout to the given state
|
|
1707
|
+
*/
|
|
1708
|
+
async setTurnout(name, state) {
|
|
1709
|
+
const message = {
|
|
1710
|
+
type: "turnout",
|
|
1711
|
+
method: "post",
|
|
1712
|
+
data: { name, state }
|
|
1713
|
+
};
|
|
1714
|
+
await this.client.request(message);
|
|
1715
|
+
const oldState = this.turnouts.get(name);
|
|
1716
|
+
this.turnouts.set(name, state);
|
|
1717
|
+
if (oldState !== state) {
|
|
1718
|
+
this.emit("turnout:changed", name, state);
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* Throw a turnout (diverging route)
|
|
1723
|
+
*/
|
|
1724
|
+
async throwTurnout(name) {
|
|
1725
|
+
return this.setTurnout(name, 4 /* THROWN */);
|
|
1726
|
+
}
|
|
1727
|
+
/**
|
|
1728
|
+
* Close a turnout (straight through / normal)
|
|
1729
|
+
*/
|
|
1730
|
+
async closeTurnout(name) {
|
|
1731
|
+
return this.setTurnout(name, 2 /* CLOSED */);
|
|
1732
|
+
}
|
|
1733
|
+
/**
|
|
1734
|
+
* List all turnouts known to JMRI
|
|
1735
|
+
*/
|
|
1736
|
+
async listTurnouts() {
|
|
1737
|
+
const message = {
|
|
1738
|
+
type: "turnout",
|
|
1739
|
+
method: "list"
|
|
1740
|
+
};
|
|
1741
|
+
const response = await this.client.request(message);
|
|
1742
|
+
const entries = Array.isArray(response?.data) ? response.data.map((r) => r.data ?? r) : [];
|
|
1743
|
+
for (const entry of entries) {
|
|
1744
|
+
if (entry.name && entry.state !== void 0) {
|
|
1745
|
+
this.turnouts.set(entry.name, entry.state);
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
return entries;
|
|
1749
|
+
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Get cached turnout state without a network request
|
|
1752
|
+
*/
|
|
1753
|
+
getTurnoutState(name) {
|
|
1754
|
+
return this.turnouts.get(name);
|
|
1755
|
+
}
|
|
1756
|
+
/**
|
|
1757
|
+
* Get all cached turnout states
|
|
1758
|
+
*/
|
|
1759
|
+
getCachedTurnouts() {
|
|
1760
|
+
return new Map(this.turnouts);
|
|
1761
|
+
}
|
|
1762
|
+
/**
|
|
1763
|
+
* Handle unsolicited turnout state updates from JMRI
|
|
1764
|
+
*/
|
|
1765
|
+
handleTurnoutUpdate(message) {
|
|
1766
|
+
const name = message.data?.name;
|
|
1767
|
+
const state = message.data?.state;
|
|
1768
|
+
if (!name || state === void 0) return;
|
|
1769
|
+
const oldState = this.turnouts.get(name);
|
|
1770
|
+
this.turnouts.set(name, state);
|
|
1771
|
+
if (oldState !== state) {
|
|
1772
|
+
this.emit("turnout:changed", name, state);
|
|
1773
|
+
}
|
|
1774
|
+
}
|
|
1775
|
+
};
|
|
1776
|
+
|
|
1777
|
+
// src/managers/light-manager.ts
|
|
1778
|
+
import { EventEmitter as EventEmitter9 } from "eventemitter3";
|
|
1779
|
+
var LightManager = class extends EventEmitter9 {
|
|
1780
|
+
constructor(client) {
|
|
1781
|
+
super();
|
|
1782
|
+
this.lights = /* @__PURE__ */ new Map();
|
|
1783
|
+
this.client = client;
|
|
1784
|
+
this.client.on("update", (message) => {
|
|
1785
|
+
if (message.type === "light") {
|
|
1786
|
+
this.handleLightUpdate(message);
|
|
1787
|
+
}
|
|
1788
|
+
});
|
|
1789
|
+
}
|
|
1790
|
+
/**
|
|
1791
|
+
* Get the current state of a light.
|
|
1792
|
+
* Also registers a server-side listener so subsequent changes are pushed.
|
|
1793
|
+
*/
|
|
1794
|
+
async getLight(name) {
|
|
1795
|
+
const message = {
|
|
1796
|
+
type: "light",
|
|
1797
|
+
data: { name }
|
|
1798
|
+
};
|
|
1799
|
+
const response = await this.client.request(message);
|
|
1800
|
+
const state = response.data?.state ?? 0 /* UNKNOWN */;
|
|
1801
|
+
this.lights.set(name, state);
|
|
1802
|
+
return state;
|
|
1803
|
+
}
|
|
1804
|
+
/**
|
|
1805
|
+
* Set a light to the given state
|
|
1806
|
+
*/
|
|
1807
|
+
async setLight(name, state) {
|
|
1808
|
+
const message = {
|
|
1809
|
+
type: "light",
|
|
1810
|
+
method: "post",
|
|
1811
|
+
data: { name, state }
|
|
1812
|
+
};
|
|
1813
|
+
await this.client.request(message);
|
|
1814
|
+
const oldState = this.lights.get(name);
|
|
1815
|
+
this.lights.set(name, state);
|
|
1816
|
+
if (oldState !== state) {
|
|
1817
|
+
this.emit("light:changed", name, state);
|
|
1818
|
+
}
|
|
1819
|
+
}
|
|
1820
|
+
/**
|
|
1821
|
+
* Turn a light on
|
|
1822
|
+
*/
|
|
1823
|
+
async turnOnLight(name) {
|
|
1824
|
+
return this.setLight(name, 2 /* ON */);
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Turn a light off
|
|
1828
|
+
*/
|
|
1829
|
+
async turnOffLight(name) {
|
|
1830
|
+
return this.setLight(name, 4 /* OFF */);
|
|
1831
|
+
}
|
|
1832
|
+
/**
|
|
1833
|
+
* List all lights known to JMRI
|
|
1834
|
+
*/
|
|
1835
|
+
async listLights() {
|
|
1836
|
+
const message = {
|
|
1837
|
+
type: "light",
|
|
1838
|
+
method: "list"
|
|
1839
|
+
};
|
|
1840
|
+
const response = await this.client.request(message);
|
|
1841
|
+
const entries = Array.isArray(response?.data) ? response.data.map((r) => r.data ?? r) : [];
|
|
1842
|
+
for (const entry of entries) {
|
|
1843
|
+
if (entry.name && entry.state !== void 0) {
|
|
1844
|
+
this.lights.set(entry.name, entry.state);
|
|
1845
|
+
}
|
|
1846
|
+
}
|
|
1847
|
+
return entries;
|
|
1848
|
+
}
|
|
1849
|
+
/**
|
|
1850
|
+
* Get cached light state without a network request
|
|
1851
|
+
*/
|
|
1852
|
+
getLightState(name) {
|
|
1853
|
+
return this.lights.get(name);
|
|
1854
|
+
}
|
|
1855
|
+
/**
|
|
1856
|
+
* Get all cached light states
|
|
1857
|
+
*/
|
|
1858
|
+
getCachedLights() {
|
|
1859
|
+
return new Map(this.lights);
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Handle unsolicited light state updates from JMRI
|
|
1863
|
+
*/
|
|
1864
|
+
handleLightUpdate(message) {
|
|
1865
|
+
const name = message.data?.name;
|
|
1866
|
+
const state = message.data?.state;
|
|
1867
|
+
if (!name || state === void 0) return;
|
|
1868
|
+
const oldState = this.lights.get(name);
|
|
1869
|
+
this.lights.set(name, state);
|
|
1870
|
+
if (oldState !== state) {
|
|
1871
|
+
this.emit("light:changed", name, state);
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
};
|
|
1875
|
+
|
|
1876
|
+
// src/managers/system-connections-manager.ts
|
|
1877
|
+
var SystemConnectionsManager = class {
|
|
1878
|
+
constructor(client) {
|
|
1879
|
+
this.client = client;
|
|
1880
|
+
}
|
|
1881
|
+
/**
|
|
1882
|
+
* List all available JMRI system connections and their prefixes.
|
|
1883
|
+
* Use the returned prefix values with power and throttle commands to
|
|
1884
|
+
* target a specific hardware connection when multiple are configured.
|
|
1885
|
+
*/
|
|
1886
|
+
async getSystemConnections() {
|
|
1887
|
+
const message = {
|
|
1888
|
+
type: "systemConnections",
|
|
1889
|
+
method: "list"
|
|
1890
|
+
};
|
|
1891
|
+
const response = await this.client.request(message);
|
|
1892
|
+
if (!response.data) {
|
|
1893
|
+
return [];
|
|
1894
|
+
}
|
|
1895
|
+
return Array.isArray(response.data) ? response.data : [response.data];
|
|
1896
|
+
}
|
|
1897
|
+
};
|
|
1898
|
+
|
|
1899
|
+
// src/types/client-options.ts
|
|
1900
|
+
var DEFAULT_CLIENT_OPTIONS = {
|
|
1901
|
+
host: "localhost",
|
|
1902
|
+
port: 12080,
|
|
1903
|
+
protocol: "ws",
|
|
1904
|
+
autoConnect: true,
|
|
1905
|
+
reconnection: {
|
|
1906
|
+
enabled: true,
|
|
1907
|
+
maxAttempts: 0,
|
|
1908
|
+
// infinite
|
|
1909
|
+
initialDelay: 1e3,
|
|
1910
|
+
maxDelay: 3e4,
|
|
1911
|
+
multiplier: 1.5,
|
|
1912
|
+
jitter: true
|
|
1913
|
+
},
|
|
1914
|
+
heartbeat: {
|
|
1915
|
+
enabled: true,
|
|
1916
|
+
interval: 3e4,
|
|
1917
|
+
timeout: 5e3
|
|
1918
|
+
},
|
|
1919
|
+
messageQueueSize: 100,
|
|
1920
|
+
requestTimeout: 1e4,
|
|
1921
|
+
mock: {
|
|
1922
|
+
enabled: false,
|
|
1923
|
+
responseDelay: 50
|
|
1924
|
+
}
|
|
1925
|
+
};
|
|
1926
|
+
function mergeOptions(userOptions) {
|
|
1927
|
+
const options = { ...DEFAULT_CLIENT_OPTIONS };
|
|
1928
|
+
if (!userOptions) {
|
|
1929
|
+
return options;
|
|
1930
|
+
}
|
|
1931
|
+
if (userOptions.host !== void 0) options.host = userOptions.host;
|
|
1932
|
+
if (userOptions.port !== void 0) options.port = userOptions.port;
|
|
1933
|
+
if (userOptions.protocol !== void 0) options.protocol = userOptions.protocol;
|
|
1934
|
+
if (userOptions.autoConnect !== void 0) options.autoConnect = userOptions.autoConnect;
|
|
1935
|
+
if (userOptions.messageQueueSize !== void 0) options.messageQueueSize = userOptions.messageQueueSize;
|
|
1936
|
+
if (userOptions.requestTimeout !== void 0) options.requestTimeout = userOptions.requestTimeout;
|
|
1937
|
+
if (userOptions.reconnection) {
|
|
1938
|
+
options.reconnection = { ...DEFAULT_CLIENT_OPTIONS.reconnection, ...userOptions.reconnection };
|
|
1939
|
+
}
|
|
1940
|
+
if (userOptions.heartbeat) {
|
|
1941
|
+
options.heartbeat = { ...DEFAULT_CLIENT_OPTIONS.heartbeat, ...userOptions.heartbeat };
|
|
1942
|
+
}
|
|
1943
|
+
if (userOptions.mock) {
|
|
1944
|
+
options.mock = { ...DEFAULT_CLIENT_OPTIONS.mock, ...userOptions.mock };
|
|
1945
|
+
}
|
|
1946
|
+
return options;
|
|
1947
|
+
}
|
|
1948
|
+
|
|
1949
|
+
// src/client.ts
|
|
1950
|
+
var JmriClient = class extends EventEmitter10 {
|
|
1951
|
+
/**
|
|
1952
|
+
* Create a new JMRI client
|
|
1953
|
+
*
|
|
1954
|
+
* @param options - Client configuration options
|
|
1955
|
+
*
|
|
1956
|
+
* @example
|
|
1957
|
+
* ```typescript
|
|
1958
|
+
* const client = new JmriClient({
|
|
1959
|
+
* host: 'jmri.local',
|
|
1960
|
+
* port: 12080
|
|
1961
|
+
* });
|
|
1962
|
+
*
|
|
1963
|
+
* client.on('connected', () => console.log('Connected!'));
|
|
1964
|
+
* client.on('power:changed', (state) => console.log('Power:', state));
|
|
1965
|
+
* ```
|
|
1966
|
+
*/
|
|
1967
|
+
constructor(options) {
|
|
1968
|
+
super();
|
|
1969
|
+
this.options = mergeOptions(options);
|
|
1970
|
+
this.wsClient = new WebSocketClient(this.options);
|
|
1971
|
+
this.powerManager = new PowerManager(this.wsClient);
|
|
1972
|
+
this.rosterManager = new RosterManager(this.wsClient);
|
|
1973
|
+
this.throttleManager = new ThrottleManager(this.wsClient);
|
|
1974
|
+
this.turnoutManager = new TurnoutManager(this.wsClient);
|
|
1975
|
+
this.lightManager = new LightManager(this.wsClient);
|
|
1976
|
+
this.systemConnectionsManager = new SystemConnectionsManager(this.wsClient);
|
|
1977
|
+
this.wsClient.on("connected", () => this.emit("connected"));
|
|
1978
|
+
this.wsClient.on("disconnected", (reason) => this.emit("disconnected", reason));
|
|
1979
|
+
this.wsClient.on(
|
|
1980
|
+
"reconnecting",
|
|
1981
|
+
(attempt, delay) => this.emit("reconnecting", attempt, delay)
|
|
1982
|
+
);
|
|
1983
|
+
this.wsClient.on("reconnected", () => this.emit("reconnected"));
|
|
1984
|
+
this.wsClient.on(
|
|
1985
|
+
"reconnectionFailed",
|
|
1986
|
+
(attempts) => this.emit("reconnectionFailed", attempts)
|
|
1987
|
+
);
|
|
1988
|
+
this.wsClient.on(
|
|
1989
|
+
"connectionStateChanged",
|
|
1990
|
+
(state) => this.emit("connectionStateChanged", state)
|
|
1991
|
+
);
|
|
1992
|
+
this.wsClient.on("error", (error) => this.emit("error", error));
|
|
1993
|
+
this.wsClient.on("heartbeat:sent", () => this.emit("heartbeat:sent"));
|
|
1994
|
+
this.wsClient.on("heartbeat:timeout", () => this.emit("heartbeat:timeout"));
|
|
1995
|
+
this.wsClient.on("hello", (data) => this.emit("hello", data));
|
|
1996
|
+
this.powerManager.on(
|
|
1997
|
+
"power:changed",
|
|
1998
|
+
(state) => this.emit("power:changed", state)
|
|
1999
|
+
);
|
|
2000
|
+
this.turnoutManager.on(
|
|
2001
|
+
"turnout:changed",
|
|
2002
|
+
(name, state) => this.emit("turnout:changed", name, state)
|
|
2003
|
+
);
|
|
2004
|
+
this.lightManager.on(
|
|
2005
|
+
"light:changed",
|
|
2006
|
+
(name, state) => this.emit("light:changed", name, state)
|
|
2007
|
+
);
|
|
2008
|
+
this.throttleManager.on(
|
|
2009
|
+
"throttle:acquired",
|
|
2010
|
+
(id) => this.emit("throttle:acquired", id)
|
|
2011
|
+
);
|
|
2012
|
+
this.throttleManager.on(
|
|
2013
|
+
"throttle:updated",
|
|
2014
|
+
(id, data) => this.emit("throttle:updated", id, data)
|
|
2015
|
+
);
|
|
2016
|
+
this.throttleManager.on(
|
|
2017
|
+
"throttle:released",
|
|
2018
|
+
(id) => this.emit("throttle:released", id)
|
|
2019
|
+
);
|
|
2020
|
+
this.throttleManager.on(
|
|
2021
|
+
"throttle:lost",
|
|
2022
|
+
(id) => this.emit("throttle:lost", id)
|
|
2023
|
+
);
|
|
2024
|
+
if (this.options.autoConnect) {
|
|
2025
|
+
this.connect().catch((error) => {
|
|
2026
|
+
this.emit("error", error);
|
|
2027
|
+
});
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
/**
|
|
2031
|
+
* Connect to JMRI server
|
|
2032
|
+
*/
|
|
2033
|
+
async connect() {
|
|
2034
|
+
return this.wsClient.connect();
|
|
2035
|
+
}
|
|
2036
|
+
/**
|
|
2037
|
+
* Disconnect from JMRI server
|
|
2038
|
+
* Releases all throttles and closes connection
|
|
2039
|
+
*/
|
|
2040
|
+
async disconnect() {
|
|
2041
|
+
await this.throttleManager.releaseAllThrottles();
|
|
2042
|
+
return this.wsClient.disconnect();
|
|
2043
|
+
}
|
|
2044
|
+
/**
|
|
2045
|
+
* Get current connection state
|
|
2046
|
+
*/
|
|
2047
|
+
getConnectionState() {
|
|
2048
|
+
return this.wsClient.getState();
|
|
2049
|
+
}
|
|
2050
|
+
/**
|
|
2051
|
+
* Check if connected to JMRI
|
|
2052
|
+
*/
|
|
2053
|
+
isConnected() {
|
|
2054
|
+
return this.wsClient.isConnected();
|
|
2055
|
+
}
|
|
2056
|
+
// ============================================================================
|
|
2057
|
+
// Power Control
|
|
2058
|
+
// ============================================================================
|
|
2059
|
+
/**
|
|
2060
|
+
* Get current track power state
|
|
2061
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
2062
|
+
*/
|
|
2063
|
+
async getPower(prefix) {
|
|
2064
|
+
return this.powerManager.getPower(prefix);
|
|
2065
|
+
}
|
|
2066
|
+
/**
|
|
2067
|
+
* Set track power state
|
|
2068
|
+
* @param state - The desired power state
|
|
2069
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
2070
|
+
*/
|
|
2071
|
+
async setPower(state, prefix) {
|
|
2072
|
+
return this.powerManager.setPower(state, prefix);
|
|
2073
|
+
}
|
|
2074
|
+
/**
|
|
2075
|
+
* Turn track power on
|
|
2076
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
2077
|
+
*/
|
|
2078
|
+
async powerOn(prefix) {
|
|
2079
|
+
return this.powerManager.powerOn(prefix);
|
|
2080
|
+
}
|
|
2081
|
+
/**
|
|
2082
|
+
* Turn track power off
|
|
2083
|
+
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
2084
|
+
*/
|
|
2085
|
+
async powerOff(prefix) {
|
|
2086
|
+
return this.powerManager.powerOff(prefix);
|
|
2087
|
+
}
|
|
2088
|
+
// ============================================================================
|
|
2089
|
+
// System Connections
|
|
2090
|
+
// ============================================================================
|
|
2091
|
+
/**
|
|
2092
|
+
* List all available JMRI system connections and their prefixes.
|
|
2093
|
+
* Use the returned prefix values with power and throttle commands to
|
|
2094
|
+
* target a specific hardware connection when multiple are configured.
|
|
2095
|
+
*
|
|
2096
|
+
* @example
|
|
2097
|
+
* ```typescript
|
|
2098
|
+
* const connections = await client.getSystemConnections();
|
|
2099
|
+
* // [{ name: 'LocoNet', prefix: 'L' }, { name: 'DCC++', prefix: 'D' }]
|
|
2100
|
+
* await client.powerOn('L'); // power on via LocoNet only
|
|
2101
|
+
* ```
|
|
2102
|
+
*/
|
|
2103
|
+
async getSystemConnections() {
|
|
2104
|
+
return this.systemConnectionsManager.getSystemConnections();
|
|
2105
|
+
}
|
|
2106
|
+
// ============================================================================
|
|
2107
|
+
// Roster Management
|
|
2108
|
+
// ============================================================================
|
|
2109
|
+
/**
|
|
2110
|
+
* Get all roster entries
|
|
2111
|
+
*/
|
|
2112
|
+
async getRoster() {
|
|
2113
|
+
return this.rosterManager.getRoster();
|
|
2114
|
+
}
|
|
2115
|
+
/**
|
|
2116
|
+
* Get roster entry by name
|
|
2117
|
+
*/
|
|
2118
|
+
async getRosterEntryByName(name) {
|
|
2119
|
+
return this.rosterManager.getRosterEntryByName(name);
|
|
2120
|
+
}
|
|
2121
|
+
/**
|
|
2122
|
+
* Get roster entry by address
|
|
2123
|
+
*/
|
|
2124
|
+
async getRosterEntryByAddress(address) {
|
|
2125
|
+
return this.rosterManager.getRosterEntryByAddress(address);
|
|
2126
|
+
}
|
|
2127
|
+
/**
|
|
2128
|
+
* Search roster by partial name match
|
|
2129
|
+
*/
|
|
2130
|
+
async searchRoster(query) {
|
|
2131
|
+
return this.rosterManager.searchRoster(query);
|
|
2132
|
+
}
|
|
2133
|
+
// ============================================================================
|
|
2134
|
+
// Turnout Control
|
|
2135
|
+
// ============================================================================
|
|
2136
|
+
/**
|
|
2137
|
+
* Get the current state of a turnout
|
|
2138
|
+
*/
|
|
2139
|
+
async getTurnout(name) {
|
|
2140
|
+
return this.turnoutManager.getTurnout(name);
|
|
2141
|
+
}
|
|
2142
|
+
/**
|
|
2143
|
+
* Set a turnout to the given state
|
|
2144
|
+
*/
|
|
2145
|
+
async setTurnout(name, state) {
|
|
2146
|
+
return this.turnoutManager.setTurnout(name, state);
|
|
2147
|
+
}
|
|
2148
|
+
/**
|
|
2149
|
+
* Throw a turnout (diverging route)
|
|
2150
|
+
*/
|
|
2151
|
+
async throwTurnout(name) {
|
|
2152
|
+
return this.turnoutManager.throwTurnout(name);
|
|
2153
|
+
}
|
|
2154
|
+
/**
|
|
2155
|
+
* Close a turnout (straight through / normal)
|
|
2156
|
+
*/
|
|
2157
|
+
async closeTurnout(name) {
|
|
2158
|
+
return this.turnoutManager.closeTurnout(name);
|
|
2159
|
+
}
|
|
2160
|
+
/**
|
|
2161
|
+
* List all turnouts known to JMRI
|
|
2162
|
+
*/
|
|
2163
|
+
async listTurnouts() {
|
|
2164
|
+
return this.turnoutManager.listTurnouts();
|
|
2165
|
+
}
|
|
2166
|
+
/**
|
|
2167
|
+
* Get cached turnout state without a network request
|
|
2168
|
+
*/
|
|
2169
|
+
getTurnoutState(name) {
|
|
2170
|
+
return this.turnoutManager.getTurnoutState(name);
|
|
2171
|
+
}
|
|
2172
|
+
/**
|
|
2173
|
+
* Get all cached turnout states
|
|
2174
|
+
*/
|
|
2175
|
+
getCachedTurnouts() {
|
|
2176
|
+
return this.turnoutManager.getCachedTurnouts();
|
|
2177
|
+
}
|
|
2178
|
+
// ============================================================================
|
|
2179
|
+
// Light Control
|
|
2180
|
+
// ============================================================================
|
|
2181
|
+
/**
|
|
2182
|
+
* Get the current state of a light
|
|
2183
|
+
*/
|
|
2184
|
+
async getLight(name) {
|
|
2185
|
+
return this.lightManager.getLight(name);
|
|
2186
|
+
}
|
|
2187
|
+
/**
|
|
2188
|
+
* Set a light to the given state
|
|
2189
|
+
*/
|
|
2190
|
+
async setLight(name, state) {
|
|
2191
|
+
return this.lightManager.setLight(name, state);
|
|
2192
|
+
}
|
|
2193
|
+
/**
|
|
2194
|
+
* Turn a light on
|
|
2195
|
+
*/
|
|
2196
|
+
async turnOnLight(name) {
|
|
2197
|
+
return this.lightManager.turnOnLight(name);
|
|
2198
|
+
}
|
|
2199
|
+
/**
|
|
2200
|
+
* Turn a light off
|
|
2201
|
+
*/
|
|
2202
|
+
async turnOffLight(name) {
|
|
2203
|
+
return this.lightManager.turnOffLight(name);
|
|
2204
|
+
}
|
|
2205
|
+
/**
|
|
2206
|
+
* List all lights known to JMRI
|
|
2207
|
+
*/
|
|
2208
|
+
async listLights() {
|
|
2209
|
+
return this.lightManager.listLights();
|
|
2210
|
+
}
|
|
2211
|
+
/**
|
|
2212
|
+
* Get cached light state without a network request
|
|
2213
|
+
*/
|
|
2214
|
+
getLightState(name) {
|
|
2215
|
+
return this.lightManager.getLightState(name);
|
|
2216
|
+
}
|
|
2217
|
+
/**
|
|
2218
|
+
* Get all cached light states
|
|
2219
|
+
*/
|
|
2220
|
+
getCachedLights() {
|
|
2221
|
+
return this.lightManager.getCachedLights();
|
|
2222
|
+
}
|
|
2223
|
+
// ============================================================================
|
|
2224
|
+
// Throttle Control
|
|
2225
|
+
// ============================================================================
|
|
2226
|
+
/**
|
|
2227
|
+
* Acquire a throttle for a locomotive
|
|
2228
|
+
*
|
|
2229
|
+
* @param options - Throttle acquisition options
|
|
2230
|
+
* @returns Throttle ID for use in other throttle methods
|
|
2231
|
+
*
|
|
2232
|
+
* @example
|
|
2233
|
+
* ```typescript
|
|
2234
|
+
* const throttleId = await client.acquireThrottle({ address: 3 });
|
|
2235
|
+
* await client.setThrottleSpeed(throttleId, 0.5); // Half speed
|
|
2236
|
+
* ```
|
|
2237
|
+
*/
|
|
2238
|
+
async acquireThrottle(options) {
|
|
2239
|
+
return this.throttleManager.acquireThrottle(options);
|
|
2240
|
+
}
|
|
2241
|
+
/**
|
|
2242
|
+
* Release a throttle
|
|
2243
|
+
*/
|
|
2244
|
+
async releaseThrottle(throttleId) {
|
|
2245
|
+
return this.throttleManager.releaseThrottle(throttleId);
|
|
2246
|
+
}
|
|
2247
|
+
/**
|
|
2248
|
+
* Set throttle speed (0.0 to 1.0)
|
|
2249
|
+
*
|
|
2250
|
+
* @param throttleId - Throttle ID from acquireThrottle
|
|
2251
|
+
* @param speed - Speed value between 0.0 (stopped) and 1.0 (full speed)
|
|
2252
|
+
*/
|
|
2253
|
+
async setThrottleSpeed(throttleId, speed) {
|
|
2254
|
+
return this.throttleManager.setSpeed(throttleId, speed);
|
|
2255
|
+
}
|
|
2256
|
+
/**
|
|
2257
|
+
* Set throttle direction
|
|
2258
|
+
*
|
|
2259
|
+
* @param throttleId - Throttle ID from acquireThrottle
|
|
2260
|
+
* @param forward - True for forward, false for reverse
|
|
2261
|
+
*/
|
|
2262
|
+
async setThrottleDirection(throttleId, forward) {
|
|
2263
|
+
return this.throttleManager.setDirection(throttleId, forward);
|
|
2264
|
+
}
|
|
2265
|
+
/**
|
|
2266
|
+
* Set throttle function (F0-F28)
|
|
2267
|
+
*
|
|
2268
|
+
* @param throttleId - Throttle ID from acquireThrottle
|
|
2269
|
+
* @param functionKey - Function key (F0-F28)
|
|
2270
|
+
* @param value - True to activate, false to deactivate
|
|
2271
|
+
*
|
|
2272
|
+
* @example
|
|
2273
|
+
* ```typescript
|
|
2274
|
+
* await client.setThrottleFunction(throttleId, 'F0', true); // Headlight on
|
|
2275
|
+
* await client.setThrottleFunction(throttleId, 'F2', true); // Horn
|
|
2276
|
+
* ```
|
|
2277
|
+
*/
|
|
2278
|
+
async setThrottleFunction(throttleId, functionKey, value) {
|
|
2279
|
+
return this.throttleManager.setFunction(throttleId, functionKey, value);
|
|
2280
|
+
}
|
|
2281
|
+
/**
|
|
2282
|
+
* Emergency stop for a throttle (speed to 0)
|
|
2283
|
+
*/
|
|
2284
|
+
async emergencyStop(throttleId) {
|
|
2285
|
+
return this.throttleManager.emergencyStop(throttleId);
|
|
2286
|
+
}
|
|
2287
|
+
/**
|
|
2288
|
+
* Set throttle to idle (speed to 0, maintain direction)
|
|
2289
|
+
*/
|
|
2290
|
+
async idleThrottle(throttleId) {
|
|
2291
|
+
return this.throttleManager.idle(throttleId);
|
|
2292
|
+
}
|
|
2293
|
+
/**
|
|
2294
|
+
* Get throttle state
|
|
2295
|
+
*/
|
|
2296
|
+
getThrottleState(throttleId) {
|
|
2297
|
+
return this.throttleManager.getThrottleState(throttleId);
|
|
2298
|
+
}
|
|
2299
|
+
/**
|
|
2300
|
+
* Get all throttle IDs
|
|
2301
|
+
*/
|
|
2302
|
+
getThrottleIds() {
|
|
2303
|
+
return this.throttleManager.getThrottleIds();
|
|
2304
|
+
}
|
|
2305
|
+
/**
|
|
2306
|
+
* Get all throttle states
|
|
2307
|
+
*/
|
|
2308
|
+
getAllThrottles() {
|
|
2309
|
+
return this.throttleManager.getAllThrottles();
|
|
2310
|
+
}
|
|
2311
|
+
/**
|
|
2312
|
+
* Release all throttles
|
|
2313
|
+
*/
|
|
2314
|
+
async releaseAllThrottles() {
|
|
2315
|
+
return this.throttleManager.releaseAllThrottles();
|
|
2316
|
+
}
|
|
2317
|
+
};
|
|
2318
|
+
export {
|
|
2319
|
+
ConnectionState,
|
|
2320
|
+
JmriClient,
|
|
2321
|
+
LightState,
|
|
2322
|
+
MockResponseManager,
|
|
2323
|
+
PowerState,
|
|
2324
|
+
TurnoutState,
|
|
2325
|
+
WebSocketClient,
|
|
2326
|
+
isThrottleFunctionKey,
|
|
2327
|
+
isValidSpeed,
|
|
2328
|
+
lightStateToString,
|
|
2329
|
+
mockData,
|
|
2330
|
+
mockResponseManager,
|
|
2331
|
+
powerStateToString,
|
|
2332
|
+
turnoutStateToString
|
|
2333
|
+
};
|