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.
Files changed (58) hide show
  1. package/README.md +3 -1
  2. package/dist/cjs/index.js +2382 -31
  3. package/dist/esm/index.js +2333 -17
  4. package/docs/BROWSER.md +4 -4
  5. package/docs/MIGRATION.md +30 -1
  6. package/package.json +17 -18
  7. package/dist/cjs/client.js +0 -366
  8. package/dist/cjs/core/connection-state-manager.js +0 -84
  9. package/dist/cjs/core/heartbeat-manager.js +0 -79
  10. package/dist/cjs/core/index.js +0 -25
  11. package/dist/cjs/core/message-queue.js +0 -59
  12. package/dist/cjs/core/reconnection-manager.js +0 -97
  13. package/dist/cjs/core/websocket-adapter.js +0 -135
  14. package/dist/cjs/core/websocket-client.js +0 -388
  15. package/dist/cjs/managers/index.js +0 -25
  16. package/dist/cjs/managers/light-manager.js +0 -111
  17. package/dist/cjs/managers/power-manager.js +0 -90
  18. package/dist/cjs/managers/roster-manager.js +0 -118
  19. package/dist/cjs/managers/system-connections-manager.js +0 -28
  20. package/dist/cjs/managers/throttle-manager.js +0 -233
  21. package/dist/cjs/managers/turnout-manager.js +0 -111
  22. package/dist/cjs/mocks/index.js +0 -12
  23. package/dist/cjs/mocks/mock-data.js +0 -237
  24. package/dist/cjs/mocks/mock-response-manager.js +0 -290
  25. package/dist/cjs/types/client-options.js +0 -66
  26. package/dist/cjs/types/events.js +0 -16
  27. package/dist/cjs/types/index.js +0 -23
  28. package/dist/cjs/types/jmri-messages.js +0 -95
  29. package/dist/cjs/types/throttle.js +0 -19
  30. package/dist/cjs/utils/exponential-backoff.js +0 -40
  31. package/dist/cjs/utils/index.js +0 -21
  32. package/dist/cjs/utils/message-id.js +0 -40
  33. package/dist/esm/client.js +0 -362
  34. package/dist/esm/core/connection-state-manager.js +0 -80
  35. package/dist/esm/core/heartbeat-manager.js +0 -75
  36. package/dist/esm/core/index.js +0 -9
  37. package/dist/esm/core/message-queue.js +0 -55
  38. package/dist/esm/core/reconnection-manager.js +0 -93
  39. package/dist/esm/core/websocket-adapter.js +0 -98
  40. package/dist/esm/core/websocket-client.js +0 -384
  41. package/dist/esm/managers/index.js +0 -9
  42. package/dist/esm/managers/light-manager.js +0 -107
  43. package/dist/esm/managers/power-manager.js +0 -86
  44. package/dist/esm/managers/roster-manager.js +0 -114
  45. package/dist/esm/managers/system-connections-manager.js +0 -24
  46. package/dist/esm/managers/throttle-manager.js +0 -229
  47. package/dist/esm/managers/turnout-manager.js +0 -107
  48. package/dist/esm/mocks/index.js +0 -6
  49. package/dist/esm/mocks/mock-data.js +0 -234
  50. package/dist/esm/mocks/mock-response-manager.js +0 -286
  51. package/dist/esm/types/client-options.js +0 -62
  52. package/dist/esm/types/events.js +0 -13
  53. package/dist/esm/types/index.js +0 -7
  54. package/dist/esm/types/jmri-messages.js +0 -89
  55. package/dist/esm/types/throttle.js +0 -15
  56. package/dist/esm/utils/exponential-backoff.js +0 -36
  57. package/dist/esm/utils/index.js +0 -5
  58. 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
- }