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.
- package/README.md +157 -16
- package/dist/core/Session.d.ts +9 -0
- package/dist/core/Session.js +34 -5
- package/dist/index.js +1 -1
- package/dist/rig/IcomCiv.js +6 -1
- package/dist/rig/IcomControl.d.ts +108 -7
- package/dist/rig/IcomControl.js +651 -67
- package/dist/transport/UdpClient.js +18 -4
- package/dist/types.d.ts +175 -0
- package/dist/types.js +46 -0
- package/package.json +1 -1
package/dist/rig/IcomControl.js
CHANGED
|
@@ -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
|
-
|
|
58
|
-
|
|
59
|
-
this.
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
//
|
|
74
|
-
|
|
75
|
-
|
|
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
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 (
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
}
|
|
647
|
-
|
|
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 -
|
|
715
|
-
this.
|
|
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,
|
|
872
|
-
this.
|
|
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;
|