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 CHANGED
@@ -1,4 +1,4 @@
1
- # @boybook/icom-wlan
1
+ # icom-wlan-node
2
2
 
3
3
  Icom WLAN (UDP) protocol implementation in Node.js + TypeScript, featuring:
4
4
 
@@ -16,7 +16,7 @@ Acknowledgements: Thanks to FT8CN (https://github.com/N0BOY/FT8CN) for sharing p
16
16
  ## Install
17
17
 
18
18
  ```
19
- npm install @boybook/icom-wlan
19
+ npm install icom-wlan-node
20
20
  ```
21
21
 
22
22
  Build from source:
@@ -29,7 +29,7 @@ npm run build
29
29
  ## Quick Start
30
30
 
31
31
  ```ts
32
- import { IcomControl, AUDIO_RATE } from '@boybook/icom-wlan';
32
+ import { IcomControl, AUDIO_RATE } from 'icom-wlan-node';
33
33
 
34
34
  const rig = new IcomControl({
35
35
  control: { ip: '192.168.1.50', port: 50001 },
@@ -106,37 +106,175 @@ await rig.setPtt(false);
106
106
  - `civFrame(Buffer)` — one complete CI‑V frame (FE FE ... FD)
107
107
  - `audio({ pcm16: Buffer })` — audio frames
108
108
  - `error(Error)` — UDP errors
109
+ - `connectionLost(ConnectionLostInfo)` — session timeout detected
110
+ - `connectionRestored(ConnectionRestoredInfo)` — reconnected successfully
111
+ - `reconnectAttempting(ReconnectAttemptInfo)` — reconnect attempt started
112
+ - `reconnectFailed(ReconnectFailedInfo)` — reconnect attempt failed
109
113
  - Methods
110
114
  - `connect()` / `disconnect()` — connects control + CIV + audio sub‑sessions; resolves when all ready
111
115
  - `sendCiv(buf: Buffer)` — send a raw CI‑V frame
112
116
  - `setPtt(on: boolean)` — key/unkey; also manages TX meter polling and audio tailing
113
117
  - `sendAudioFloat32(samples: Float32Array, addLeadingBuffer?: boolean)` / `sendAudioPcm16(samples: Int16Array)`
118
+ - `getConnectionPhase()` — returns current ConnectionPhase (IDLE, CONNECTING, CONNECTED, DISCONNECTING, RECONNECTING)
119
+ - `getConnectionMetrics()` — returns detailed ConnectionMetrics (phase, uptime, session states, etc.)
120
+ - `getConnectionState()` — returns per‑session ConnectionState (control, civ, audio)
121
+ - `isAnySessionDisconnected()` — returns true if any session is disconnected
122
+ - `configureMonitoring(config)` — configure connection monitoring and auto‑reconnect behavior
123
+
124
+ ### Connection Management & Auto-Reconnect
125
+
126
+ The library features a robust state machine for connection lifecycle management with automatic reconnection support.
127
+
128
+ #### Connection State Machine
129
+
130
+ ```ts
131
+ ConnectionPhase: IDLE → CONNECTING → CONNECTED → DISCONNECTING
132
+ ↓ ↓
133
+ RECONNECTING ←────────────┘
134
+ ```
135
+
136
+ #### Basic Usage
137
+
138
+ ```ts
139
+ // Connect (idempotent - safe to call multiple times)
140
+ await rig.connect();
141
+
142
+ // Query connection phase
143
+ const phase = rig.getConnectionPhase(); // 'IDLE' | 'CONNECTING' | 'CONNECTED' | ...
144
+
145
+ // Get detailed metrics
146
+ const metrics = rig.getConnectionMetrics();
147
+ console.log(metrics.phase); // Current phase
148
+ console.log(metrics.uptime); // Milliseconds since connected
149
+ console.log(metrics.sessions); // Per-session states {control, civ, audio}
150
+
151
+ // Disconnect (also idempotent)
152
+ await rig.disconnect();
153
+ ```
154
+
155
+ #### Connection Monitoring Events
156
+
157
+ ```ts
158
+ // Connection lost (any session timeout)
159
+ rig.events.on('connectionLost', (info) => {
160
+ console.error(`Lost: ${info.sessionType}, idle: ${info.timeSinceLastData}ms`);
161
+ });
162
+
163
+ // Connection restored after reconnect
164
+ rig.events.on('connectionRestored', (info) => {
165
+ console.log(`Restored after ${info.downtime}ms downtime`);
166
+ });
167
+
168
+ // Reconnect attempt started
169
+ rig.events.on('reconnectAttempting', (info) => {
170
+ console.log(`Reconnect attempt #${info.attemptNumber}, delay: ${info.delay}ms`);
171
+ });
172
+
173
+ // Reconnect attempt failed
174
+ rig.events.on('reconnectFailed', (info) => {
175
+ console.error(`Attempt #${info.attemptNumber} failed: ${info.error}`);
176
+ if (!info.willRetry) console.error('Giving up - max retries reached');
177
+ });
178
+ ```
179
+
180
+ #### Auto-Reconnect Configuration
181
+
182
+ ```ts
183
+ rig.configureMonitoring({
184
+ timeout: 8000, // Session timeout: 8s (default: 5s)
185
+ checkInterval: 1000, // Check every 1s (default: 1s)
186
+ autoReconnect: true, // Enable auto-reconnect (default: false)
187
+ maxReconnectAttempts: 10, // Max retries (default: undefined = infinite)
188
+ reconnectBaseDelay: 2000, // Base delay: 2s (default: 2s)
189
+ reconnectMaxDelay: 30000 // Max delay: 30s (default: 30s, uses exponential backoff)
190
+ });
191
+ ```
192
+
193
+ **Exponential Backoff**: Delays are `baseDelay × 2^(attempt-1)`, capped at `maxDelay`.
194
+ Example: 2s → 4s → 8s → 16s → 30s (capped) → 30s ...
195
+
196
+ #### Error Handling
197
+
198
+ **Common Errors**:
199
+
200
+ ```ts
201
+ try {
202
+ await rig.connect();
203
+ } catch (err) {
204
+ if (err.message.includes('timeout')) {
205
+ // Connection timeout (no response from radio)
206
+ } else if (err.message.includes('Login failed')) {
207
+ // Authentication error (check userName/password)
208
+ } else if (err.message.includes('Radio reported connected=false')) {
209
+ // Radio rejected connection (may be busy with another client)
210
+ } else if (err.message.includes('Cannot connect while disconnecting')) {
211
+ // Invalid state transition (wait for disconnect to complete)
212
+ }
213
+ }
214
+
215
+ // Listen for UDP errors
216
+ rig.events.on('error', (err) => {
217
+ console.error('UDP error:', err.message);
218
+ // Network issues, invalid packets, etc.
219
+ });
220
+ ```
221
+
222
+ **Connection States to Handle**:
223
+ - **CONNECTING**: Wait or show "connecting..." UI
224
+ - **CONNECTED**: Normal operation
225
+ - **RECONNECTING**: Show "reconnecting..." UI, disable TX
226
+ - **DISCONNECTING**: Cleanup in progress
227
+ - **IDLE**: Not connected
114
228
 
115
229
  ### High‑Level API
116
230
 
117
231
  The library exposes common CI‑V operations as friendly methods. Addresses are handled internally (`ctrAddr=0xe0`, `rigAddr` discovered via capabilities).
118
232
 
119
- - `setFrequency(hz: number)`
120
- - `setMode(mode: IcomMode | number, { dataMode?: boolean })`
121
- - `setPtt(on: boolean)`
233
+ #### Rig Control
234
+
235
+ - `setFrequency(hz: number)` — Set operating frequency in Hz
236
+ - `setMode(mode: IcomMode | number, options?: { dataMode?: boolean })` — Set mode (supports string or numeric code)
237
+ - `setPtt(on: boolean)` — Key/unkey transmitter
238
+
239
+ **Supported Modes** (IcomMode string constants):
240
+ - `'LSB'`, `'USB'`, `'AM'`, `'CW'`, `'RTTY'`, `'FM'`, `'WFM'`, `'CW_R'`, `'RTTY_R'`, `'DV'`
241
+ - Or use numeric codes: `0x00` (LSB), `0x01` (USB), `0x02` (AM), etc.
242
+
243
+ #### Rig Query
244
+
122
245
  - `readOperatingFrequency(options?: QueryOptions) => Promise<number|null>`
123
246
  - `readOperatingMode(options?: QueryOptions) => Promise<{ mode: number; filter?: number; modeName?: string; filterName?: string } | null>`
124
247
  - `readTransmitFrequency(options?: QueryOptions) => Promise<number|null>`
125
248
  - `readTransceiverState(options?: QueryOptions) => Promise<'TX' | 'RX' | 'UNKNOWN' | null>`
126
249
  - `readBandEdges(options?: QueryOptions) => Promise<Buffer|null>`
250
+
251
+ #### Meters & Levels
252
+
127
253
  - `readSWR(options?: QueryOptions) => Promise<{ raw: number; swr: number; alert: boolean } | null>`
128
254
  - `readALC(options?: QueryOptions) => Promise<{ raw: number; percent: number; alert: boolean } | null>`
129
255
  - `getConnectorWLanLevel(options?: QueryOptions) => Promise<{ raw: number; percent: number } | null>`
130
256
  - `getLevelMeter(options?: QueryOptions) => Promise<{ raw: number; percent: number } | null>`
131
- - `setConnectorWLanLevel(level: number)`
132
- - `setConnectorDataMode(mode: ConnectorDataMode | number)`
257
+ - `setConnectorWLanLevel(level: number)` — Set WLAN audio level (0-255)
258
+
259
+ #### Connector Settings
133
260
 
134
- Examples:
261
+ - `setConnectorDataMode(mode: ConnectorDataMode | number)` — Set data routing mode (supports string or numeric)
262
+
263
+ **Supported Connector Modes** (ConnectorDataMode string constants):
264
+ - `'MIC'` (0x00), `'ACC'` (0x01), `'USB'` (0x02), `'WLAN'` (0x03)
265
+
266
+ #### Examples
135
267
 
136
268
  ```ts
137
- // Set frequency and mode (USB-D)
269
+ // Set frequency and mode using string constants
138
270
  await rig.setFrequency(14074000);
139
- await rig.setMode(0x01, { dataMode: true }); // USB=0x01, data mode
271
+ await rig.setMode('USB', { dataMode: true }); // USB-D for FT8
272
+
273
+ // Or use numeric codes
274
+ await rig.setMode(0x01, { dataMode: true }); // USB=0x01
275
+
276
+ // Set LSB mode
277
+ await rig.setMode('LSB');
140
278
 
141
279
  // Query current frequency (Hz)
142
280
  const hz = await rig.readOperatingFrequency({ timeout: 3000 });
@@ -156,12 +294,15 @@ await rig.setPtt(false);
156
294
  const swr = await rig.readSWR({ timeout: 2000 });
157
295
  const alc = await rig.readALC({ timeout: 2000 });
158
296
  const wlanLevel = await rig.getConnectorWLanLevel({ timeout: 2000 });
159
- const level = await rig.getLevelMeter({ timeout: 1500 });
160
- await rig.setConnectorWLanLevel(0x0120);
161
- await rig.setConnectorDataMode(0x01); // e.g., DATA
162
297
 
163
- if (level) {
164
- console.log(`Generic Level Meter: raw=${level.raw} (${level.percent.toFixed(1)}%)`);
298
+ // Set connector to WLAN mode using string constant
299
+ await rig.setConnectorDataMode('WLAN');
300
+ // Or numeric: await rig.setConnectorDataMode(0x03);
301
+
302
+ await rig.setConnectorWLanLevel(120); // Set WLAN audio level
303
+
304
+ if (wlanLevel) {
305
+ console.log(`WLAN Level: ${wlanLevel.percent.toFixed(1)}%`);
165
306
  }
166
307
  ```
167
308
 
@@ -1,4 +1,5 @@
1
1
  import { UdpClient } from '../transport/UdpClient';
2
+ import { SessionType } from '../types';
2
3
  export interface SessionOptions {
3
4
  ip: string;
4
5
  port: number;
@@ -19,10 +20,12 @@ export declare class Session {
19
20
  lastSentTime: number;
20
21
  lastReceivedTime: number;
21
22
  private address;
23
+ private handlers;
22
24
  private txHistory;
23
25
  private areYouThereTimer?;
24
26
  private pingTimer?;
25
27
  private destroyed;
28
+ sessionType?: SessionType;
26
29
  constructor(address: SessionOptions, handlers: SessionHandlers);
27
30
  open(): void;
28
31
  close(): void;
@@ -40,4 +43,10 @@ export declare class Session {
40
43
  stopPing(): void;
41
44
  stopTimers(): void;
42
45
  setRemote(ip: string, port: number): void;
46
+ /**
47
+ * Reset session state to initial values
48
+ * Call this before reconnecting to ensure clean state
49
+ * (especially important after radio restart)
50
+ */
51
+ resetState(): void;
43
52
  }
@@ -19,21 +19,24 @@ class Session {
19
19
  this.txHistory = new Map();
20
20
  this.destroyed = false;
21
21
  this.address = address;
22
+ this.handlers = handlers;
22
23
  this.udp.on('data', (rinfo, data) => {
23
24
  if (this.destroyed)
24
25
  return;
25
26
  this.lastReceivedTime = Date.now();
26
27
  try {
27
- // Peek type directly to avoid late requires during teardown
28
- const t = data.length >= 6 ? data.readUInt16LE(4) : -1;
29
- (0, debug_1.dbgV)(`RX port=${this.localPort} from ${rinfo.address}:${rinfo.port} len=${data.length} type=${t >= 0 ? '0x' + t.toString(16) : 'n/a'}`);
28
+ const type = data.length >= 6 ? data.readUInt16LE(4) : -1;
29
+ (0, debug_1.dbgV)(`RX port=${this.localPort} from ${rinfo.address}:${rinfo.port} len=${data.length} type=${type >= 0 ? '0x' + type.toString(16) : 'n/a'}`);
30
30
  }
31
31
  catch { }
32
32
  handlers.onData(data);
33
33
  });
34
34
  this.udp.on('error', handlers.onSendError);
35
35
  }
36
- open() { this.udp.open(); }
36
+ open() {
37
+ this.destroyed = false; // Reset destroyed flag to allow sending data after reconnection
38
+ this.udp.open();
39
+ }
37
40
  close() {
38
41
  this.stopTimers();
39
42
  this.destroyed = true;
@@ -78,8 +81,9 @@ class Session {
78
81
  } }
79
82
  startAreYouThere() {
80
83
  this.stopAreYouThere();
84
+ (0, debug_1.dbg)(`Starting AreYouThere timer for ${this.address.ip}:${this.address.port}`);
81
85
  this.areYouThereTimer = setInterval(() => {
82
- (0, debug_1.dbgV)(`AYT -> ${this.address.ip}:${this.address.port} localId=${this.localId}`);
86
+ (0, debug_1.dbg)(`AYT -> ${this.address.ip}:${this.address.port} localId=${this.localId}`);
83
87
  this.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.ARE_YOU_THERE, 0, this.localId, 0));
84
88
  }, 500);
85
89
  }
@@ -101,5 +105,30 @@ class Session {
101
105
  setRemote(ip, port) {
102
106
  this.address = { ip, port };
103
107
  }
108
+ /**
109
+ * Reset session state to initial values
110
+ * Call this before reconnecting to ensure clean state
111
+ * (especially important after radio restart)
112
+ */
113
+ resetState() {
114
+ // Reset destroyed flag to ensure session is usable after state reset
115
+ this.destroyed = false;
116
+ // Generate new IDs
117
+ this.localId = Date.now() >>> 0;
118
+ this.remoteId = 0;
119
+ // Reset sequence numbers
120
+ this.trackedSeq = 1;
121
+ this.pingSeq = 0;
122
+ this.innerSeq = 0x30;
123
+ // Reset tokens
124
+ this.rigToken = 0;
125
+ this.localToken = (Date.now() & 0xffff) >>> 0;
126
+ // Reset timestamps
127
+ this.lastSentTime = Date.now();
128
+ this.lastReceivedTime = Date.now();
129
+ // Clear history
130
+ this.txHistory.clear();
131
+ (0, debug_1.dbgV)(`Session state reset: localId=${this.localId}, localToken=${this.localToken}`);
132
+ }
104
133
  }
105
134
  exports.Session = Session;
package/dist/index.js CHANGED
@@ -15,7 +15,7 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
15
15
  };
16
16
  Object.defineProperty(exports, "__esModule", { value: true });
17
17
  exports.AUDIO_RATE = exports.IcomRigCommands = exports.intToTwoByteBcd = exports.parseTwoByteBcd = exports.getFilterString = exports.getConnectorModeString = exports.getModeString = exports.getConnectorModeCode = exports.getModeCode = exports.METER_THRESHOLDS = exports.DEFAULT_CONTROLLER_ADDR = exports.CONNECTOR_MODE_MAP = exports.MODE_MAP = exports.IcomControl = void 0;
18
- // Export types
18
+ // Export types (includes ConnectionPhase, ConnectionMetrics, etc.)
19
19
  __exportStar(require("./types"), exports);
20
20
  // Export main class
21
21
  var IcomControl_1 = require("./rig/IcomControl");
@@ -12,9 +12,14 @@ class IcomCiv {
12
12
  }
13
13
  start() {
14
14
  this.stop();
15
- // keep-alive open/close
15
+ // CIV session watchdog: Keep-alive mechanism to maintain CIV connection
16
+ // Matches FT8CN's startCivDataTimer() implementation:
17
+ // - Checks every 500ms if CIV session is receiving data
18
+ // - If no data received for >2000ms, sends OpenClose(true) to re-establish connection
19
+ // - This prevents silent disconnection where radio stops responding without error
16
20
  this.openTimer = setInterval(() => {
17
21
  if (Date.now() - this.sess.lastReceivedTime > 2000) {
22
+ (0, debug_1.dbg)('CIV watchdog: No data for >2s, sending OpenClose to keep alive');
18
23
  this.sendOpenClose(true);
19
24
  }
20
25
  }, 500);
@@ -1,4 +1,4 @@
1
- import { IcomRigOptions, RigEventEmitter, IcomMode, ConnectorDataMode, SetModeOptions, QueryOptions, SwrReading, AlcReading, WlanLevelReading, LevelMeterReading } from '../types';
1
+ import { IcomRigOptions, RigEventEmitter, IcomMode, ConnectorDataMode, SetModeOptions, QueryOptions, SwrReading, AlcReading, WlanLevelReading, LevelMeterReading, ConnectionState, ConnectionMonitorConfig, ConnectionPhase, ConnectionMetrics } from '../types';
2
2
  import { IcomCiv } from './IcomCiv';
3
3
  import { IcomAudio } from './IcomAudio';
4
4
  export declare class IcomControl {
@@ -14,15 +14,58 @@ export declare class IcomControl {
14
14
  private tokenTimer?;
15
15
  private civAssembleBuf;
16
16
  private meterTimer?;
17
- private loginReady;
18
- private civReady;
19
- private audioReady;
20
- private resolveLoginReady;
21
- private resolveCivReady;
22
- private resolveAudioReady;
17
+ private connectionSession;
18
+ private nextSessionId;
19
+ private abortHandlers;
20
+ private monitorTimer?;
21
+ private monitorConfig;
23
22
  constructor(options: IcomRigOptions);
24
23
  get events(): RigEventEmitter;
24
+ /**
25
+ * Transition to a new connection phase with logging
26
+ * @private
27
+ */
28
+ private transitionTo;
29
+ /**
30
+ * Validate if a state transition is legal
31
+ * @private
32
+ */
33
+ private canTransitionTo;
34
+ /**
35
+ * Abort an ongoing connection attempt by session ID
36
+ * @private
37
+ */
38
+ private abortConnectionAttempt;
39
+ /**
40
+ * Connect to the rig
41
+ * Idempotent: multiple calls during CONNECTING phase are safe
42
+ * @throws Error if called during DISCONNECTING phase
43
+ */
25
44
  connect(): Promise<void>;
45
+ /**
46
+ * Internal connection implementation
47
+ * Uses local promises to avoid race conditions
48
+ * Uses phased timeout: 30s for overall, 10s for sub-sessions after login
49
+ * @param sessionId - Unique session ID to prevent race conditions
50
+ */
51
+ private _doConnect;
52
+ /**
53
+ * Create local promises for connection readiness
54
+ * This avoids race conditions with instance variables
55
+ * @param sessionId - Connection session ID for abort handler tracking
56
+ */
57
+ private createReadyPromises;
58
+ /**
59
+ * Start unified connection monitoring
60
+ * Monitors all three sessions from a single timer to avoid race conditions
61
+ * @private
62
+ */
63
+ private startUnifiedMonitoring;
64
+ /**
65
+ * Stop unified connection monitoring
66
+ * @private
67
+ */
68
+ private stopUnifiedMonitoring;
26
69
  disconnect(): Promise<void>;
27
70
  sendCiv(data: Buffer): void;
28
71
  /**
@@ -152,4 +195,62 @@ export declare class IcomControl {
152
195
  private stopMeterPolling;
153
196
  private onAudioData;
154
197
  private sendConnectionRequest;
198
+ /**
199
+ * Configure unified connection monitoring
200
+ * @param config - Monitoring configuration options
201
+ * @example
202
+ * rig.configureMonitoring({ timeout: 10000, checkInterval: 2000, autoReconnect: true });
203
+ */
204
+ configureMonitoring(config: ConnectionMonitorConfig): void;
205
+ /**
206
+ * Get current connection state for all sessions
207
+ * @returns Object with connection state for each session
208
+ */
209
+ getConnectionState(): {
210
+ control: ConnectionState;
211
+ civ: ConnectionState;
212
+ audio: ConnectionState;
213
+ };
214
+ /**
215
+ * Check if any session has lost connection
216
+ * @returns true if any session is disconnected
217
+ */
218
+ isAnySessionDisconnected(): boolean;
219
+ /**
220
+ * Handle connection lost event from a session
221
+ * Simplified strategy: any session loss triggers full reconnect
222
+ * @private
223
+ */
224
+ private handleConnectionLost;
225
+ /**
226
+ * Schedule a full reconnection (all sessions)
227
+ * Uses simple while loop with exponential backoff
228
+ * @private
229
+ */
230
+ private scheduleFullReconnect;
231
+ /**
232
+ * Connect with timeout (helper for reconnection)
233
+ * @private
234
+ */
235
+ private connectWithTimeout;
236
+ /**
237
+ * Sleep helper (returns a Promise)
238
+ * @private
239
+ */
240
+ private sleep;
241
+ /**
242
+ * Calculate reconnect delay using exponential backoff
243
+ * @private
244
+ */
245
+ private calculateReconnectDelay;
246
+ /**
247
+ * Get current connection phase
248
+ * @returns Current connection phase (IDLE, CONNECTING, CONNECTED, etc.)
249
+ */
250
+ getConnectionPhase(): ConnectionPhase;
251
+ /**
252
+ * Get detailed connection metrics for monitoring and diagnostics
253
+ * @returns Connection metrics including phase, uptime, session states
254
+ */
255
+ getConnectionMetrics(): ConnectionMetrics;
155
256
  }