icom-wlan-node 0.1.0 → 0.2.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.
@@ -38,6 +38,7 @@ const events_1 = require("events");
38
38
  const IcomPackets_1 = require("../core/IcomPackets");
39
39
  const debug_1 = require("../utils/debug");
40
40
  const Session_1 = require("../core/Session");
41
+ const types_1 = require("../types");
41
42
  const IcomCiv_1 = require("./IcomCiv");
42
43
  const IcomAudio_1 = require("./IcomAudio");
43
44
  const IcomRigCommands_1 = require("./IcomRigCommands");
@@ -49,66 +50,397 @@ class IcomControl {
49
50
  this.rigName = '';
50
51
  this.macAddress = Buffer.alloc(6);
51
52
  this.civAssembleBuf = Buffer.alloc(0); // CIV stream reassembler
53
+ // Connection state machine (replaces old fragmented state flags)
54
+ this.connectionSession = {
55
+ phase: types_1.ConnectionPhase.IDLE,
56
+ sessionId: 0,
57
+ startTime: Date.now()
58
+ };
59
+ this.nextSessionId = 1;
60
+ // Map of sessionId -> abort function for cancelling ongoing connection attempts
61
+ this.abortHandlers = new Map();
62
+ this.monitorConfig = {
63
+ timeout: 5000,
64
+ checkInterval: 1000,
65
+ autoReconnect: false,
66
+ maxReconnectAttempts: undefined, // undefined = infinite retries
67
+ reconnectBaseDelay: 2000,
68
+ reconnectMaxDelay: 30000
69
+ };
52
70
  this.options = options;
71
+ // Setup control session
53
72
  this.sess = new Session_1.Session({ ip: options.control.ip, port: options.control.port }, {
54
73
  onData: (data) => this.onData(data),
55
74
  onSendError: (e) => this.ev.emit('error', e)
56
75
  });
57
- // Pre-open local CIV/Audio sessions to obtain local ports before 0x90
58
- this.civSess = new Session_1.Session({ ip: options.control.ip, port: 0 }, { onData: (b) => this.onCivData(b), onSendError: (e) => this.ev.emit('error', e) });
59
- this.audioSess = new Session_1.Session({ ip: options.control.ip, port: 0 }, { onData: (b) => this.onAudioData(b), onSendError: (e) => this.ev.emit('error', e) });
76
+ this.sess.sessionType = types_1.SessionType.CONTROL;
77
+ // Setup CIV session
78
+ this.civSess = new Session_1.Session({ ip: options.control.ip, port: 0 }, {
79
+ onData: (b) => this.onCivData(b),
80
+ onSendError: (e) => this.ev.emit('error', e)
81
+ });
82
+ this.civSess.sessionType = types_1.SessionType.CIV;
60
83
  this.civSess.open();
84
+ // Setup audio session
85
+ this.audioSess = new Session_1.Session({ ip: options.control.ip, port: 0 }, {
86
+ onData: (b) => this.onAudioData(b),
87
+ onSendError: (e) => this.ev.emit('error', e)
88
+ });
89
+ this.audioSess.sessionType = types_1.SessionType.AUDIO;
61
90
  this.audioSess.open();
62
91
  this.civ = new IcomCiv_1.IcomCiv(this.civSess);
63
92
  this.audio = new IcomAudio_1.IcomAudio(this.audioSess);
64
93
  }
65
94
  get events() { return this.ev; }
