icom-wlan-node 0.1.1 → 0.2.1
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 +234 -23
- package/dist/core/Session.d.ts +9 -0
- package/dist/core/Session.js +34 -5
- package/dist/index.d.ts +1 -1
- package/dist/index.js +6 -2
- package/dist/rig/IcomCiv.js +6 -1
- package/dist/rig/IcomConstants.d.ts +72 -0
- package/dist/rig/IcomConstants.js +82 -1
- package/dist/rig/IcomControl.d.ts +185 -7
- package/dist/rig/IcomControl.js +819 -67
- package/dist/rig/IcomRigCommands.d.ts +7 -0
- package/dist/rig/IcomRigCommands.js +28 -0
- package/dist/transport/UdpClient.js +18 -4
- package/dist/types.d.ts +254 -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
|
/**
|
|
@@ -400,6 +732,174 @@ class IcomControl {
|
|
|
400
732
|
const modeCode = typeof mode === 'string' ? (0, IcomConstants_1.getConnectorModeCode)(mode) : mode;
|
|
401
733
|
this.sendCiv(IcomRigCommands_1.IcomRigCommands.setConnectorDataMode(ctrAddr, rigAddr, modeCode));
|
|
402
734
|
}
|
|
735
|
+
/**
|
|
736
|
+
* Read squelch status (noise/signal gate state)
|
|
737
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
738
|
+
* @returns Squelch status with raw value and boolean state
|
|
739
|
+
* @example
|
|
740
|
+
* const status = await rig.readSquelchStatus({ timeout: 2000 });
|
|
741
|
+
* if (status) {
|
|
742
|
+
* console.log(`Squelch: ${status.isOpen ? 'OPEN' : 'CLOSED'}`);
|
|
743
|
+
* }
|
|
744
|
+
*/
|
|
745
|
+
async readSquelchStatus(options) {
|
|
746
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
747
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
748
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
749
|
+
const req = IcomRigCommands_1.IcomRigCommands.getSquelchStatus(ctrAddr, rigAddr);
|
|
750
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x01, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
751
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
752
|
+
if (raw === null)
|
|
753
|
+
return null;
|
|
754
|
+
return {
|
|
755
|
+
raw,
|
|
756
|
+
isOpen: raw === 0x0001
|
|
757
|
+
};
|
|
758
|
+
}
|
|
759
|
+
/**
|
|
760
|
+
* Read audio squelch state
|
|
761
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
762
|
+
* @returns Audio squelch status with raw value and boolean state
|
|
763
|
+
* @example
|
|
764
|
+
* const squelch = await rig.readAudioSquelch({ timeout: 2000 });
|
|
765
|
+
* if (squelch) {
|
|
766
|
+
* console.log(`Audio Squelch: ${squelch.isOpen ? 'OPEN' : 'CLOSED'}`);
|
|
767
|
+
* }
|
|
768
|
+
*/
|
|
769
|
+
async readAudioSquelch(options) {
|
|
770
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
771
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
772
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
773
|
+
const req = IcomRigCommands_1.IcomRigCommands.getAudioSquelch(ctrAddr, rigAddr);
|
|
774
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x05, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
775
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
776
|
+
if (raw === null)
|
|
777
|
+
return null;
|
|
778
|
+
return {
|
|
779
|
+
raw,
|
|
780
|
+
isOpen: raw === 0x0001
|
|
781
|
+
};
|
|
782
|
+
}
|
|
783
|
+
/**
|
|
784
|
+
* Read OVF (ADC overload) status
|
|
785
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
786
|
+
* @returns OVF status with raw value and boolean overload flag
|
|
787
|
+
* @example
|
|
788
|
+
* const ovf = await rig.readOvfStatus({ timeout: 2000 });
|
|
789
|
+
* if (ovf) {
|
|
790
|
+
* console.log(`ADC: ${ovf.isOverload ? '⚠️ OVERLOAD' : '✓ OK'}`);
|
|
791
|
+
* }
|
|
792
|
+
*/
|
|
793
|
+
async readOvfStatus(options) {
|
|
794
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
795
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
796
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
797
|
+
const req = IcomRigCommands_1.IcomRigCommands.getOvfStatus(ctrAddr, rigAddr);
|
|
798
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x07, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
799
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
800
|
+
if (raw === null)
|
|
801
|
+
return null;
|
|
802
|
+
return {
|
|
803
|
+
raw,
|
|
804
|
+
isOverload: raw === 0x0001
|
|
805
|
+
};
|
|
806
|
+
}
|
|
807
|
+
/**
|
|
808
|
+
* Read power output level during transmission
|
|
809
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
810
|
+
* @returns Power level with raw value and percentage
|
|
811
|
+
* @example
|
|
812
|
+
* const power = await rig.readPowerLevel({ timeout: 2000 });
|
|
813
|
+
* if (power) {
|
|
814
|
+
* console.log(`Power: ${power.percent.toFixed(1)}%`);
|
|
815
|
+
* }
|
|
816
|
+
*/
|
|
817
|
+
async readPowerLevel(options) {
|
|
818
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
819
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
820
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
821
|
+
const req = IcomRigCommands_1.IcomRigCommands.getPowerLevel(ctrAddr, rigAddr);
|
|
822
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x11, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
823
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
824
|
+
if (raw === null)
|
|
825
|
+
return null;
|
|
826
|
+
return {
|
|
827
|
+
raw,
|
|
828
|
+
percent: (0, IcomConstants_1.rawToPowerPercent)(raw)
|
|
829
|
+
};
|
|
830
|
+
}
|
|
831
|
+
/**
|
|
832
|
+
* Read COMP (voice compression) level during transmission
|
|
833
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
834
|
+
* @returns Compression level with raw value and percentage
|
|
835
|
+
* @example
|
|
836
|
+
* const comp = await rig.readCompLevel({ timeout: 2000 });
|
|
837
|
+
* if (comp) {
|
|
838
|
+
* console.log(`COMP: ${comp.percent.toFixed(1)}%`);
|
|
839
|
+
* }
|
|
840
|
+
*/
|
|
841
|
+
async readCompLevel(options) {
|
|
842
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
843
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
844
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
845
|
+
const req = IcomRigCommands_1.IcomRigCommands.getCompLevel(ctrAddr, rigAddr);
|
|
846
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x14, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
847
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
848
|
+
if (raw === null)
|
|
849
|
+
return null;
|
|
850
|
+
return {
|
|
851
|
+
raw,
|
|
852
|
+
percent: (raw / 255) * 100
|
|
853
|
+
};
|
|
854
|
+
}
|
|
855
|
+
/**
|
|
856
|
+
* Read power supply voltage
|
|
857
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
858
|
+
* @returns Voltage reading with raw value and volts
|
|
859
|
+
* @example
|
|
860
|
+
* const voltage = await rig.readVoltage({ timeout: 2000 });
|
|
861
|
+
* if (voltage) {
|
|
862
|
+
* console.log(`Voltage: ${voltage.volts.toFixed(2)}V`);
|
|
863
|
+
* }
|
|
864
|
+
*/
|
|
865
|
+
async readVoltage(options) {
|
|
866
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
867
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
868
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
869
|
+
const req = IcomRigCommands_1.IcomRigCommands.getVoltage(ctrAddr, rigAddr);
|
|
870
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x15, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
871
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
872
|
+
if (raw === null)
|
|
873
|
+
return null;
|
|
874
|
+
return {
|
|
875
|
+
raw,
|
|
876
|
+
volts: (0, IcomConstants_1.rawToVoltage)(raw)
|
|
877
|
+
};
|
|
878
|
+
}
|
|
879
|
+
/**
|
|
880
|
+
* Read power supply current draw
|
|
881
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
882
|
+
* @returns Current reading with raw value and amperes
|
|
883
|
+
* @example
|
|
884
|
+
* const current = await rig.readCurrent({ timeout: 2000 });
|
|
885
|
+
* if (current) {
|
|
886
|
+
* console.log(`Current: ${current.amps.toFixed(2)}A`);
|
|
887
|
+
* }
|
|
888
|
+
*/
|
|
889
|
+
async readCurrent(options) {
|
|
890
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
891
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
892
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
893
|
+
const req = IcomRigCommands_1.IcomRigCommands.getCurrent(ctrAddr, rigAddr);
|
|
894
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x16, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
895
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
896
|
+
if (raw === null)
|
|
897
|
+
return null;
|
|
898
|
+
return {
|
|
899
|
+
raw,
|
|
900
|
+
amps: (0, IcomConstants_1.rawToCurrent)(raw)
|
|
901
|
+
};
|
|
902
|
+
}
|
|
403
903
|
static isReplyOf(frame, cmd, ctrAddr, rigAddr) {
|
|
404
904
|
// typical reply FE FE [ctr] [rig] cmd ... FD
|
|
405
905
|
return frame.length >= 7 && frame[0] === 0xfe && frame[1] === 0xfe && frame[4] === (cmd & 0xff);
|
|
@@ -568,10 +1068,42 @@ class IcomControl {
|
|
|
568
1068
|
case IcomPackets_1.Sizes.STATUS: {
|
|
569
1069
|
const civPort = IcomPackets_1.StatusPacket.getRigCivPort(buf);
|
|
570
1070
|
const audioPort = IcomPackets_1.StatusPacket.getRigAudioPort(buf);
|
|
571
|
-
|
|
1071
|
+
const connected = IcomPackets_1.StatusPacket.getIsConnected(buf);
|
|
1072
|
+
const authOK = IcomPackets_1.StatusPacket.authOK(buf);
|
|
1073
|
+
(0, debug_1.dbg)('STATUS <= civPort=', civPort, 'audioPort=', audioPort, 'authOK=', authOK, 'connected=', connected);
|
|
1074
|
+
// If radio reports disconnected, handle immediately
|
|
1075
|
+
if (!connected) {
|
|
1076
|
+
(0, debug_1.dbg)('Radio reported connected=false');
|
|
1077
|
+
// If we're currently attempting to connect, abort immediately (fast-fail)
|
|
1078
|
+
const currentPhase = this.connectionSession.phase;
|
|
1079
|
+
if (currentPhase === types_1.ConnectionPhase.CONNECTING || currentPhase === types_1.ConnectionPhase.RECONNECTING) {
|
|
1080
|
+
(0, debug_1.dbg)('Aborting ongoing connection attempt due to connected=false');
|
|
1081
|
+
this.abortConnectionAttempt(this.connectionSession.sessionId, 'Radio reported connected=false');
|
|
1082
|
+
}
|
|
1083
|
+
else if (currentPhase === types_1.ConnectionPhase.CONNECTED) {
|
|
1084
|
+
// Trigger reconnection for established connection
|
|
1085
|
+
(0, debug_1.dbg)('Triggering reconnection for established connection');
|
|
1086
|
+
this.handleConnectionLost(types_1.SessionType.CONTROL, 0);
|
|
1087
|
+
}
|
|
1088
|
+
break;
|
|
1089
|
+
}
|
|
1090
|
+
// CRITICAL: Ignore STATUS packets with invalid ports (0)
|
|
1091
|
+
// Radio sends multiple STATUS packets during connection:
|
|
1092
|
+
// 1. First with valid ports (e.g., 50002, 50003) when CONNINFO busy=false
|
|
1093
|
+
// 2. Second with port=0 when CONNINFO busy=true (should be ignored!)
|
|
1094
|
+
// If we don't check, the second packet will overwrite the valid ports with 0
|
|
1095
|
+
if (civPort === 0 || audioPort === 0) {
|
|
1096
|
+
(0, debug_1.dbg)('STATUS packet has invalid ports (0) - ignoring to preserve existing valid ports');
|
|
1097
|
+
(0, debug_1.dbg)('This is normal during reconnection when rig sends CONNINFO busy=true');
|
|
1098
|
+
// Still emit status event for monitoring, but don't setRemote
|
|
1099
|
+
const info = { civPort, audioPort, authOK, connected };
|
|
1100
|
+
this.ev.emit('status', info);
|
|
1101
|
+
break;
|
|
1102
|
+
}
|
|
572
1103
|
const info = { civPort, audioPort, authOK: true, connected: true };
|
|
573
1104
|
this.ev.emit('status', info);
|
|
574
|
-
// set remote ports and start
|
|
1105
|
+
// Only set remote ports and start sessions if ports are valid (non-zero)
|
|
1106
|
+
(0, debug_1.dbg)('STATUS has valid ports - setting up CIV/Audio sessions');
|
|
575
1107
|
if (this.civSess) {
|
|
576
1108
|
this.civSess.setRemote(this.options.control.ip, civPort);
|
|
577
1109
|
this.civSess.startAreYouThere();
|
|
@@ -604,10 +1136,7 @@ class IcomControl {
|
|
|
604
1136
|
}
|
|
605
1137
|
const res = { ok, errorCode: IcomPackets_1.LoginResponsePacket.errorNum(buf), connection: IcomPackets_1.LoginResponsePacket.getConnection(buf) };
|
|
606
1138
|
this.ev.emit('login', res);
|
|
607
|
-
|
|
608
|
-
(0, debug_1.dbg)('Login ready - resolving loginReady promise');
|
|
609
|
-
this.resolveLoginReady();
|
|
610
|
-
}
|
|
1139
|
+
// Note: login event is caught by createReadyPromises() listener
|
|
611
1140
|
break;
|
|
612
1141
|
}
|
|
613
1142
|
case IcomPackets_1.Sizes.CAP_CAP: {
|
|
@@ -629,23 +1158,28 @@ class IcomControl {
|
|
|
629
1158
|
}
|
|
630
1159
|
case IcomPackets_1.Sizes.CONNINFO: {
|
|
631
1160
|
// rig sends twice; first time busy=false, reply with our ports
|
|
1161
|
+
// IMPORTANT: During reconnection, rig may send busy=true if old connection not fully cleaned up
|
|
1162
|
+
// We MUST still reply to proceed with connection (otherwise STATUS packet will never arrive)
|
|
632
1163
|
const busy = IcomPackets_1.ConnInfoPacket.getBusy(buf);
|
|
633
1164
|
this.macAddress = IcomPackets_1.ConnInfoPacket.getMacAddress(buf);
|
|
634
1165
|
this.rigName = IcomPackets_1.ConnInfoPacket.getRigName(buf);
|
|
635
1166
|
(0, debug_1.dbg)('CONNINFO <= busy=', busy, 'rigName=', this.rigName);
|
|
636
|
-
if (
|
|
637
|
-
|
|
638
|
-
|
|
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 { }
|
|
1167
|
+
if (busy) {
|
|
1168
|
+
(0, debug_1.dbg)('CONNINFO busy=true detected - likely reconnecting while rig still has old session');
|
|
1169
|
+
(0, debug_1.dbg)('Sending ConnInfo reply anyway to allow STATUS packet delivery');
|
|
648
1170
|
}
|
|
1171
|
+
// ALWAYS send reply (even when busy=true during reconnection)
|
|
1172
|
+
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);
|
|
1173
|
+
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
1174
|
+
this.sess.sendTracked(reply);
|
|
1175
|
+
try {
|
|
1176
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
1177
|
+
const { hex } = require('../utils/codec');
|
|
1178
|
+
(0, debug_1.dbg)('CONNINFO -> reply with local civPort=', this.civSess.localPort, 'audioPort=', this.audioSess.localPort);
|
|
1179
|
+
(0, debug_1.dbgV)('CONNINFO reply hex (first 0x60):', hex(Buffer.from(reply.subarray(0, 0x60))));
|
|
1180
|
+
(0, debug_1.dbgV)('CONNINFO reply hex (0x60..0x90):', hex(Buffer.from(reply.subarray(0x60, 0x90))));
|
|
1181
|
+
}
|
|
1182
|
+
catch { }
|
|
649
1183
|
break;
|
|
650
1184
|
}
|
|
651
1185
|
default: {
|
|
@@ -711,8 +1245,8 @@ class IcomControl {
|
|
|
711
1245
|
if (type === IcomPackets_1.Cmd.I_AM_READY) {
|
|
712
1246
|
this.civ.sendOpenClose(true);
|
|
713
1247
|
this.civSess.startIdle();
|
|
714
|
-
(0, debug_1.dbg)('CIV ready -
|
|
715
|
-
this.
|
|
1248
|
+
(0, debug_1.dbg)('CIV ready - emitting internal _civReady event');
|
|
1249
|
+
this.ev.emit('_civReady');
|
|
716
1250
|
return;
|
|
717
1251
|
}
|
|
718
1252
|
}
|
|
@@ -868,8 +1402,8 @@ class IcomControl {
|
|
|
868
1402
|
// Start continuous audio transmission (like Java's startTxAudio on I_AM_READY)
|
|
869
1403
|
this.audio.start();
|
|
870
1404
|
this.audioSess.startIdle();
|
|
871
|
-
(0, debug_1.dbg)('Audio ready - started continuous audio stream,
|
|
872
|
-
this.
|
|
1405
|
+
(0, debug_1.dbg)('Audio ready - started continuous audio stream, emitting internal _audioReady event');
|
|
1406
|
+
this.ev.emit('_audioReady');
|
|
873
1407
|
return;
|
|
874
1408
|
}
|
|
875
1409
|
}
|
|
@@ -908,5 +1442,223 @@ class IcomControl {
|
|
|
908
1442
|
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
909
1443
|
this.sess.sendTracked(pkt);
|
|
910
1444
|
}
|
|
1445
|
+
// ============================================================================
|
|
1446
|
+
// Connection Monitoring
|
|
1447
|
+
// ============================================================================
|
|
1448
|
+
/**
|
|
1449
|
+
* Configure unified connection monitoring
|
|
1450
|
+
* @param config - Monitoring configuration options
|
|
1451
|
+
* @example
|
|
1452
|
+
* rig.configureMonitoring({ timeout: 10000, checkInterval: 2000, autoReconnect: true });
|
|
1453
|
+
*/
|
|
1454
|
+
configureMonitoring(config) {
|
|
1455
|
+
this.monitorConfig = { ...this.monitorConfig, ...config };
|
|
1456
|
+
}
|
|
1457
|
+
/**
|
|
1458
|
+
* Get current connection state for all sessions
|
|
1459
|
+
* @returns Object with connection state for each session
|
|
1460
|
+
*/
|
|
1461
|
+
getConnectionState() {
|
|
1462
|
+
const now = Date.now();
|
|
1463
|
+
const isTimedOut = (sess) => {
|
|
1464
|
+
if (sess['destroyed'])
|
|
1465
|
+
return true;
|
|
1466
|
+
return (now - sess.lastReceivedTime) > this.monitorConfig.timeout;
|
|
1467
|
+
};
|
|
1468
|
+
return {
|
|
1469
|
+
control: isTimedOut(this.sess) ? types_1.ConnectionState.DISCONNECTED : types_1.ConnectionState.CONNECTED,
|
|
1470
|
+
civ: isTimedOut(this.civSess) ? types_1.ConnectionState.DISCONNECTED : types_1.ConnectionState.CONNECTED,
|
|
1471
|
+
audio: isTimedOut(this.audioSess) ? types_1.ConnectionState.DISCONNECTED : types_1.ConnectionState.CONNECTED
|
|
1472
|
+
};
|
|
1473
|
+
}
|
|
1474
|
+
/**
|
|
1475
|
+
* Check if any session has lost connection
|
|
1476
|
+
* @returns true if any session is disconnected
|
|
1477
|
+
*/
|
|
1478
|
+
isAnySessionDisconnected() {
|
|
1479
|
+
const state = this.getConnectionState();
|
|
1480
|
+
return state.control === types_1.ConnectionState.DISCONNECTED ||
|
|
1481
|
+
state.civ === types_1.ConnectionState.DISCONNECTED ||
|
|
1482
|
+
state.audio === types_1.ConnectionState.DISCONNECTED;
|
|
1483
|
+
}
|
|
1484
|
+
/**
|
|
1485
|
+
* Handle connection lost event from a session
|
|
1486
|
+
* Simplified strategy: any session loss triggers full reconnect
|
|
1487
|
+
* @private
|
|
1488
|
+
*/
|
|
1489
|
+
handleConnectionLost(sessionType, timeSinceLastData) {
|
|
1490
|
+
// Record disconnect time for downtime calculation
|
|
1491
|
+
this.connectionSession.lastDisconnectTime = Date.now();
|
|
1492
|
+
const info = {
|
|
1493
|
+
sessionType,
|
|
1494
|
+
reason: `No data received for ${timeSinceLastData}ms`,
|
|
1495
|
+
timeSinceLastData,
|
|
1496
|
+
timestamp: this.connectionSession.lastDisconnectTime
|
|
1497
|
+
};
|
|
1498
|
+
(0, debug_1.dbg)(`Connection lost: ${sessionType} session (${timeSinceLastData}ms since last data)`);
|
|
1499
|
+
this.ev.emit('connectionLost', info);
|
|
1500
|
+
// Check if auto-reconnect is enabled
|
|
1501
|
+
if (!this.monitorConfig.autoReconnect) {
|
|
1502
|
+
(0, debug_1.dbg)(`Auto-reconnect disabled, not attempting reconnect`);
|
|
1503
|
+
// Transition to IDLE since we won't reconnect
|
|
1504
|
+
this.transitionTo(types_1.ConnectionPhase.IDLE, 'Connection lost, auto-reconnect disabled');
|
|
1505
|
+
return;
|
|
1506
|
+
}
|
|
1507
|
+
// Validate state transition to RECONNECTING
|
|
1508
|
+
if (!this.canTransitionTo(types_1.ConnectionPhase.RECONNECTING)) {
|
|
1509
|
+
(0, debug_1.dbg)(`Cannot transition to RECONNECTING from ${this.connectionSession.phase} - skipping reconnect`);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
// Simplified strategy: any session loss → full reconnect
|
|
1513
|
+
// This is more reliable than trying to reconnect individual sessions
|
|
1514
|
+
(0, debug_1.dbg)(`${sessionType} session lost - initiating full reconnect`);
|
|
1515
|
+
this.scheduleFullReconnect();
|
|
1516
|
+
}
|
|
1517
|
+
/**
|
|
1518
|
+
* Schedule a full reconnection (all sessions)
|
|
1519
|
+
* Uses simple while loop with exponential backoff
|
|
1520
|
+
* @private
|
|
1521
|
+
*/
|
|
1522
|
+
async scheduleFullReconnect() {
|
|
1523
|
+
// Prevent multiple concurrent reconnect attempts
|
|
1524
|
+
if (this.connectionSession.phase === types_1.ConnectionPhase.RECONNECTING) {
|
|
1525
|
+
(0, debug_1.dbg)('Full reconnect already in progress, skipping');
|
|
1526
|
+
return;
|
|
1527
|
+
}
|
|
1528
|
+
// Transition to RECONNECTING state
|
|
1529
|
+
this.transitionTo(types_1.ConnectionPhase.RECONNECTING, 'Starting full reconnect');
|
|
1530
|
+
let attempt = 0;
|
|
1531
|
+
const disconnectTime = this.connectionSession.lastDisconnectTime || Date.now();
|
|
1532
|
+
try {
|
|
1533
|
+
while (true) {
|
|
1534
|
+
attempt++;
|
|
1535
|
+
const delay = this.calculateReconnectDelay(attempt);
|
|
1536
|
+
// Emit reconnect attempting event
|
|
1537
|
+
this.ev.emit('reconnectAttempting', {
|
|
1538
|
+
sessionType: types_1.SessionType.CONTROL,
|
|
1539
|
+
attemptNumber: attempt,
|
|
1540
|
+
delay,
|
|
1541
|
+
timestamp: Date.now(),
|
|
1542
|
+
fullReconnect: true
|
|
1543
|
+
});
|
|
1544
|
+
(0, debug_1.dbg)(`Full reconnect attempt #${attempt} (delay: ${delay}ms)`);
|
|
1545
|
+
await this.sleep(delay);
|
|
1546
|
+
try {
|
|
1547
|
+
// Disconnect all sessions
|
|
1548
|
+
(0, debug_1.dbg)('Full reconnect: disconnecting all sessions');
|
|
1549
|
+
await this.disconnect();
|
|
1550
|
+
// Wait longer before reconnecting to allow rig to fully clean up old connection
|
|
1551
|
+
// This is critical - rig may report CONNINFO busy=true if we reconnect too quickly
|
|
1552
|
+
(0, debug_1.dbg)('Full reconnect: waiting 5s for rig to clean up old session...');
|
|
1553
|
+
await this.sleep(5000);
|
|
1554
|
+
// Reconnect with timeout (uses new state-machine-based connect())
|
|
1555
|
+
(0, debug_1.dbg)('Full reconnect: reconnecting');
|
|
1556
|
+
await this.connectWithTimeout(30000);
|
|
1557
|
+
// Success! Calculate downtime and emit connectionRestored event
|
|
1558
|
+
const downtime = Date.now() - disconnectTime;
|
|
1559
|
+
(0, debug_1.dbg)(`Full reconnect successful! Downtime: ${downtime}ms`);
|
|
1560
|
+
const finalState = this.getConnectionState();
|
|
1561
|
+
(0, debug_1.dbg)(`Reconnect complete - All sessions: Control=${finalState.control}, CIV=${finalState.civ}, Audio=${finalState.audio}`);
|
|
1562
|
+
// Emit connectionRestored event (fixes Problem #5)
|
|
1563
|
+
this.ev.emit('connectionRestored', {
|
|
1564
|
+
sessionType: types_1.SessionType.CONTROL,
|
|
1565
|
+
downtime,
|
|
1566
|
+
timestamp: Date.now()
|
|
1567
|
+
});
|
|
1568
|
+
// State is already CONNECTED from connect() call
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
catch (err) {
|
|
1572
|
+
const errorMsg = err instanceof Error ? err.message : String(err);
|
|
1573
|
+
(0, debug_1.dbg)('Full reconnect failed:', errorMsg);
|
|
1574
|
+
// Get current connection state for diagnostics
|
|
1575
|
+
const state = this.getConnectionState();
|
|
1576
|
+
(0, debug_1.dbg)(`Current state after failed reconnect: Control=${state.control}, CIV=${state.civ}, Audio=${state.audio}`);
|
|
1577
|
+
// Check if we should retry
|
|
1578
|
+
const maxAttempts = this.monitorConfig.maxReconnectAttempts;
|
|
1579
|
+
const willRetry = maxAttempts === undefined || attempt < maxAttempts;
|
|
1580
|
+
// Emit reconnect failed event
|
|
1581
|
+
this.ev.emit('reconnectFailed', {
|
|
1582
|
+
sessionType: types_1.SessionType.CONTROL,
|
|
1583
|
+
attemptNumber: attempt,
|
|
1584
|
+
error: errorMsg,
|
|
1585
|
+
timestamp: Date.now(),
|
|
1586
|
+
fullReconnect: true,
|
|
1587
|
+
willRetry,
|
|
1588
|
+
nextRetryDelay: willRetry ? this.calculateReconnectDelay(attempt + 1) : undefined
|
|
1589
|
+
});
|
|
1590
|
+
if (!willRetry) {
|
|
1591
|
+
(0, debug_1.dbg)(`Max reconnect attempts (${maxAttempts}) reached, giving up`);
|
|
1592
|
+
(0, debug_1.dbg)(`Final state: Control=${state.control}, CIV=${state.civ}, Audio=${state.audio}`);
|
|
1593
|
+
// Transition to IDLE since we're giving up
|
|
1594
|
+
this.transitionTo(types_1.ConnectionPhase.IDLE, 'Max reconnect attempts reached');
|
|
1595
|
+
return;
|
|
1596
|
+
}
|
|
1597
|
+
// Continue loop for retry
|
|
1598
|
+
(0, debug_1.dbg)(`Will retry in ${this.calculateReconnectDelay(attempt + 1)}ms...`);
|
|
1599
|
+
}
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
catch (err) {
|
|
1603
|
+
// Unexpected error in reconnect loop
|
|
1604
|
+
(0, debug_1.dbg)('Unexpected error in scheduleFullReconnect:', err);
|
|
1605
|
+
this.transitionTo(types_1.ConnectionPhase.IDLE, 'Reconnect loop error');
|
|
1606
|
+
}
|
|
1607
|
+
}
|
|
1608
|
+
/**
|
|
1609
|
+
* Connect with timeout (helper for reconnection)
|
|
1610
|
+
* @private
|
|
1611
|
+
*/
|
|
1612
|
+
async connectWithTimeout(timeout) {
|
|
1613
|
+
const timeoutPromise = this.sleep(timeout).then(() => {
|
|
1614
|
+
throw new Error(`Connection timeout after ${timeout}ms`);
|
|
1615
|
+
});
|
|
1616
|
+
await Promise.race([
|
|
1617
|
+
this.connect(),
|
|
1618
|
+
timeoutPromise
|
|
1619
|
+
]);
|
|
1620
|
+
}
|
|
1621
|
+
/**
|
|
1622
|
+
* Sleep helper (returns a Promise)
|
|
1623
|
+
* @private
|
|
1624
|
+
*/
|
|
1625
|
+
sleep(ms) {
|
|
1626
|
+
return new Promise(resolve => setTimeout(resolve, ms));
|
|
1627
|
+
}
|
|
1628
|
+
/**
|
|
1629
|
+
* Calculate reconnect delay using exponential backoff
|
|
1630
|
+
* @private
|
|
1631
|
+
*/
|
|
1632
|
+
calculateReconnectDelay(attemptNumber) {
|
|
1633
|
+
const delay = this.monitorConfig.reconnectBaseDelay * Math.pow(2, attemptNumber - 1);
|
|
1634
|
+
return Math.min(delay, this.monitorConfig.reconnectMaxDelay);
|
|
1635
|
+
}
|
|
1636
|
+
// ============================================================================
|
|
1637
|
+
// Public Observability APIs
|
|
1638
|
+
// ============================================================================
|
|
1639
|
+
/**
|
|
1640
|
+
* Get current connection phase
|
|
1641
|
+
* @returns Current connection phase (IDLE, CONNECTING, CONNECTED, etc.)
|
|
1642
|
+
*/
|
|
1643
|
+
getConnectionPhase() {
|
|
1644
|
+
return this.connectionSession.phase;
|
|
1645
|
+
}
|
|
1646
|
+
/**
|
|
1647
|
+
* Get detailed connection metrics for monitoring and diagnostics
|
|
1648
|
+
* @returns Connection metrics including phase, uptime, session states
|
|
1649
|
+
*/
|
|
1650
|
+
getConnectionMetrics() {
|
|
1651
|
+
const now = Date.now();
|
|
1652
|
+
return {
|
|
1653
|
+
phase: this.connectionSession.phase,
|
|
1654
|
+
sessionId: this.connectionSession.sessionId,
|
|
1655
|
+
uptime: this.connectionSession.phase === types_1.ConnectionPhase.CONNECTED
|
|
1656
|
+
? now - this.connectionSession.startTime
|
|
1657
|
+
: 0,
|
|
1658
|
+
sessions: this.getConnectionState(),
|
|
1659
|
+
lastDisconnectTime: this.connectionSession.lastDisconnectTime,
|
|
1660
|
+
isReconnecting: this.connectionSession.phase === types_1.ConnectionPhase.RECONNECTING
|
|
1661
|
+
};
|
|
1662
|
+
}
|
|
911
1663
|
}
|
|
912
1664
|
exports.IcomControl = IcomControl;
|