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
|
@@ -1,384 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Core WebSocket client for JMRI communication
|
|
3
|
-
*/
|
|
4
|
-
import { EventEmitter } from 'eventemitter3';
|
|
5
|
-
import { createWebSocketAdapter } from './websocket-adapter.js';
|
|
6
|
-
import { ConnectionState } from '../types/events.js';
|
|
7
|
-
import { MessageIdGenerator } from '../utils/message-id.js';
|
|
8
|
-
import { MessageQueue } from './message-queue.js';
|
|
9
|
-
import { ConnectionStateManager } from './connection-state-manager.js';
|
|
10
|
-
import { HeartbeatManager } from './heartbeat-manager.js';
|
|
11
|
-
import { ReconnectionManager } from './reconnection-manager.js';
|
|
12
|
-
import { MockResponseManager } from '../mocks/index.js';
|
|
13
|
-
/**
|
|
14
|
-
* Core WebSocket client
|
|
15
|
-
*/
|
|
16
|
-
export class WebSocketClient extends EventEmitter {
|
|
17
|
-
constructor(options) {
|
|
18
|
-
super();
|
|
19
|
-
// Request/response tracking
|
|
20
|
-
this.pendingRequests = new Map();
|
|
21
|
-
// Connection state
|
|
22
|
-
this.isManualDisconnect = false;
|
|
23
|
-
this.options = options;
|
|
24
|
-
this.url = `${options.protocol}://${options.host}:${options.port}/json/`;
|
|
25
|
-
// Initialize sub-managers
|
|
26
|
-
this.messageIdGen = new MessageIdGenerator();
|
|
27
|
-
this.messageQueue = new MessageQueue(options.messageQueueSize);
|
|
28
|
-
this.stateManager = new ConnectionStateManager();
|
|
29
|
-
this.heartbeatManager = new HeartbeatManager(options.heartbeat);
|
|
30
|
-
this.reconnectionManager = new ReconnectionManager(options.reconnection);
|
|
31
|
-
// Initialize mock manager if mock mode is enabled
|
|
32
|
-
if (options.mock.enabled) {
|
|
33
|
-
this.mockManager = new MockResponseManager({
|
|
34
|
-
responseDelay: options.mock.responseDelay
|
|
35
|
-
});
|
|
36
|
-
}
|
|
37
|
-
// Wire up state manager events
|
|
38
|
-
this.stateManager.on('stateChanged', (newState, prevState) => {
|
|
39
|
-
this.emit('connectionStateChanged', newState, prevState);
|
|
40
|
-
});
|
|
41
|
-
// Wire up heartbeat events
|
|
42
|
-
this.heartbeatManager.on('timeout', () => {
|
|
43
|
-
this.emit('heartbeat:timeout');
|
|
44
|
-
this.handleHeartbeatTimeout();
|
|
45
|
-
});
|
|
46
|
-
this.heartbeatManager.on('pingSent', () => {
|
|
47
|
-
this.emit('heartbeat:sent');
|
|
48
|
-
});
|
|
49
|
-
// Wire up reconnection events
|
|
50
|
-
this.reconnectionManager.on('attemptScheduled', (attempt, delay) => {
|
|
51
|
-
this.emit('reconnecting', attempt, delay);
|
|
52
|
-
});
|
|
53
|
-
this.reconnectionManager.on('success', () => {
|
|
54
|
-
this.emit('reconnected');
|
|
55
|
-
});
|
|
56
|
-
this.reconnectionManager.on('maxAttemptsReached', (attempts) => {
|
|
57
|
-
this.emit('reconnectionFailed', attempts);
|
|
58
|
-
});
|
|
59
|
-
this.reconnectionManager.on('debug', (message) => {
|
|
60
|
-
this.emit('debug', message);
|
|
61
|
-
});
|
|
62
|
-
}
|
|
63
|
-
/**
|
|
64
|
-
* Connect to JMRI WebSocket server (or mock)
|
|
65
|
-
*/
|
|
66
|
-
async connect() {
|
|
67
|
-
if (this.stateManager.isConnected() || this.stateManager.isConnecting()) {
|
|
68
|
-
return;
|
|
69
|
-
}
|
|
70
|
-
this.isManualDisconnect = false;
|
|
71
|
-
this.stateManager.transition(ConnectionState.CONNECTING);
|
|
72
|
-
// Mock mode - simulate connection
|
|
73
|
-
if (this.mockManager) {
|
|
74
|
-
return this.connectMock();
|
|
75
|
-
}
|
|
76
|
-
// Real WebSocket connection
|
|
77
|
-
return new Promise(async (resolve, reject) => {
|
|
78
|
-
try {
|
|
79
|
-
this.ws = await createWebSocketAdapter(this.url);
|
|
80
|
-
this.ws.on('open', () => {
|
|
81
|
-
this.handleOpen();
|
|
82
|
-
resolve();
|
|
83
|
-
});
|
|
84
|
-
this.ws.on('message', (data) => {
|
|
85
|
-
this.handleMessage(data);
|
|
86
|
-
});
|
|
87
|
-
this.ws.on('close', (code, reason) => {
|
|
88
|
-
// If close happens during connection attempt, treat as connection failure
|
|
89
|
-
if (this.stateManager.isConnecting()) {
|
|
90
|
-
// Transition to disconnected state so reconnection can proceed
|
|
91
|
-
this.stateManager.transition(ConnectionState.DISCONNECTED);
|
|
92
|
-
const error = new Error(`WebSocket connection failed (code: ${code}${reason ? ', reason: ' + reason : ''})`);
|
|
93
|
-
this.emit('error', error);
|
|
94
|
-
reject(error);
|
|
95
|
-
// Still need to handle close to trigger reconnection
|
|
96
|
-
this.handleClose(code, reason);
|
|
97
|
-
}
|
|
98
|
-
else {
|
|
99
|
-
this.handleClose(code, reason);
|
|
100
|
-
}
|
|
101
|
-
});
|
|
102
|
-
this.ws.on('error', (error) => {
|
|
103
|
-
this.emit('error', error);
|
|
104
|
-
if (this.stateManager.isConnecting()) {
|
|
105
|
-
this.stateManager.transition(ConnectionState.DISCONNECTED);
|
|
106
|
-
reject(error);
|
|
107
|
-
}
|
|
108
|
-
});
|
|
109
|
-
}
|
|
110
|
-
catch (error) {
|
|
111
|
-
this.stateManager.transition(ConnectionState.DISCONNECTED);
|
|
112
|
-
reject(error);
|
|
113
|
-
}
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
/**
|
|
117
|
-
* Simulate connection in mock mode
|
|
118
|
-
*/
|
|
119
|
-
async connectMock() {
|
|
120
|
-
// Simulate connection delay
|
|
121
|
-
await this.delay(10);
|
|
122
|
-
// Transition to connected state
|
|
123
|
-
this.handleOpen();
|
|
124
|
-
// Send hello message in mock mode
|
|
125
|
-
const helloResponse = await this.mockManager.getMockResponse({ type: 'hello' });
|
|
126
|
-
if (helloResponse) {
|
|
127
|
-
this.processMessage(helloResponse);
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
/**
|
|
131
|
-
* Disconnect from JMRI WebSocket server
|
|
132
|
-
*/
|
|
133
|
-
async disconnect() {
|
|
134
|
-
// Already disconnected, nothing to do
|
|
135
|
-
if (this.stateManager.isDisconnected()) {
|
|
136
|
-
return;
|
|
137
|
-
}
|
|
138
|
-
this.isManualDisconnect = true;
|
|
139
|
-
this.reconnectionManager.stop();
|
|
140
|
-
this.heartbeatManager.stop();
|
|
141
|
-
// Send goodbye message
|
|
142
|
-
if (this.stateManager.isConnected()) {
|
|
143
|
-
try {
|
|
144
|
-
await this.sendGoodbye();
|
|
145
|
-
}
|
|
146
|
-
catch (error) {
|
|
147
|
-
// Ignore errors during goodbye
|
|
148
|
-
}
|
|
149
|
-
}
|
|
150
|
-
// Close WebSocket
|
|
151
|
-
if (this.ws) {
|
|
152
|
-
this.ws.close();
|
|
153
|
-
this.ws = undefined;
|
|
154
|
-
}
|
|
155
|
-
// Reject all pending requests
|
|
156
|
-
this.rejectAllPendingRequests(new Error('Client disconnected'));
|
|
157
|
-
if (!this.stateManager.isDisconnected()) {
|
|
158
|
-
this.stateManager.transition(ConnectionState.DISCONNECTED);
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
/**
|
|
162
|
-
* Send message to JMRI (or mock)
|
|
163
|
-
*/
|
|
164
|
-
send(message) {
|
|
165
|
-
if (!this.stateManager.isConnected()) {
|
|
166
|
-
// Queue message for later
|
|
167
|
-
this.messageQueue.enqueue(message);
|
|
168
|
-
return;
|
|
169
|
-
}
|
|
170
|
-
// Mock mode - send doesn't need to do anything
|
|
171
|
-
// Responses are generated in request()
|
|
172
|
-
if (this.mockManager) {
|
|
173
|
-
this.emit('message:sent', message);
|
|
174
|
-
return;
|
|
175
|
-
}
|
|
176
|
-
if (!this.ws) {
|
|
177
|
-
throw new Error('WebSocket not initialized');
|
|
178
|
-
}
|
|
179
|
-
const json = JSON.stringify(message);
|
|
180
|
-
this.ws.send(json);
|
|
181
|
-
this.emit('message:sent', message);
|
|
182
|
-
}
|
|
183
|
-
/**
|
|
184
|
-
* Send request and wait for response (or get mock response)
|
|
185
|
-
*/
|
|
186
|
-
async request(message, timeout) {
|
|
187
|
-
// Mock mode - get response from mock manager
|
|
188
|
-
if (this.mockManager) {
|
|
189
|
-
const response = await this.mockManager.getMockResponse(message);
|
|
190
|
-
this.emit('message:sent', message);
|
|
191
|
-
if (response) {
|
|
192
|
-
this.processMessage(response);
|
|
193
|
-
}
|
|
194
|
-
return response;
|
|
195
|
-
}
|
|
196
|
-
// Real mode - send request and wait for response
|
|
197
|
-
// Assign message ID
|
|
198
|
-
const id = this.messageIdGen.next();
|
|
199
|
-
message.id = id;
|
|
200
|
-
return new Promise((resolve, reject) => {
|
|
201
|
-
// Set up timeout
|
|
202
|
-
const timeoutMs = timeout || this.options.requestTimeout;
|
|
203
|
-
const timeoutHandle = setTimeout(() => {
|
|
204
|
-
this.pendingRequests.delete(id);
|
|
205
|
-
reject(new Error(`Request timeout after ${timeoutMs}ms`));
|
|
206
|
-
}, timeoutMs);
|
|
207
|
-
// Track pending request with metadata for matching responses without IDs
|
|
208
|
-
const pendingRequest = {
|
|
209
|
-
resolve,
|
|
210
|
-
reject,
|
|
211
|
-
timeout: timeoutHandle,
|
|
212
|
-
messageType: message.type
|
|
213
|
-
};
|
|
214
|
-
// For throttle requests, store the throttle name for matching
|
|
215
|
-
if (message.type === 'throttle' && message.data && 'name' in message.data) {
|
|
216
|
-
pendingRequest.matchKey = message.data.name;
|
|
217
|
-
}
|
|
218
|
-
this.pendingRequests.set(id, pendingRequest);
|
|
219
|
-
// Send message
|
|
220
|
-
try {
|
|
221
|
-
this.send(message);
|
|
222
|
-
}
|
|
223
|
-
catch (error) {
|
|
224
|
-
this.pendingRequests.delete(id);
|
|
225
|
-
clearTimeout(timeoutHandle);
|
|
226
|
-
reject(error);
|
|
227
|
-
}
|
|
228
|
-
});
|
|
229
|
-
}
|
|
230
|
-
/**
|
|
231
|
-
* Get current connection state
|
|
232
|
-
*/
|
|
233
|
-
getState() {
|
|
234
|
-
return this.stateManager.getState();
|
|
235
|
-
}
|
|
236
|
-
/**
|
|
237
|
-
* Check if connected
|
|
238
|
-
*/
|
|
239
|
-
isConnected() {
|
|
240
|
-
return this.stateManager.isConnected();
|
|
241
|
-
}
|
|
242
|
-
/**
|
|
243
|
-
* Handle WebSocket open event
|
|
244
|
-
*/
|
|
245
|
-
handleOpen() {
|
|
246
|
-
this.stateManager.transition(ConnectionState.CONNECTED);
|
|
247
|
-
this.emit('connected');
|
|
248
|
-
// Start heartbeat
|
|
249
|
-
if (this.options.heartbeat.enabled) {
|
|
250
|
-
this.heartbeatManager.start(() => this.sendPing());
|
|
251
|
-
}
|
|
252
|
-
// Flush queued messages
|
|
253
|
-
const queuedMessages = this.messageQueue.flush();
|
|
254
|
-
for (const message of queuedMessages) {
|
|
255
|
-
this.send(message);
|
|
256
|
-
}
|
|
257
|
-
}
|
|
258
|
-
/**
|
|
259
|
-
* Handle WebSocket message event
|
|
260
|
-
*/
|
|
261
|
-
handleMessage(data) {
|
|
262
|
-
try {
|
|
263
|
-
const message = JSON.parse(data);
|
|
264
|
-
this.processMessage(message);
|
|
265
|
-
}
|
|
266
|
-
catch (error) {
|
|
267
|
-
this.emit('error', new Error(`Failed to parse message: ${error}`));
|
|
268
|
-
}
|
|
269
|
-
}
|
|
270
|
-
/**
|
|
271
|
-
* Process a parsed message (called by both real and mock mode)
|
|
272
|
-
*/
|
|
273
|
-
processMessage(message) {
|
|
274
|
-
this.emit('message:received', message);
|
|
275
|
-
// Handle pong
|
|
276
|
-
if (message.type === 'pong') {
|
|
277
|
-
this.heartbeatManager.receivedPong();
|
|
278
|
-
return;
|
|
279
|
-
}
|
|
280
|
-
// Handle hello
|
|
281
|
-
if (message.type === 'hello') {
|
|
282
|
-
this.emit('hello', message.data);
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
// Handle response to request
|
|
286
|
-
if (message.id !== undefined) {
|
|
287
|
-
const pending = this.pendingRequests.get(message.id);
|
|
288
|
-
if (pending) {
|
|
289
|
-
clearTimeout(pending.timeout);
|
|
290
|
-
this.pendingRequests.delete(message.id);
|
|
291
|
-
pending.resolve(message);
|
|
292
|
-
return;
|
|
293
|
-
}
|
|
294
|
-
}
|
|
295
|
-
// Handle responses without ID (like throttle responses from JMRI)
|
|
296
|
-
// Match by message type and data
|
|
297
|
-
if (message.id === undefined) {
|
|
298
|
-
for (const [id, pending] of this.pendingRequests.entries()) {
|
|
299
|
-
// Match by type
|
|
300
|
-
if (pending.messageType === message.type) {
|
|
301
|
-
// For throttle messages, also match by throttle name
|
|
302
|
-
if (message.type === 'throttle' && pending.matchKey) {
|
|
303
|
-
const throttleName = message.data?.throttle || message.data?.name;
|
|
304
|
-
if (throttleName === pending.matchKey) {
|
|
305
|
-
clearTimeout(pending.timeout);
|
|
306
|
-
this.pendingRequests.delete(id);
|
|
307
|
-
pending.resolve(message);
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
310
|
-
}
|
|
311
|
-
else {
|
|
312
|
-
// For other message types, just match by type
|
|
313
|
-
clearTimeout(pending.timeout);
|
|
314
|
-
this.pendingRequests.delete(id);
|
|
315
|
-
pending.resolve(message);
|
|
316
|
-
return;
|
|
317
|
-
}
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
// Handle unsolicited updates (auto-subscriptions)
|
|
322
|
-
this.emit('update', message);
|
|
323
|
-
}
|
|
324
|
-
/**
|
|
325
|
-
* Handle WebSocket close event
|
|
326
|
-
*/
|
|
327
|
-
handleClose(code, reason) {
|
|
328
|
-
this.heartbeatManager.stop();
|
|
329
|
-
const wasConnected = this.stateManager.isConnected();
|
|
330
|
-
const isReconnecting = this.reconnectionManager.reconnecting();
|
|
331
|
-
if (this.stateManager.isConnected() || this.stateManager.isConnecting()) {
|
|
332
|
-
this.stateManager.transition(ConnectionState.DISCONNECTED);
|
|
333
|
-
}
|
|
334
|
-
this.emit('disconnected', reason || `Connection closed (code: ${code})`);
|
|
335
|
-
// Reject all pending requests
|
|
336
|
-
this.rejectAllPendingRequests(new Error('Connection closed'));
|
|
337
|
-
// Attempt reconnection if not manual disconnect
|
|
338
|
-
// Continue reconnecting if we were connected OR reconnection manager is already active
|
|
339
|
-
if (!this.isManualDisconnect && (wasConnected || isReconnecting) && this.options.reconnection.enabled) {
|
|
340
|
-
this.stateManager.forceState(ConnectionState.RECONNECTING);
|
|
341
|
-
this.reconnectionManager.start(() => this.connect());
|
|
342
|
-
}
|
|
343
|
-
}
|
|
344
|
-
/**
|
|
345
|
-
* Handle heartbeat timeout
|
|
346
|
-
*/
|
|
347
|
-
handleHeartbeatTimeout() {
|
|
348
|
-
// Connection appears dead, force reconnect
|
|
349
|
-
if (this.ws) {
|
|
350
|
-
this.ws.close();
|
|
351
|
-
}
|
|
352
|
-
}
|
|
353
|
-
/**
|
|
354
|
-
* Send ping message
|
|
355
|
-
*/
|
|
356
|
-
sendPing() {
|
|
357
|
-
this.send({ type: 'ping' });
|
|
358
|
-
}
|
|
359
|
-
/**
|
|
360
|
-
* Send goodbye message
|
|
361
|
-
*/
|
|
362
|
-
async sendGoodbye() {
|
|
363
|
-
const message = { type: 'goodbye' };
|
|
364
|
-
this.send(message);
|
|
365
|
-
// Give it a moment to send
|
|
366
|
-
await new Promise(resolve => setTimeout(resolve, 100));
|
|
367
|
-
}
|
|
368
|
-
/**
|
|
369
|
-
* Reject all pending requests
|
|
370
|
-
*/
|
|
371
|
-
rejectAllPendingRequests(error) {
|
|
372
|
-
for (const pending of this.pendingRequests.values()) {
|
|
373
|
-
clearTimeout(pending.timeout);
|
|
374
|
-
pending.reject(error);
|
|
375
|
-
}
|
|
376
|
-
this.pendingRequests.clear();
|
|
377
|
-
}
|
|
378
|
-
/**
|
|
379
|
-
* Delay helper
|
|
380
|
-
*/
|
|
381
|
-
delay(ms) {
|
|
382
|
-
return new Promise(resolve => setTimeout(resolve, ms));
|
|
383
|
-
}
|
|
384
|
-
}
|
|
@@ -1,9 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Manager exports
|
|
3
|
-
*/
|
|
4
|
-
export * from './power-manager.js';
|
|
5
|
-
export * from './roster-manager.js';
|
|
6
|
-
export * from './throttle-manager.js';
|
|
7
|
-
export * from './turnout-manager.js';
|
|
8
|
-
export * from './light-manager.js';
|
|
9
|
-
export * from './system-connections-manager.js';
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Light manager
|
|
3
|
-
*/
|
|
4
|
-
import { EventEmitter } from 'eventemitter3';
|
|
5
|
-
import { LightState } from '../types/jmri-messages.js';
|
|
6
|
-
/**
|
|
7
|
-
* Manages JMRI light state
|
|
8
|
-
*/
|
|
9
|
-
export class LightManager extends EventEmitter {
|
|
10
|
-
constructor(client) {
|
|
11
|
-
super();
|
|
12
|
-
this.lights = new Map();
|
|
13
|
-
this.client = client;
|
|
14
|
-
this.client.on('update', (message) => {
|
|
15
|
-
if (message.type === 'light') {
|
|
16
|
-
this.handleLightUpdate(message);
|
|
17
|
-
}
|
|
18
|
-
});
|
|
19
|
-
}
|
|
20
|
-
/**
|
|
21
|
-
* Get the current state of a light.
|
|
22
|
-
* Also registers a server-side listener so subsequent changes are pushed.
|
|
23
|
-
*/
|
|
24
|
-
async getLight(name) {
|
|
25
|
-
const message = {
|
|
26
|
-
type: 'light',
|
|
27
|
-
data: { name }
|
|
28
|
-
};
|
|
29
|
-
const response = await this.client.request(message);
|
|
30
|
-
const state = response.data?.state ?? LightState.UNKNOWN;
|
|
31
|
-
this.lights.set(name, state);
|
|
32
|
-
return state;
|
|
33
|
-
}
|
|
34
|
-
/**
|
|
35
|
-
* Set a light to the given state
|
|
36
|
-
*/
|
|
37
|
-
async setLight(name, state) {
|
|
38
|
-
const message = {
|
|
39
|
-
type: 'light',
|
|
40
|
-
method: 'post',
|
|
41
|
-
data: { name, state }
|
|
42
|
-
};
|
|
43
|
-
await this.client.request(message);
|
|
44
|
-
const oldState = this.lights.get(name);
|
|
45
|
-
this.lights.set(name, state);
|
|
46
|
-
if (oldState !== state) {
|
|
47
|
-
this.emit('light:changed', name, state);
|
|
48
|
-
}
|
|
49
|
-
}
|
|
50
|
-
/**
|
|
51
|
-
* Turn a light on
|
|
52
|
-
*/
|
|
53
|
-
async turnOnLight(name) {
|
|
54
|
-
return this.setLight(name, LightState.ON);
|
|
55
|
-
}
|
|
56
|
-
/**
|
|
57
|
-
* Turn a light off
|
|
58
|
-
*/
|
|
59
|
-
async turnOffLight(name) {
|
|
60
|
-
return this.setLight(name, LightState.OFF);
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* List all lights known to JMRI
|
|
64
|
-
*/
|
|
65
|
-
async listLights() {
|
|
66
|
-
const message = {
|
|
67
|
-
type: 'light',
|
|
68
|
-
method: 'list'
|
|
69
|
-
};
|
|
70
|
-
const response = await this.client.request(message);
|
|
71
|
-
const entries = Array.isArray(response?.data)
|
|
72
|
-
? response.data.map((r) => r.data ?? r)
|
|
73
|
-
: [];
|
|
74
|
-
for (const entry of entries) {
|
|
75
|
-
if (entry.name && entry.state !== undefined) {
|
|
76
|
-
this.lights.set(entry.name, entry.state);
|
|
77
|
-
}
|
|
78
|
-
}
|
|
79
|
-
return entries;
|
|
80
|
-
}
|
|
81
|
-
/**
|
|
82
|
-
* Get cached light state without a network request
|
|
83
|
-
*/
|
|
84
|
-
getLightState(name) {
|
|
85
|
-
return this.lights.get(name);
|
|
86
|
-
}
|
|
87
|
-
/**
|
|
88
|
-
* Get all cached light states
|
|
89
|
-
*/
|
|
90
|
-
getCachedLights() {
|
|
91
|
-
return new Map(this.lights);
|
|
92
|
-
}
|
|
93
|
-
/**
|
|
94
|
-
* Handle unsolicited light state updates from JMRI
|
|
95
|
-
*/
|
|
96
|
-
handleLightUpdate(message) {
|
|
97
|
-
const name = message.data?.name;
|
|
98
|
-
const state = message.data?.state;
|
|
99
|
-
if (!name || state === undefined)
|
|
100
|
-
return;
|
|
101
|
-
const oldState = this.lights.get(name);
|
|
102
|
-
this.lights.set(name, state);
|
|
103
|
-
if (oldState !== state) {
|
|
104
|
-
this.emit('light:changed', name, state);
|
|
105
|
-
}
|
|
106
|
-
}
|
|
107
|
-
}
|
|
@@ -1,86 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Power control manager
|
|
3
|
-
*/
|
|
4
|
-
import { EventEmitter } from 'eventemitter3';
|
|
5
|
-
import { PowerState } from '../types/jmri-messages.js';
|
|
6
|
-
/**
|
|
7
|
-
* Manages track power control
|
|
8
|
-
*/
|
|
9
|
-
export class PowerManager extends EventEmitter {
|
|
10
|
-
constructor(client) {
|
|
11
|
-
super();
|
|
12
|
-
this.currentState = PowerState.UNKNOWN;
|
|
13
|
-
this.client = client;
|
|
14
|
-
// Listen for power updates
|
|
15
|
-
this.client.on('update', (message) => {
|
|
16
|
-
if (message.type === 'power') {
|
|
17
|
-
this.handlePowerUpdate(message);
|
|
18
|
-
}
|
|
19
|
-
});
|
|
20
|
-
}
|
|
21
|
-
/**
|
|
22
|
-
* Get current track power state
|
|
23
|
-
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
24
|
-
*/
|
|
25
|
-
async getPower(prefix) {
|
|
26
|
-
const message = {
|
|
27
|
-
type: 'power',
|
|
28
|
-
...(prefix !== undefined && { data: { state: PowerState.UNKNOWN, prefix } })
|
|
29
|
-
};
|
|
30
|
-
const response = await this.client.request(message);
|
|
31
|
-
if (response.data?.state !== undefined) {
|
|
32
|
-
this.currentState = response.data.state;
|
|
33
|
-
}
|
|
34
|
-
return this.currentState;
|
|
35
|
-
}
|
|
36
|
-
/**
|
|
37
|
-
* Set track power state
|
|
38
|
-
* @param state - The desired power state
|
|
39
|
-
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
40
|
-
*/
|
|
41
|
-
async setPower(state, prefix) {
|
|
42
|
-
const message = {
|
|
43
|
-
type: 'power',
|
|
44
|
-
method: 'post',
|
|
45
|
-
data: { state, ...(prefix !== undefined && { prefix }) }
|
|
46
|
-
};
|
|
47
|
-
await this.client.request(message);
|
|
48
|
-
const oldState = this.currentState;
|
|
49
|
-
this.currentState = state;
|
|
50
|
-
if (oldState !== this.currentState) {
|
|
51
|
-
this.emit('power:changed', this.currentState);
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
/**
|
|
55
|
-
* Turn track power on
|
|
56
|
-
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
57
|
-
*/
|
|
58
|
-
async powerOn(prefix) {
|
|
59
|
-
await this.setPower(PowerState.ON, prefix);
|
|
60
|
-
}
|
|
61
|
-
/**
|
|
62
|
-
* Turn track power off
|
|
63
|
-
* @param prefix - Optional JMRI connection prefix to target a specific hardware connection
|
|
64
|
-
*/
|
|
65
|
-
async powerOff(prefix) {
|
|
66
|
-
await this.setPower(PowerState.OFF, prefix);
|
|
67
|
-
}
|
|
68
|
-
/**
|
|
69
|
-
* Get cached power state (no network request)
|
|
70
|
-
*/
|
|
71
|
-
getCachedState() {
|
|
72
|
-
return this.currentState;
|
|
73
|
-
}
|
|
74
|
-
/**
|
|
75
|
-
* Handle unsolicited power updates from JMRI
|
|
76
|
-
*/
|
|
77
|
-
handlePowerUpdate(message) {
|
|
78
|
-
if (message.data?.state !== undefined) {
|
|
79
|
-
const oldState = this.currentState;
|
|
80
|
-
this.currentState = message.data.state;
|
|
81
|
-
if (oldState !== this.currentState) {
|
|
82
|
-
this.emit('power:changed', this.currentState);
|
|
83
|
-
}
|
|
84
|
-
}
|
|
85
|
-
}
|
|
86
|
-
}
|
|
@@ -1,114 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Roster management
|
|
3
|
-
*/
|
|
4
|
-
/**
|
|
5
|
-
* Manages locomotive roster
|
|
6
|
-
*/
|
|
7
|
-
export class RosterManager {
|
|
8
|
-
constructor(client) {
|
|
9
|
-
this.rosterCache = new Map();
|
|
10
|
-
this.client = client;
|
|
11
|
-
}
|
|
12
|
-
/**
|
|
13
|
-
* Get all roster entries
|
|
14
|
-
*/
|
|
15
|
-
async getRoster() {
|
|
16
|
-
const message = {
|
|
17
|
-
type: 'roster',
|
|
18
|
-
method: 'list'
|
|
19
|
-
};
|
|
20
|
-
const response = await this.client.request(message);
|
|
21
|
-
// Parse roster data
|
|
22
|
-
if (response.data) {
|
|
23
|
-
this.updateCache(response.data);
|
|
24
|
-
}
|
|
25
|
-
return Array.from(this.rosterCache.values());
|
|
26
|
-
}
|
|
27
|
-
/**
|
|
28
|
-
* Get roster entry by name
|
|
29
|
-
*/
|
|
30
|
-
async getRosterEntryByName(name) {
|
|
31
|
-
// Check cache first
|
|
32
|
-
if (this.rosterCache.has(name)) {
|
|
33
|
-
return this.rosterCache.get(name);
|
|
34
|
-
}
|
|
35
|
-
// Refresh roster
|
|
36
|
-
await this.getRoster();
|
|
37
|
-
return this.rosterCache.get(name);
|
|
38
|
-
}
|
|
39
|
-
/**
|
|
40
|
-
* Get roster entry by address
|
|
41
|
-
*/
|
|
42
|
-
async getRosterEntryByAddress(address) {
|
|
43
|
-
// Ensure roster is loaded
|
|
44
|
-
if (this.rosterCache.size === 0) {
|
|
45
|
-
await this.getRoster();
|
|
46
|
-
}
|
|
47
|
-
const addressStr = address.toString();
|
|
48
|
-
for (const wrapper of this.rosterCache.values()) {
|
|
49
|
-
if (wrapper.data.address === addressStr) {
|
|
50
|
-
return wrapper;
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
return undefined;
|
|
54
|
-
}
|
|
55
|
-
/**
|
|
56
|
-
* Search roster by partial name match
|
|
57
|
-
*/
|
|
58
|
-
async searchRoster(query) {
|
|
59
|
-
// Ensure roster is loaded
|
|
60
|
-
if (this.rosterCache.size === 0) {
|
|
61
|
-
await this.getRoster();
|
|
62
|
-
}
|
|
63
|
-
const lowerQuery = query.toLowerCase();
|
|
64
|
-
const results = [];
|
|
65
|
-
for (const wrapper of this.rosterCache.values()) {
|
|
66
|
-
const entry = wrapper.data;
|
|
67
|
-
if (entry.name.toLowerCase().includes(lowerQuery) ||
|
|
68
|
-
entry.address.includes(query) ||
|
|
69
|
-
entry.road?.toLowerCase().includes(lowerQuery) ||
|
|
70
|
-
entry.number?.includes(query)) {
|
|
71
|
-
results.push(wrapper);
|
|
72
|
-
}
|
|
73
|
-
}
|
|
74
|
-
return results;
|
|
75
|
-
}
|
|
76
|
-
/**
|
|
77
|
-
* Get cached roster (no network request)
|
|
78
|
-
*/
|
|
79
|
-
getCachedRoster() {
|
|
80
|
-
return Array.from(this.rosterCache.values());
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* Clear roster cache
|
|
84
|
-
*/
|
|
85
|
-
clearCache() {
|
|
86
|
-
this.rosterCache.clear();
|
|
87
|
-
}
|
|
88
|
-
/**
|
|
89
|
-
* Update internal cache from roster data
|
|
90
|
-
*/
|
|
91
|
-
updateCache(rosterData) {
|
|
92
|
-
this.rosterCache.clear();
|
|
93
|
-
// Handle array format (real JMRI server) - store wrapped entries
|
|
94
|
-
if (Array.isArray(rosterData)) {
|
|
95
|
-
for (const wrapper of rosterData) {
|
|
96
|
-
if (wrapper.type === 'rosterEntry' && wrapper.data) {
|
|
97
|
-
this.rosterCache.set(wrapper.data.name, wrapper);
|
|
98
|
-
}
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
// Handle legacy keyed object format (for backward compatibility) - wrap entries
|
|
102
|
-
else {
|
|
103
|
-
let id = 1;
|
|
104
|
-
for (const [name, entry] of Object.entries(rosterData)) {
|
|
105
|
-
const wrapper = {
|
|
106
|
-
type: 'rosterEntry',
|
|
107
|
-
data: entry,
|
|
108
|
-
id: id++
|
|
109
|
-
};
|
|
110
|
-
this.rosterCache.set(name, wrapper);
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
}
|
|
114
|
-
}
|