95
+ // ============================================================================
96
+ // State Machine Management
97
+ // ============================================================================
98
+ /**
99
+ * Transition to a new connection phase with logging
100
+ * @private
101
+ */
102
+ transitionTo(newPhase, reason) {
103
+ const oldPhase = this.connectionSession.phase;
104
+ if (oldPhase === newPhase)
105
+ return; // No-op if already in target phase
106
+ (0, debug_1.dbg)(`State transition: ${oldPhase} → ${newPhase} (${reason})`);
107
+ this.connectionSession.phase = newPhase;
108
+ // Update timestamps based on phase
109
+ if (newPhase === types_1.ConnectionPhase.CONNECTING || newPhase === types_1.ConnectionPhase.RECONNECTING) {
110
+ this.connectionSession.startTime = Date.now();
111
+ }
112
+ else if (newPhase === types_1.ConnectionPhase.IDLE) {
113
+ // Record disconnect time when entering IDLE
114
+ this.connectionSession.lastDisconnectTime = Date.now();
115
+ }
116
+ }
117
+ /**
118
+ * Validate if a state transition is legal
119
+ * @private
120
+ */
121
+ canTransitionTo(targetPhase) {
122
+ const current = this.connectionSession.phase;
123
+ // Define valid state transitions
124
+ const validTransitions = {
125
+ [types_1.ConnectionPhase.IDLE]: [types_1.ConnectionPhase.CONNECTING],
126
+ [types_1.ConnectionPhase.CONNECTING]: [types_1.ConnectionPhase.CONNECTED, types_1.ConnectionPhase.DISCONNECTING, types_1.ConnectionPhase.IDLE],
127
+ [types_1.ConnectionPhase.CONNECTED]: [types_1.ConnectionPhase.DISCONNECTING, types_1.ConnectionPhase.RECONNECTING],
128
+ [types_1.ConnectionPhase.DISCONNECTING]: [types_1.ConnectionPhase.IDLE],
129
+ [types_1.ConnectionPhase.RECONNECTING]: [types_1.ConnectionPhase.CONNECTED, types_1.ConnectionPhase.IDLE]
130
+ };
131
+ return validTransitions[current]?.includes(targetPhase) ?? false;
132
+ }
133
+ /**
134
+ * Abort an ongoing connection attempt by session ID
135
+ * @private
136
+ */
137
+ abortConnectionAttempt(sessionId, reason) {
138
+ const abortHandler = this.abortHandlers.get(sessionId);
139
+ if (abortHandler) {
140
+ (0, debug_1.dbg)(`Aborting connection session ${sessionId}: ${reason}`);
141
+ abortHandler(reason);
142
+ this.abortHandlers.delete(sessionId);
143
+ }
144
+ }
145
+ // ============================================================================
146
+ // Connection Methods
147
+ // ============================================================================
148
+ /**
149
+ * Connect to the rig
150
+ * Idempotent: multiple calls during CONNECTING phase are safe
151
+ * @throws Error if called during DISCONNECTING phase
152
+ */
66
153
  async connect() {
67
- // Initialize readiness promises
68
- this.loginReady = new Promise(resolve => { this.resolveLoginReady = resolve; });
69
- this.civReady = new Promise(resolve => { this.resolveCivReady = resolve; });
70
- this.audioReady = new Promise(resolve => { this.resolveAudioReady = resolve; });
71
- this.sess.open();
72
- this.sess.startAreYouThere();
73
- // Wait for all sub-sessions to be ready
74
- await Promise.all([this.loginReady, this.civReady, this.audioReady]);
75
- (0, debug_1.dbg)('All sessions ready (login + civ + audio)');
154
+ const currentPhase = this.connectionSession.phase;
155
+ // If already connected, return immediately
156
+ if (currentPhase === types_1.ConnectionPhase.CONNECTED) {
157
+ (0, debug_1.dbg)('connect() called but already CONNECTED - returning immediately');
158
+ return;
159
+ }
160
+ // If already connecting or reconnecting, wait for completion (idempotent)
161
+ if (currentPhase === types_1.ConnectionPhase.CONNECTING || currentPhase === types_1.ConnectionPhase.RECONNECTING) {
162
+ (0, debug_1.dbg)(`connect() called while ${currentPhase} - idempotent behavior, will wait`);
163
+ // Return a promise that resolves when state changes to CONNECTED
164
+ return new Promise((resolve, reject) => {
165
+ const checkState = () => {
166
+ if (this.connectionSession.phase === types_1.ConnectionPhase.CONNECTED) {
167
+ resolve();
168
+ }
169
+ else if (this.connectionSession.phase === types_1.ConnectionPhase.IDLE) {
170
+ reject(new Error('Connection failed'));
171
+ }
172
+ else {
173
+ setTimeout(checkState, 100);
174
+ }
175
+ };
176
+ checkState();
177
+ });
178
+ }
179
+ // Reject if in DISCONNECTING phase
180
+ if (currentPhase === types_1.ConnectionPhase.DISCONNECTING) {
181
+ throw new Error('Cannot connect while disconnecting - wait for IDLE state');
182
+ }
183
+ // Only IDLE state can transition to CONNECTING
184
+ if (!this.canTransitionTo(types_1.ConnectionPhase.CONNECTING)) {
185
+ throw new Error(`Cannot connect from ${currentPhase} state`);
186
+ }
187
+ // Start new connection session
188
+ const sessionId = this.nextSessionId++;
189
+ this.connectionSession.sessionId = sessionId;
190
+ this.transitionTo(types_1.ConnectionPhase.CONNECTING, `User connect() - sessionId=${sessionId}`);
191
+ try {
192
+ await this._doConnect(sessionId);
193
+ this.transitionTo(types_1.ConnectionPhase.CONNECTED, 'All sessions ready');
194
+ (0, debug_1.dbg)(`Connection session ${sessionId} established successfully`);
195
+ }
196
+ catch (err) {
197
+ // Clean up abort handler
198
+ this.abortHandlers.delete(sessionId);
199
+ // Transition to IDLE on failure
200
+ this.transitionTo(types_1.ConnectionPhase.IDLE, `Connection failed: ${err instanceof Error ? err.message : String(err)}`);
201
+ throw err;
202
+ }
203
+ }
204
+ /**
205
+ * Internal connection implementation
206
+ * Uses local promises to avoid race conditions
207
+ * Uses phased timeout: 30s for overall, 10s for sub-sessions after login
208
+ * @param sessionId - Unique session ID to prevent race conditions
209
+ */
210
+ async _doConnect(sessionId) {
211
+ const { loginReady, civReady, audioReady, cleanup } = this.createReadyPromises(sessionId);
212
+ try {
213
+ // Reset all session states to initial values
214
+ // This is CRITICAL for reconnection after radio restart
215
+ // Without this, the radio won't recognize our old localId/remoteId/tokens
216
+ (0, debug_1.dbg)('Resetting all session states before connection...');
217
+ this.sess.resetState();
218
+ this.civSess.resetState();
219
+ this.audioSess.resetState();
220
+ // Ensure all session sockets are open (critical for reconnection after disconnect)
221
+ this.sess.open();
222
+ this.civSess.open();
223
+ this.audioSess.open();
224
+ this.sess.startAreYouThere();
225
+ // Phase 1: Wait for login (protected by overall 30s timeout from connectWithTimeout)
226
+ await loginReady;
227
+ (0, debug_1.dbg)('Login complete, waiting for CIV/Audio sub-sessions...');
228
+ // Phase 2: Wait for CIV/Audio with shorter timeout (10s)
229
+ // If radio doesn't respond to AreYouThere, fail fast instead of waiting full 30s
230
+ const SUB_SESSION_TIMEOUT = 10000;
231
+ const subSessionTimeout = new Promise((_, reject) => {
232
+ setTimeout(() => {
233
+ reject(new Error(`CIV/Audio sessions timeout after ${SUB_SESSION_TIMEOUT}ms - radio not responding to AreYouThere`));
234
+ }, SUB_SESSION_TIMEOUT);
235
+ });
236
+ await Promise.race([
237
+ Promise.all([civReady, audioReady]),
238
+ subSessionTimeout
239
+ ]);
240
+ (0, debug_1.dbg)('All sessions ready (login + civ + audio)');
241
+ // Start unified connection monitoring
242
+ this.startUnifiedMonitoring();
243
+ (0, debug_1.dbg)('Unified connection monitoring started');
244
+ }
245
+ finally {
246
+ cleanup();
247
+ }
248
+ }
249
+ /**
250
+ * Create local promises for connection readiness
251
+ * This avoids race conditions with instance variables
252
+ * @param sessionId - Connection session ID for abort handler tracking
253
+ */
254
+ createReadyPromises(sessionId) {
255
+ let resolveLogin;
256
+ let resolveCiv;
257
+ let resolveAudio;
258
+ let rejectLogin;
259
+ let rejectCiv;
260
+ let rejectAudio;
261
+ const loginReady = new Promise((resolve, reject) => {
262
+ resolveLogin = resolve;
263
+ rejectLogin = reject;
264
+ });
265
+ const civReady = new Promise((resolve, reject) => {
266
+ resolveCiv = resolve;
267
+ rejectCiv = reject;
268
+ });
269
+ const audioReady = new Promise((resolve, reject) => {
270
+ resolveAudio = resolve;
271
+ rejectAudio = reject;
272
+ });
273
+ // Store abort handler bound to this specific sessionId
274
+ // This prevents race conditions when multiple connection attempts overlap
275
+ const abortHandler = (reason) => {
276
+ (0, debug_1.dbg)(`Aborting connection session ${sessionId}: ${reason}`);
277
+ const error = new Error(reason);
278
+ rejectLogin(error);
279
+ rejectCiv(error);
280
+ rejectAudio(error);
281
+ };
282
+ this.abortHandlers.set(sessionId, abortHandler);
283
+ // Temporary event listeners (local scope)
284
+ const onLogin = (res) => {
285
+ if (res.ok) {
286
+ (0, debug_1.dbg)('Login ready - resolving local loginReady promise');
287
+ resolveLogin();
288
+ }
289
+ };
290
+ const onCivReady = () => {
291
+ (0, debug_1.dbg)('CIV ready - resolving local civReady promise');
292
+ resolveCiv();
293
+ };
294
+ const onAudioReady = () => {
295
+ (0, debug_1.dbg)('Audio ready - resolving local audioReady promise');
296
+ resolveAudio();
297
+ };
298
+ this.ev.once('login', onLogin);
299
+ this.ev.once('_civReady', onCivReady);
300
+ this.ev.once('_audioReady', onAudioReady);
301
+ return {
302
+ loginReady,
303
+ civReady,
304
+ audioReady,
305
+ cleanup: () => {
306
+ // Remove abort handler for this specific sessionId
307
+ // This is safe because the connection attempt is complete (success or failure)
308
+ this.abortHandlers.delete(sessionId);
309
+ // Remove event listeners
310
+ this.ev.off('login', onLogin);
311
+ this.ev.off('_civReady', onCivReady);
312
+ this.ev.off('_audioReady', onAudioReady);
313
+ }
314
+ };
315
+ }
316
+ /**
317
+ * Start unified connection monitoring
318
+ * Monitors all three sessions from a single timer to avoid race conditions
319
+ * @private
320
+ */
321
+ startUnifiedMonitoring() {
322
+ this.stopUnifiedMonitoring();
323
+ this.monitorTimer = setInterval(() => {
324
+ // Only monitor when CONNECTED (not during CONNECTING, RECONNECTING, DISCONNECTING, or IDLE)
325
+ if (this.connectionSession.phase !== types_1.ConnectionPhase.CONNECTED) {
326
+ return;
327
+ }
328
+ const now = Date.now();
329
+ const sessions = [
330
+ { sess: this.sess, type: types_1.SessionType.CONTROL },
331
+ { sess: this.civSess, type: types_1.SessionType.CIV },
332
+ { sess: this.audioSess, type: types_1.SessionType.AUDIO }
333
+ ];
334
+ // Check each session for timeout
335
+ for (const { sess, type } of sessions) {
336
+ if (sess['destroyed'])
337
+ continue; // Skip destroyed sessions
338
+ const timeSinceLastData = now - sess.lastReceivedTime;
339
+ if (timeSinceLastData > this.monitorConfig.timeout) {
340
+ (0, debug_1.dbg)(`${type} session timeout detected (${timeSinceLastData}ms since last data)`);
341
+ this.handleConnectionLost(type, timeSinceLastData);
342
+ return; // Only handle one timeout at a time to avoid duplicate reconnect triggers
343
+ }
344
+ }
345
+ }, this.monitorConfig.checkInterval);
346
+ }
347
+ /**
348
+ * Stop unified connection monitoring
349
+ * @private
350
+ */
351
+ stopUnifiedMonitoring() {
352
+ if (this.monitorTimer) {
353
+ clearInterval(this.monitorTimer);
354
+ this.monitorTimer = undefined;
355
+ }
76
356
  }
77
357
  async disconnect() {
78
- // 1. Stop all timers first to prevent interference
79
- if (this.tokenTimer) {
80
- clearInterval(this.tokenTimer);
81
- this.tokenTimer = undefined;
358
+ const currentPhase = this.connectionSession.phase;
359
+ // If already disconnecting or idle, avoid duplicate work
360
+ if (currentPhase === types_1.ConnectionPhase.DISCONNECTING) {
361
+ (0, debug_1.dbg)('disconnect() called but already DISCONNECTING - waiting for completion');
362
+ // Wait for transition to IDLE
363
+ return new Promise((resolve) => {
364
+ const checkState = () => {
365
+ if (this.connectionSession.phase === types_1.ConnectionPhase.IDLE) {
366
+ resolve();
367
+ }
368
+ else {
369
+ setTimeout(checkState, 100);
370
+ }
371
+ };
372
+ checkState();
373
+ });
374
+ }
375
+ if (currentPhase === types_1.ConnectionPhase.IDLE) {
376
+ (0, debug_1.dbg)('disconnect() called but already IDLE - no-op');
377
+ return;
378
+ }
379
+ // Abort any ongoing connection attempts
380
+ const currentSessionId = this.connectionSession.sessionId;
381
+ if (currentPhase === types_1.ConnectionPhase.CONNECTING || currentPhase === types_1.ConnectionPhase.RECONNECTING) {
382
+ (0, debug_1.dbg)(`Aborting ongoing connection attempt (sessionId=${currentSessionId})`);
383
+ this.abortConnectionAttempt(currentSessionId, 'User disconnect()');
384
+ }
385
+ // Transition to DISCONNECTING state
386
+ this.transitionTo(types_1.ConnectionPhase.DISCONNECTING, 'User disconnect()');
387
+ try {
388
+ // 1. Stop all timers first to prevent interference
389
+ this.stopUnifiedMonitoring(); // Stop unified monitoring
390
+ if (this.tokenTimer) {
391
+ clearInterval(this.tokenTimer);
392
+ this.tokenTimer = undefined;
393
+ }
394
+ this.stopMeterPolling();
395
+ this.sess.stopTimers();
396
+ if (this.civSess)
397
+ this.civSess.stopTimers();
398
+ if (this.audioSess)
399
+ this.audioSess.stopTimers();
400
+ // 2. Send DELETE token packet
401
+ try {
402
+ const del = IcomPackets_1.TokenPacket.build(0, this.sess.localId, this.sess.remoteId, IcomPackets_1.TokenType.DELETE, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken);
403
+ this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
404
+ this.sess.sendTracked(del);
405
+ }
406
+ catch (err) {
407
+ (0, debug_1.dbg)('Failed to send DELETE token packet:', err);
408
+ // Continue with disconnect even if this fails
409
+ }
410
+ // 3. Send CMD_DISCONNECT to all sessions
411
+ try {
412
+ this.sess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.DISCONNECT, 0, this.sess.localId, this.sess.remoteId));
413
+ if (this.civSess) {
414
+ this.civ.sendOpenClose(false);
415
+ this.civSess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.DISCONNECT, 0, this.civSess.localId, this.civSess.remoteId));
416
+ }
417
+ if (this.audioSess) {
418
+ this.audioSess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.DISCONNECT, 0, this.audioSess.localId, this.audioSess.remoteId));
419
+ }
420
+ }
421
+ catch (err) {
422
+ (0, debug_1.dbg)('Failed to send DISCONNECT packets:', err);
423
+ // Continue with disconnect even if this fails
424
+ }
425
+ // 4. Wait 200ms to ensure UDP packets are sent before closing sockets
426
+ await new Promise(resolve => setTimeout(resolve, 200));
427
+ // 5. Stop streams and close sockets
428
+ this.civ.stop();
429
+ this.audio.stop(); // Stop continuous audio transmission
430
+ this.sess.close();
431
+ if (this.civSess)
432
+ this.civSess.close();
433
+ if (this.audioSess)
434
+ this.audioSess.close();
435
+ }
436
+ catch (err) {
437
+ (0, debug_1.dbg)('Error during disconnect:', err);
438
+ // Continue to IDLE state even if there were errors
439
+ }
440
+ finally {
441
+ // Always transition to IDLE at the end
442
+ this.transitionTo(types_1.ConnectionPhase.IDLE, 'Disconnect complete');
82
443
  }
83
- this.stopMeterPolling();
84
- this.sess.stopTimers();
85
- if (this.civSess)
86
- this.civSess.stopTimers();
87
- if (this.audioSess)
88
- this.audioSess.stopTimers();
89
- // 2. Send DELETE token packet
90
- const del = IcomPackets_1.TokenPacket.build(0, this.sess.localId, this.sess.remoteId, IcomPackets_1.TokenType.DELETE, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken);
91
- this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
92
- this.sess.sendTracked(del);
93
- // 3. Send CMD_DISCONNECT to all sessions
94
- this.sess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.DISCONNECT, 0, this.sess.localId, this.sess.remoteId));
95
- if (this.civSess) {
96
- this.civ.sendOpenClose(false);
97
- this.civSess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.DISCONNECT, 0, this.civSess.localId, this.civSess.remoteId));
98
- }
99
- if (this.audioSess) {
100
- this.audioSess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.DISCONNECT, 0, this.audioSess.localId, this.audioSess.remoteId));
101
- }
102
- // 4. Wait 200ms to ensure UDP packets are sent before closing sockets
103
- await new Promise(resolve => setTimeout(resolve, 200));
104
- // 5. Stop streams and close sockets
105
- this.civ.stop();
106
- this.audio.stop(); // Stop continuous audio transmission
107
- this.sess.close();
108
- if (this.civSess)
109
- this.civSess.close();
110
- if (this.audioSess)
111
- this.audioSess.close();
112
444
  }
113
445
  sendCiv(data) { this.civ.sendCivData(data); }
114
446
  /**
@@ -568,10 +900,42 @@ class IcomControl {
568
900
  case IcomPackets_1.Sizes.STATUS: {
569
901
  const civPort = IcomPackets_1.StatusPacket.getRigCivPort(buf);
570
902
  const audioPort = IcomPackets_1.StatusPacket.getRigAudioPort(buf);
571
- (0, debug_1.dbg)('STATUS <= civPort=', civPort, 'audioPort=', audioPort, 'authOK=', IcomPackets_1.StatusPacket.authOK(buf), 'connected=', IcomPackets_1.StatusPacket.getIsConnected(buf));
903
+ const connected = IcomPackets_1.StatusPacket.getIsConnected(buf);
904
+ const authOK = IcomPackets_1.StatusPacket.authOK(buf);
905
+ (0, debug_1.dbg)('STATUS <= civPort=', civPort, 'audioPort=', audioPort, 'authOK=', authOK, 'connected=', connected);
906
+ // If radio reports disconnected, handle immediately
907
+ if (!connected) {
908
+ (0, debug_1.dbg)('Radio reported connected=false');
909
+ // If we're currently attempting to connect, abort immediately (fast-fail)
910
+ const currentPhase = this.connectionSession.phase;
911
+ if (currentPhase === types_1.ConnectionPhase.CONNECTING || currentPhase === types_1.ConnectionPhase.RECONNECTING) {
912
+ (0, debug_1.dbg)('Aborting ongoing connection attempt due to connected=false');
913
+ this.abortConnectionAttempt(this.connectionSession.sessionId, 'Radio reported connected=false');
914
+ }
915
+ else if (currentPhase === types_1.ConnectionPhase.CONNECTED) {
916
+ // Trigger reconnection for established connection
917
+ (0, debug_1.dbg)('Triggering reconnection for established connection');
918
+ this.handleConnectionLost(types_1.SessionType.CONTROL, 0);
919
+ }
920
+ break;
921
+ }
922
+ // CRITICAL: Ignore STATUS packets with invalid ports (0)
923
+ // Radio sends multiple STATUS packets during connection:
924
+ // 1. First with valid ports (e.g., 50002, 50003) when CONNINFO busy=false
925
+ // 2. Second with port=0 when CONNINFO busy=true (should be ignored!)
926
+ // If we don't check, the second packet will overwrite the valid ports with 0
927
+ if (civPort === 0 || audioPort === 0) {
928
+ (0, debug_1.dbg)('STATUS packet has invalid ports (0) - ignoring to preserve existing valid ports');
929
+ (0, debug_1.dbg)('This is normal during reconnection when rig sends CONNINFO busy=true');
930
+ // Still emit status event for monitoring, but don't setRemote
931
+ const info = { civPort, audioPort, authOK, connected };
932
+ this.ev.emit('status', info);
933
+ break;
934
+ }
572
935
  const info = { civPort, audioPort, authOK: true, connected: true };
573
936
  this.ev.emit('status', info);
574
- // set remote ports and start AYT for civ/audio
937
+ // Only set remote ports and start sessions if ports are valid (non-zero)
938
+ (0, debug_1.dbg)('STATUS has valid ports - setting up CIV/Audio sessions');
575
939
  if (this.civSess) {
576
940
  this.civSess.setRemote(this.options.control.ip, civPort);
577
941
  this.civSess.startAreYouThere();
@@ -604,10 +968,7 @@ class IcomControl {
604
968
  }
605
969
  const res = { ok, errorCode: IcomPackets_1.LoginResponsePacket.errorNum(buf), connection: IcomPackets_1.LoginResponsePacket.getConnection(buf) };
606
970
  this.ev.emit('login', res);
607
- if (ok) {
608
- (0, debug_1.dbg)('Login ready - resolving loginReady promise');
609
- this.resolveLoginReady();
610
- }
971
+ // Note: login event is caught by createReadyPromises() listener
611
972
  break;
612
973
  }
613
974
  case IcomPackets_1.Sizes.CAP_CAP: {
@@ -629,23 +990,28 @@ class IcomControl {
629
990
  }
630
991
  case IcomPackets_1.Sizes.CONNINFO: {
631
992
  // rig sends twice; first time busy=false, reply with our ports
993
+ // IMPORTANT: During reconnection, rig may send busy=true if old connection not fully cleaned up
994
+ // We MUST still reply to proceed with connection (otherwise STATUS packet will never arrive)
632
995
  const busy = IcomPackets_1.ConnInfoPacket.getBusy(buf);
633
996
  this.macAddress = IcomPackets_1.ConnInfoPacket.getMacAddress(buf);
634
997
  this.rigName = IcomPackets_1.ConnInfoPacket.getRigName(buf);
635
998
  (0, debug_1.dbg)('CONNINFO <= busy=', busy, 'rigName=', this.rigName);
636
- if (!busy) {
637
- const reply = IcomPackets_1.ConnInfoPacket.connInfoPacketData(buf, 0, this.sess.localId, this.sess.remoteId, 0x01, 0x03, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken, this.rigName, this.options.userName, IcomPackets_1.AUDIO_SAMPLE_RATE, IcomPackets_1.AUDIO_SAMPLE_RATE, this.civSess.localPort, this.audioSess.localPort, IcomPackets_1.XIEGU_TX_BUFFER_SIZE);
638
- this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
639
- this.sess.sendTracked(reply);
640
- try {
641
- // eslint-disable-next-line @typescript-eslint/no-var-requires
642
- const { hex } = require('../utils/codec');
643
- (0, debug_1.dbg)('CONNINFO -> reply with local civPort=', this.civSess.localPort, 'audioPort=', this.audioSess.localPort);
644
- (0, debug_1.dbgV)('CONNINFO reply hex (first 0x60):', hex(Buffer.from(reply.subarray(0, 0x60))));
645
- (0, debug_1.dbgV)('CONNINFO reply hex (0x60..0x90):', hex(Buffer.from(reply.subarray(0x60, 0x90))));
646
- }
647
- catch { }
999
+ if (busy) {
1000
+ (0, debug_1.dbg)('CONNINFO busy=true detected - likely reconnecting while rig still has old session');
1001
+ (0, debug_1.dbg)('Sending ConnInfo reply anyway to allow STATUS packet delivery');
1002
+ }
1003
+ // ALWAYS send reply (even when busy=true during reconnection)
1004
+ const reply = IcomPackets_1.ConnInfoPacket.connInfoPacketData(buf, 0, this.sess.localId, this.sess.remoteId, 0x01, 0x03, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken, this.rigName, this.options.userName, IcomPackets_1.AUDIO_SAMPLE_RATE, IcomPackets_1.AUDIO_SAMPLE_RATE, this.civSess.localPort, this.audioSess.localPort, IcomPackets_1.XIEGU_TX_BUFFER_SIZE);
1005
+ this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
1006
+ this.sess.sendTracked(reply);
1007
+ try {
1008
+ // eslint-disable-next-line @typescript-eslint/no-var-requires
1009
+ const { hex } = require('../utils/codec');
1010
+ (0, debug_1.dbg)('CONNINFO -> reply with local civPort=', this.civSess.localPort, 'audioPort=', this.audioSess.localPort);
1011
+ (0, debug_1.dbgV)('CONNINFO reply hex (first 0x60):', hex(Buffer.from(reply.subarray(0, 0x60))));
1012
+ (0, debug_1.dbgV)('CONNINFO reply hex (0x60..0x90):', hex(Buffer.from(reply.subarray(0x60, 0x90))));
648
1013
  }
1014
+ catch { }
649
1015
  break;
650
1016
  }
651
1017
  default: {
@@ -711,8 +1077,8 @@ class IcomControl {
711
1077
  if (type === IcomPackets_1.Cmd.I_AM_READY) {
712
1078
  this.civ.sendOpenClose(true);
713
1079
  this.civSess.startIdle();
714
- (0, debug_1.dbg)('CIV ready - resolving civReady promise');
715
- this.resolveCivReady();
1080
+ (0, debug_1.dbg)('CIV ready - emitting internal _civReady event');
1081
+ this.ev.emit('_civReady');
716
1082
  return;
717
1083
  }
718
1084
  }
@@ -868,8 +1234,8 @@ class IcomControl {
868
1234
  // Start continuous audio transmission (like Java's startTxAudio on I_AM_READY)
869
1235
  this.audio.start();
870
1236
  this.audioSess.startIdle();
871
- (0, debug_1.dbg)('Audio ready - started continuous audio stream, resolving audioReady promise');
872
- this.resolveAudioReady();
1237
+ (0, debug_1.dbg)('Audio ready - started continuous audio stream, emitting internal _audioReady event');
1238
+ this.ev.emit('_audioReady');
873
1239
  return;
874
1240
  }
875
1241
  }
@@ -908,5 +1274,223 @@ class IcomControl {
908
1274
  this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
909
1275
  this.sess.sendTracked(pkt);
910
1276
  }
1277
+ // ============================================================================
1278
+ // Connection Monitoring
1279
+ // ============================================================================
1280
+ /**
1281
+ * Configure unified connection monitoring
1282
+ * @param config - Monitoring configuration options
1283
+ * @example
1284
+ * rig.configureMonitoring({ timeout: 10000, checkInterval: 2000, autoReconnect: true });
1285
+ */
1286
+ configureMonitoring(config) {
1287
+ this.monitorConfig = { ...this.monitorConfig, ...config };
1288
+ }
1289
+ /**
1290
+ * Get current connection state for all sessions
1291
+ * @returns Object with connection state for each session
1292
+ */
1293
+ getConnectionState() {
1294
+ const now = Date.now();
1295
+ const isTimedOut = (sess) => {
1296
+ if (sess['destroyed'])
1297
+ return true;
1298
+ return (now - sess.lastReceivedTime) > this.monitorConfig.timeout;
1299
+ };
1300
+ return {
1301
+ control: isTimedOut(this.sess) ? types_1.ConnectionState.DISCONNECTED : types_1.ConnectionState.CONNECTED,
1302
+ civ: isTimedOut(this.civSess) ? types_1.ConnectionState.DISCONNECTED : types_1.ConnectionState.CONNECTED,
1303
+ audio: isTimedOut(this.audioSess) ? types_1.ConnectionState.DISCONNECTED : types_1.ConnectionState.CONNECTED
1304
+ };
1305
+ }
1306
+ /**
1307
+ * Check if any session has lost connection
1308
+ * @returns true if any session is disconnected
1309
+ */
1310
+ isAnySessionDisconnected() {
1311
+ const state = this.getConnectionState();
1312
+ return state.control === types_1.ConnectionState.DISCONNECTED ||
1313
+ state.civ === types_1.ConnectionState.DISCONNECTED ||
1314
+ state.audio === types_1.ConnectionState.DISCONNECTED;
1315
+ }
1316
+ /**
1317
+ * Handle connection lost event from a session
1318
+ * Simplified strategy: any session loss triggers full reconnect
1319
+ * @private
1320
+ */
1321
+ handleConnectionLost(sessionType, timeSinceLastData) {
1322
+ // Record disconnect time for downtime calculation
1323
+ this.connectionSession.lastDisconnectTime = Date.now();
1324
+ const info = {
1325
+ sessionType,
1326
+ reason: `No data received for ${timeSinceLastData}ms`,
1327
+ timeSinceLastData,
1328
+ timestamp: this.connectionSession.lastDisconnectTime
1329
+ };
1330
+ (0, debug_1.dbg)(`Connection lost: ${sessionType} session (${timeSinceLastData}ms since last data)`);
1331
+ this.ev.emit('connectionLost', info);
1332
+ // Check if auto-reconnect is enabled
1333
+ if (!this.monitorConfig.autoReconnect) {
1334
+ (0, debug_1.dbg)(`Auto-reconnect disabled, not attempting reconnect`);
1335
+ // Transition to IDLE since we won't reconnect
1336
+ this.transitionTo(types_1.ConnectionPhase.IDLE, 'Connection lost, auto-reconnect disabled');
1337
+ return;
1338
+ }
1339
+ // Validate state transition to RECONNECTING
1340
+ if (!this.canTransitionTo(types_1.ConnectionPhase.RECONNECTING)) {
1341
+ (0, debug_1.dbg)(`Cannot transition to RECONNECTING from ${this.connectionSession.phase} - skipping reconnect`);
1342
+ return;
1343
+ }
1344
+ // Simplified strategy: any session loss → full reconnect
1345
+ // This is more reliable than trying to reconnect individual sessions
1346
+ (0, debug_1.dbg)(`${sessionType} session lost - initiating full reconnect`);
1347
+ this.scheduleFullReconnect();
1348
+ }
1349
+ /**
1350
+ * Schedule a full reconnection (all sessions)
1351
+ * Uses simple while loop with exponential backoff
1352
+ * @private
1353
+ */
1354
+ async scheduleFullReconnect() {
1355
+ // Prevent multiple concurrent reconnect attempts
1356
+ if (this.connectionSession.phase === types_1.ConnectionPhase.RECONNECTING) {
1357
+ (0, debug_1.dbg)('Full reconnect already in progress, skipping');
1358
+ return;
1359
+ }
1360
+ // Transition to RECONNECTING state
1361
+ this.transitionTo(types_1.ConnectionPhase.RECONNECTING, 'Starting full reconnect');
1362
+ let attempt = 0;
1363
+ const disconnectTime = this.connectionSession.lastDisconnectTime || Date.now();
1364
+ try {
1365
+ while (true) {
1366
+ attempt++;
1367
+ const delay = this.calculateReconnectDelay(attempt);
1368
+ // Emit reconnect attempting event
1369
+ this.ev.emit('reconnectAttempting', {
1370
+ sessionType: types_1.SessionType.CONTROL,
1371
+ attemptNumber: attempt,
1372
+ delay,
1373
+ timestamp: Date.now(),
1374
+ fullReconnect: true
1375
+ });
1376
+ (0, debug_1.dbg)(`Full reconnect attempt #${attempt} (delay: ${delay}ms)`);
1377
+ await this.sleep(delay);
1378
+ try {
1379
+ // Disconnect all sessions
1380
+ (0, debug_1.dbg)('Full reconnect: disconnecting all sessions');
1381
+ await this.disconnect();
1382
+ // Wait longer before reconnecting to allow rig to fully clean up old connection
1383
+ // This is critical - rig may report CONNINFO busy=true if we reconnect too quickly
1384
+ (0, debug_1.dbg)('Full reconnect: waiting 5s for rig to clean up old session...');
1385
+ await this.sleep(5000);
1386
+ // Reconnect with timeout (uses new state-machine-based connect())
1387
+ (0, debug_1.dbg)('Full reconnect: reconnecting');
1388
+ await this.connectWithTimeout(30000);
1389
+ // Success! Calculate downtime and emit connectionRestored event
1390
+ const downtime = Date.now() - disconnectTime;
1391
+ (0, debug_1.dbg)(`Full reconnect successful! Downtime: ${downtime}ms`);
1392
+ const finalState = this.getConnectionState();
1393
+ (0, debug_1.dbg)(`Reconnect complete - All sessions: Control=${finalState.control}, CIV=${finalState.civ}, Audio=${finalState.audio}`);
1394
+ // Emit connectionRestored event (fixes Problem #5)
1395
+ this.ev.emit('connectionRestored', {
1396
+ sessionType: types_1.SessionType.CONTROL,
1397
+ downtime,
1398
+ timestamp: Date.now()
1399
+ });
1400
+ // State is already CONNECTED from connect() call
1401
+ return;
1402
+ }
1403
+ catch (err) {
1404
+ const errorMsg = err instanceof Error ? err.message : String(err);
1405
+ (0, debug_1.dbg)('Full reconnect failed:', errorMsg);
1406
+ // Get current connection state for diagnostics
1407
+ const state = this.getConnectionState();
1408
+ (0, debug_1.dbg)(`Current state after failed reconnect: Control=${state.control}, CIV=${state.civ}, Audio=${state.audio}`);
1409
+ // Check if we should retry
1410
+ const maxAttempts = this.monitorConfig.maxReconnectAttempts;
1411
+ const willRetry = maxAttempts === undefined || attempt < maxAttempts;
1412
+ // Emit reconnect failed event
1413
+ this.ev.emit('reconnectFailed', {
1414
+ sessionType: types_1.SessionType.CONTROL,
1415
+ attemptNumber: attempt,
1416
+ error: errorMsg,
1417
+ timestamp: Date.now(),
1418
+ fullReconnect: true,
1419
+ willRetry,
1420
+ nextRetryDelay: willRetry ? this.calculateReconnectDelay(attempt + 1) : undefined
1421
+ });
1422
+ if (!willRetry) {
1423
+ (0, debug_1.dbg)(`Max reconnect attempts (${maxAttempts}) reached, giving up`);
1424
+ (0, debug_1.dbg)(`Final state: Control=${state.control}, CIV=${state.civ}, Audio=${state.audio}`);
1425
+ // Transition to IDLE since we're giving up
1426
+ this.transitionTo(types_1.ConnectionPhase.IDLE, 'Max reconnect attempts reached');
1427
+ return;
1428
+ }
1429
+ // Continue loop for retry
1430
+ (0, debug_1.dbg)(`Will retry in ${this.calculateReconnectDelay(attempt + 1)}ms...`);
1431
+ }
1432
+ }
1433
+ }
1434
+ catch (err) {
1435
+ // Unexpected error in reconnect loop
1436
+ (0, debug_1.dbg)('Unexpected error in scheduleFullReconnect:', err);
1437
+ this.transitionTo(types_1.ConnectionPhase.IDLE, 'Reconnect loop error');
1438
+ }
1439
+ }
1440
+ /**
1441
+ * Connect with timeout (helper for reconnection)
1442
+ * @private
1443
+ */
1444
+ async connectWithTimeout(timeout) {
1445
+ const timeoutPromise = this.sleep(timeout).then(() => {
1446
+ throw new Error(`Connection timeout after ${timeout}ms`);
1447
+ });
1448
+ await Promise.race([
1449
+ this.connect(),
1450
+ timeoutPromise
1451
+ ]);
1452
+ }
1453
+ /**
1454
+ * Sleep helper (returns a Promise)
1455
+ * @private
1456
+ */
1457
+ sleep(ms) {
1458
+ return new Promise(resolve => setTimeout(resolve, ms));
1459
+ }
1460
+ /**
1461
+ * Calculate reconnect delay using exponential backoff
1462
+ * @private
1463
+ */
1464
+ calculateReconnectDelay(attemptNumber) {
1465
+ const delay = this.monitorConfig.reconnectBaseDelay * Math.pow(2, attemptNumber - 1);
1466
+ return Math.min(delay, this.monitorConfig.reconnectMaxDelay);
1467
+ }
1468
+ // ============================================================================
1469
+ // Public Observability APIs
1470
+ // ============================================================================
1471
+ /**
1472
+ * Get current connection phase
1473
+ * @returns Current connection phase (IDLE, CONNECTING, CONNECTED, etc.)
1474
+ */
1475
+ getConnectionPhase() {
1476
+ return this.connectionSession.phase;
1477
+ }
1478
+ /**
1479
+ * Get detailed connection metrics for monitoring and diagnostics
1480
+ * @returns Connection metrics including phase, uptime, session states
1481
+ */
1482
+ getConnectionMetrics() {
1483
+ const now = Date.now();
1484
+ return {
1485
+ phase: this.connectionSession.phase,
1486
+ sessionId: this.connectionSession.sessionId,
1487
+ uptime: this.connectionSession.phase === types_1.ConnectionPhase.CONNECTED
1488
+ ? now - this.connectionSession.startTime
1489
+ : 0,
1490
+ sessions: this.getConnectionState(),
1491
+ lastDisconnectTime: this.connectionSession.lastDisconnectTime,
1492
+ isReconnecting: this.connectionSession.phase === types_1.ConnectionPhase.RECONNECTING
1493
+ };
1494
+ }
911
1495
  }
912
1496
  exports.IcomControl = IcomControl;