icom-wlan-node 0.1.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/LICENSE +21 -0
- package/README.md +204 -0
- package/dist/core/IcomPackets.d.ts +104 -0
- package/dist/core/IcomPackets.js +333 -0
- package/dist/core/Session.d.ts +43 -0
- package/dist/core/Session.js +105 -0
- package/dist/demo.d.ts +12 -0
- package/dist/demo.js +329 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +42 -0
- package/dist/rig/IcomAudio.d.ts +27 -0
- package/dist/rig/IcomAudio.js +143 -0
- package/dist/rig/IcomCiv.d.ts +14 -0
- package/dist/rig/IcomCiv.js +44 -0
- package/dist/rig/IcomConstants.d.ts +84 -0
- package/dist/rig/IcomConstants.js +112 -0
- package/dist/rig/IcomControl.d.ts +155 -0
- package/dist/rig/IcomControl.js +912 -0
- package/dist/rig/IcomRigCommands.d.ts +17 -0
- package/dist/rig/IcomRigCommands.js +73 -0
- package/dist/transport/UdpClient.d.ts +14 -0
- package/dist/transport/UdpClient.js +47 -0
- package/dist/types.d.ts +160 -0
- package/dist/types.js +2 -0
- package/dist/utils/bcd.d.ts +36 -0
- package/dist/utils/bcd.js +59 -0
- package/dist/utils/codec.d.ts +22 -0
- package/dist/utils/codec.js +56 -0
- package/dist/utils/debug.d.ts +4 -0
- package/dist/utils/debug.js +15 -0
- package/package.json +31 -0
|
@@ -0,0 +1,912 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
14
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
15
|
+
}) : function(o, v) {
|
|
16
|
+
o["default"] = v;
|
|
17
|
+
});
|
|
18
|
+
var __importStar = (this && this.__importStar) || (function () {
|
|
19
|
+
var ownKeys = function(o) {
|
|
20
|
+
ownKeys = Object.getOwnPropertyNames || function (o) {
|
|
21
|
+
var ar = [];
|
|
22
|
+
for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
|
|
23
|
+
return ar;
|
|
24
|
+
};
|
|
25
|
+
return ownKeys(o);
|
|
26
|
+
};
|
|
27
|
+
return function (mod) {
|
|
28
|
+
if (mod && mod.__esModule) return mod;
|
|
29
|
+
var result = {};
|
|
30
|
+
if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
|
|
31
|
+
__setModuleDefault(result, mod);
|
|
32
|
+
return result;
|
|
33
|
+
};
|
|
34
|
+
})();
|
|
35
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
36
|
+
exports.IcomControl = void 0;
|
|
37
|
+
const events_1 = require("events");
|
|
38
|
+
const IcomPackets_1 = require("../core/IcomPackets");
|
|
39
|
+
const debug_1 = require("../utils/debug");
|
|
40
|
+
const Session_1 = require("../core/Session");
|
|
41
|
+
const IcomCiv_1 = require("./IcomCiv");
|
|
42
|
+
const IcomAudio_1 = require("./IcomAudio");
|
|
43
|
+
const IcomRigCommands_1 = require("./IcomRigCommands");
|
|
44
|
+
const IcomConstants_1 = require("./IcomConstants");
|
|
45
|
+
const bcd_1 = require("../utils/bcd");
|
|
46
|
+
class IcomControl {
|
|
47
|
+
constructor(options) {
|
|
48
|
+
this.ev = new events_1.EventEmitter();
|
|
49
|
+
this.rigName = '';
|
|
50
|
+
this.macAddress = Buffer.alloc(6);
|
|
51
|
+
this.civAssembleBuf = Buffer.alloc(0); // CIV stream reassembler
|
|
52
|
+
this.options = options;
|
|
53
|
+
this.sess = new Session_1.Session({ ip: options.control.ip, port: options.control.port }, {
|
|
54
|
+
onData: (data) => this.onData(data),
|
|
55
|
+
onSendError: (e) => this.ev.emit('error', e)
|
|
56
|
+
});
|
|
57
|
+
// Pre-open local CIV/Audio sessions to obtain local ports before 0x90
|
|
58
|
+
this.civSess = new Session_1.Session({ ip: options.control.ip, port: 0 }, { onData: (b) => this.onCivData(b), onSendError: (e) => this.ev.emit('error', e) });
|
|
59
|
+
this.audioSess = new Session_1.Session({ ip: options.control.ip, port: 0 }, { onData: (b) => this.onAudioData(b), onSendError: (e) => this.ev.emit('error', e) });
|
|
60
|
+
this.civSess.open();
|
|
61
|
+
this.audioSess.open();
|
|
62
|
+
this.civ = new IcomCiv_1.IcomCiv(this.civSess);
|
|
63
|
+
this.audio = new IcomAudio_1.IcomAudio(this.audioSess);
|
|
64
|
+
}
|
|
65
|
+
get events() { return this.ev; }
|
|
66
|
+
async connect() {
|
|
67
|
+
// Initialize readiness promises
|
|
68
|
+
this.loginReady = new Promise(resolve => { this.resolveLoginReady = resolve; });
|
|
69
|
+
this.civReady = new Promise(resolve => { this.resolveCivReady = resolve; });
|
|
70
|
+
this.audioReady = new Promise(resolve => { this.resolveAudioReady = resolve; });
|
|
71
|
+
this.sess.open();
|
|
72
|
+
this.sess.startAreYouThere();
|
|
73
|
+
// Wait for all sub-sessions to be ready
|
|
74
|
+
await Promise.all([this.loginReady, this.civReady, this.audioReady]);
|
|
75
|
+
(0, debug_1.dbg)('All sessions ready (login + civ + audio)');
|
|
76
|
+
}
|
|
77
|
+
async disconnect() {
|
|
78
|
+
// 1. Stop all timers first to prevent interference
|
|
79
|
+
if (this.tokenTimer) {
|
|
80
|
+
clearInterval(this.tokenTimer);
|
|
81
|
+
this.tokenTimer = undefined;
|
|
82
|
+
}
|
|
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
|
+
}
|
|
113
|
+
sendCiv(data) { this.civ.sendCivData(data); }
|
|
114
|
+
/**
|
|
115
|
+
* Set PTT (Push-To-Talk) state
|
|
116
|
+
* @param on - true to key transmitter, false to unkey
|
|
117
|
+
*/
|
|
118
|
+
async setPtt(on) {
|
|
119
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
120
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
121
|
+
// Send CIV PTT command
|
|
122
|
+
const frame = IcomRigCommands_1.IcomRigCommands.setPTT(ctrAddr, rigAddr, on);
|
|
123
|
+
this.sendCiv(frame);
|
|
124
|
+
// Set audio PTT flag (like Java: audioUdp.isPttOn = on)
|
|
125
|
+
this.audio.isPttOn = on;
|
|
126
|
+
// Start/stop meter polling to match Java behavior
|
|
127
|
+
if (on) {
|
|
128
|
+
this.startMeterPolling();
|
|
129
|
+
}
|
|
130
|
+
else {
|
|
131
|
+
this.stopMeterPolling();
|
|
132
|
+
}
|
|
133
|
+
// Add trailing silence when PTT off (like Java implementation)
|
|
134
|
+
if (!on) {
|
|
135
|
+
// Add 5 trailing silence frames before clearing queue
|
|
136
|
+
const silence = new Int16Array(240); // TX_BUFFER_SIZE = 240
|
|
137
|
+
for (let i = 0; i < 5; i++) {
|
|
138
|
+
this.audio.queue.push(silence);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Set operating frequency
|
|
144
|
+
* @param hz - Frequency in Hz
|
|
145
|
+
*/
|
|
146
|
+
async setFrequency(hz) {
|
|
147
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
148
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
149
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.setFrequency(ctrAddr, rigAddr, hz));
|
|
150
|
+
}
|
|
151
|
+
/**
|
|
152
|
+
* Set operating mode
|
|
153
|
+
* @param mode - Operating mode (LSB, USB, AM, CW, RTTY, FM, WFM, CW_R, RTTY_R, DV)
|
|
154
|
+
* @param options - Mode options (dataMode for digital modes like USB-D)
|
|
155
|
+
* @example
|
|
156
|
+
* // Set USB mode
|
|
157
|
+
* await rig.setMode('USB');
|
|
158
|
+
* // Set USB-D (data mode) for FT8
|
|
159
|
+
* await rig.setMode('USB', { dataMode: true });
|
|
160
|
+
*/
|
|
161
|
+
async setMode(mode, options) {
|
|
162
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
163
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
164
|
+
const modeCode = typeof mode === 'string' ? (0, IcomConstants_1.getModeCode)(mode) : mode;
|
|
165
|
+
if (options?.dataMode) {
|
|
166
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.setOperationDataMode(ctrAddr, rigAddr, modeCode));
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.setMode(ctrAddr, rigAddr, modeCode));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
/**
|
|
173
|
+
* Read current operating frequency
|
|
174
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
175
|
+
* @returns Frequency in Hz, or null if timeout/error
|
|
176
|
+
* @example
|
|
177
|
+
* const hz = await rig.readOperatingFrequency({ timeout: 5000 });
|
|
178
|
+
* console.log(`Frequency: ${hz} Hz`);
|
|
179
|
+
*/
|
|
180
|
+
async readOperatingFrequency(options) {
|
|
181
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
182
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
183
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
184
|
+
const req = IcomRigCommands_1.IcomRigCommands.readOperatingFrequency(ctrAddr, rigAddr);
|
|
185
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isReplyOf(frame, 0x03, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
186
|
+
if (!resp)
|
|
187
|
+
return null;
|
|
188
|
+
const freq = IcomControl.parseIcomFreqFromReply(resp);
|
|
189
|
+
return freq;
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Read current operating mode and filter
|
|
193
|
+
* @returns { mode: number, filter?: number } or null
|
|
194
|
+
*/
|
|
195
|
+
async readOperatingMode(options) {
|
|
196
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
197
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
198
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
199
|
+
const req = IcomRigCommands_1.IcomRigCommands.readOperatingMode(ctrAddr, rigAddr);
|
|
200
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.matchCommandFrame(frame, 0x04, [], ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
201
|
+
if (!resp)
|
|
202
|
+
return null;
|
|
203
|
+
// Expect FE FE [ctr] [rig] 0x04 [mode] [filter] FD (some rigs may omit filter)
|
|
204
|
+
const mode = resp.length > 5 ? resp[5] : undefined;
|
|
205
|
+
const filter = resp.length > 6 ? resp[6] : undefined;
|
|
206
|
+
if (mode === undefined)
|
|
207
|
+
return null;
|
|
208
|
+
// Map names using constants
|
|
209
|
+
const { getModeString, getFilterString } = await Promise.resolve().then(() => __importStar(require('./IcomConstants')));
|
|
210
|
+
const modeName = getModeString(mode);
|
|
211
|
+
const filterName = getFilterString(filter);
|
|
212
|
+
return { mode, filter, modeName, filterName };
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Read current transmit frequency (when TX)
|
|
216
|
+
*/
|
|
217
|
+
async readTransmitFrequency(options) {
|
|
218
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
219
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
220
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
221
|
+
const req = IcomRigCommands_1.IcomRigCommands.readTransmitFrequency(ctrAddr, rigAddr);
|
|
222
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.matchCommandFrame(frame, 0x1c, [0x03], ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
223
|
+
if (!resp)
|
|
224
|
+
return null;
|
|
225
|
+
// Parse BCD like readOperatingFrequency, but starting after [0x1c, 0x03]
|
|
226
|
+
// Find 0x1c position and read next 2 bytes (0x03 + 5 BCD bytes)
|
|
227
|
+
let idx = resp.indexOf(0x1c, 4);
|
|
228
|
+
if (idx < 0 || idx + 6 >= resp.length)
|
|
229
|
+
idx = 4;
|
|
230
|
+
if (idx + 6 >= resp.length)
|
|
231
|
+
return null;
|
|
232
|
+
// After 0x1c 0x03, we expect 5 BCD bytes
|
|
233
|
+
if (resp[idx + 1] !== 0x03)
|
|
234
|
+
return null;
|
|
235
|
+
const d0 = resp[idx + 2];
|
|
236
|
+
const d1 = resp[idx + 3];
|
|
237
|
+
const d2 = resp[idx + 4];
|
|
238
|
+
const d3 = resp[idx + 5];
|
|
239
|
+
const d4 = resp[idx + 6];
|
|
240
|
+
const bcdToInt = (b) => ((b >> 4) & 0x0f) * 10 + (b & 0x0f);
|
|
241
|
+
const v0 = bcdToInt(d0);
|
|
242
|
+
const v1 = bcdToInt(d1);
|
|
243
|
+
const v2 = bcdToInt(d2);
|
|
244
|
+
const v3 = bcdToInt(d3);
|
|
245
|
+
const v4 = bcdToInt(d4);
|
|
246
|
+
const hz = v0 + v1 * 100 + v2 * 10000 + v3 * 1000000 + v4 * 100000000;
|
|
247
|
+
return hz;
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* Read transceiver state (TX/RX) via 0x1A 0x00 0x48
|
|
251
|
+
* Note: Java comments mark this as not recommended; use with caution.
|
|
252
|
+
*/
|
|
253
|
+
async readTransceiverState(options) {
|
|
254
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
255
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
256
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
257
|
+
const req = IcomRigCommands_1.IcomRigCommands.readTransceiverState(ctrAddr, rigAddr);
|
|
258
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.matchCommandFrame(frame, 0x1a, [0x00, 0x48], ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
259
|
+
if (!resp)
|
|
260
|
+
return null;
|
|
261
|
+
// Heuristic: take first data byte after subcmd2 as state
|
|
262
|
+
const pos = 5 + 2; // after 0x1a [0x00,0x48]
|
|
263
|
+
const state = resp.length > pos ? resp[pos] : undefined;
|
|
264
|
+
if (state === undefined)
|
|
265
|
+
return 'UNKNOWN';
|
|
266
|
+
if (state === 0x01)
|
|
267
|
+
return 'TX';
|
|
268
|
+
if (state === 0x00)
|
|
269
|
+
return 'RX';
|
|
270
|
+
return 'UNKNOWN';
|
|
271
|
+
}
|
|
272
|
+
/**
|
|
273
|
+
* Read band edge data (0x02). Format may vary by rig; returns raw data bytes after command.
|
|
274
|
+
*/
|
|
275
|
+
async readBandEdges(options) {
|
|
276
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
277
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
278
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
279
|
+
const req = IcomRigCommands_1.IcomRigCommands.readBandEdges(ctrAddr, rigAddr);
|
|
280
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.matchCommandFrame(frame, 0x02, [], ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
281
|
+
if (!resp)
|
|
282
|
+
return null;
|
|
283
|
+
// Return raw payload bytes after command
|
|
284
|
+
return Buffer.from(resp.subarray(5, resp.length - 1));
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* Read SWR (Standing Wave Ratio) meter
|
|
288
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
289
|
+
* @returns SWR reading with raw value, calculated SWR, and alert status
|
|
290
|
+
* @example
|
|
291
|
+
* const swr = await rig.readSWR({ timeout: 2000 });
|
|
292
|
+
* if (swr) {
|
|
293
|
+
* console.log(`SWR: ${swr.swr.toFixed(2)} ${swr.alert ? '⚠️ HIGH' : '✓'}`);
|
|
294
|
+
* }
|
|
295
|
+
*/
|
|
296
|
+
async readSWR(options) {
|
|
297
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
298
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
299
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
300
|
+
const req = IcomRigCommands_1.IcomRigCommands.getSWRState(ctrAddr, rigAddr);
|
|
301
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x12, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
302
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
303
|
+
if (raw === null)
|
|
304
|
+
return null;
|
|
305
|
+
return {
|
|
306
|
+
raw,
|
|
307
|
+
swr: raw / 100,
|
|
308
|
+
alert: raw >= IcomConstants_1.METER_THRESHOLDS.SWR_ALERT
|
|
309
|
+
};
|
|
310
|
+
}
|
|
311
|
+
/**
|
|
312
|
+
* Read ALC (Automatic Level Control) meter
|
|
313
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
314
|
+
* @returns ALC reading with raw value, percent, and alert status
|
|
315
|
+
* @example
|
|
316
|
+
* const alc = await rig.readALC({ timeout: 2000 });
|
|
317
|
+
* if (alc) {
|
|
318
|
+
* console.log(`ALC: ${alc.percent.toFixed(1)}% ${alc.alert ? '⚠️ HIGH' : '✓'}`);
|
|
319
|
+
* }
|
|
320
|
+
*/
|
|
321
|
+
async readALC(options) {
|
|
322
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
323
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
324
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
325
|
+
const req = IcomRigCommands_1.IcomRigCommands.getALCState(ctrAddr, rigAddr);
|
|
326
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x13, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
327
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
328
|
+
if (raw === null)
|
|
329
|
+
return null;
|
|
330
|
+
return {
|
|
331
|
+
raw,
|
|
332
|
+
percent: (raw / IcomConstants_1.METER_THRESHOLDS.ALC_MAX) * 100,
|
|
333
|
+
alert: raw > IcomConstants_1.METER_THRESHOLDS.ALC_ALERT_MAX
|
|
334
|
+
};
|
|
335
|
+
}
|
|
336
|
+
/**
|
|
337
|
+
* Get WLAN connector audio level setting
|
|
338
|
+
* @param options - Query options (timeout in ms, default 3000)
|
|
339
|
+
* @returns WLAN level reading with raw value and percent
|
|
340
|
+
* @example
|
|
341
|
+
* const level = await rig.getConnectorWLanLevel({ timeout: 2000 });
|
|
342
|
+
* if (level) {
|
|
343
|
+
* console.log(`WLAN Level: ${level.percent.toFixed(1)}%`);
|
|
344
|
+
* }
|
|
345
|
+
*/
|
|
346
|
+
async getConnectorWLanLevel(options) {
|
|
347
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
348
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
349
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
350
|
+
const req = IcomRigCommands_1.IcomRigCommands.getConnectorWLanLevel(ctrAddr, rigAddr);
|
|
351
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.matchCommandFrame(frame, 0x1a, [0x05, 0x01, 0x17], ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
352
|
+
const raw = IcomControl.extractMeterData(resp);
|
|
353
|
+
if (raw === null)
|
|
354
|
+
return null;
|
|
355
|
+
return {
|
|
356
|
+
raw,
|
|
357
|
+
percent: (raw / IcomConstants_1.METER_THRESHOLDS.WLAN_LEVEL_MAX) * 100
|
|
358
|
+
};
|
|
359
|
+
}
|
|
360
|
+
/**
|
|
361
|
+
* Read generic level meter (CI-V 0x15/0x02), raw 0-255.
|
|
362
|
+
* Many rigs return two bytes and the low byte is the level.
|
|
363
|
+
*/
|
|
364
|
+
async getLevelMeter(options) {
|
|
365
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
366
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
367
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
368
|
+
const req = IcomRigCommands_1.IcomRigCommands.getLevelMeter(ctrAddr, rigAddr);
|
|
369
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.isMeterReply(frame, 0x02, ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
370
|
+
if (!resp)
|
|
371
|
+
return null;
|
|
372
|
+
const data = resp.subarray(6, resp.length - 1);
|
|
373
|
+
if (data.length === 0)
|
|
374
|
+
return null;
|
|
375
|
+
const raw = data[data.length - 1] & 0xff; // use low byte as 0-255 level
|
|
376
|
+
return {
|
|
377
|
+
raw,
|
|
378
|
+
percent: (raw / 255) * 100
|
|
379
|
+
};
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Set WLAN connector audio level
|
|
383
|
+
* @param level - Audio level (0-255)
|
|
384
|
+
*/
|
|
385
|
+
async setConnectorWLanLevel(level) {
|
|
386
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
387
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
388
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.setConnectorWLanLevel(ctrAddr, rigAddr, level));
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Set connector data routing mode
|
|
392
|
+
* @param mode - Data routing mode (MIC, ACC, USB, WLAN)
|
|
393
|
+
* @example
|
|
394
|
+
* // Route audio to WLAN
|
|
395
|
+
* await rig.setConnectorDataMode('WLAN');
|
|
396
|
+
*/
|
|
397
|
+
async setConnectorDataMode(mode) {
|
|
398
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
399
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
400
|
+
const modeCode = typeof mode === 'string' ? (0, IcomConstants_1.getConnectorModeCode)(mode) : mode;
|
|
401
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.setConnectorDataMode(ctrAddr, rigAddr, modeCode));
|
|
402
|
+
}
|
|
403
|
+
static isReplyOf(frame, cmd, ctrAddr, rigAddr) {
|
|
404
|
+
// typical reply FE FE [ctr] [rig] cmd ... FD
|
|
405
|
+
return frame.length >= 7 && frame[0] === 0xfe && frame[1] === 0xfe && frame[4] === (cmd & 0xff);
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* Extract meter data from CI-V response frame
|
|
409
|
+
* CI-V format: FE FE [ctr] [rig] [cmd] [subcmd] [data0] [data1] FD
|
|
410
|
+
* @param frame - CI-V response buffer
|
|
411
|
+
* @returns Parsed BCD integer value, or null if invalid
|
|
412
|
+
*/
|
|
413
|
+
static extractMeterData(frame) {
|
|
414
|
+
if (!frame || frame.length < 9)
|
|
415
|
+
return null;
|
|
416
|
+
// Extract 2-byte BCD data at position 6-7 of the CI-V frame (FE FE [ctr] [rig] 0x15 [sub] [b0] [b1] FD)
|
|
417
|
+
const bcdData = frame.subarray(6, 8);
|
|
418
|
+
return (0, bcd_1.parseTwoByteBcd)(bcdData);
|
|
419
|
+
}
|
|
420
|
+
static matchCommand(frame, cmd, tail) {
|
|
421
|
+
// FE FE ?? ?? cmd ... tail... FD
|
|
422
|
+
if (!(frame.length >= 7 && frame[0] === 0xfe && frame[1] === 0xfe && frame[4] === (cmd & 0xff)))
|
|
423
|
+
return false;
|
|
424
|
+
if (tail.length === 0)
|
|
425
|
+
return true;
|
|
426
|
+
for (let i = 0; i + tail.length < frame.length; i++) {
|
|
427
|
+
let ok = true;
|
|
428
|
+
for (let j = 0; j < tail.length; j++) {
|
|
429
|
+
if (frame[i + j] !== tail[j]) {
|
|
430
|
+
ok = false;
|
|
431
|
+
break;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
if (ok)
|
|
435
|
+
return true;
|
|
436
|
+
}
|
|
437
|
+
return false;
|
|
438
|
+
}
|
|
439
|
+
// Strict command matcher on full CI-V frame (optional address check)
|
|
440
|
+
static matchCommandFrame(frame, cmd, tail, ctrAddr, rigAddr) {
|
|
441
|
+
if (!(frame.length >= 7 && frame[0] === 0xfe && frame[1] === 0xfe))
|
|
442
|
+
return false;
|
|
443
|
+
if (ctrAddr !== undefined) {
|
|
444
|
+
const addrCtrOk = frame[2] === (ctrAddr & 0xff) || frame[2] === 0x00;
|
|
445
|
+
if (!addrCtrOk)
|
|
446
|
+
return false;
|
|
447
|
+
}
|
|
448
|
+
if (rigAddr !== undefined) {
|
|
449
|
+
if (frame[3] !== (rigAddr & 0xff))
|
|
450
|
+
return false;
|
|
451
|
+
}
|
|
452
|
+
if (frame[4] !== (cmd & 0xff))
|
|
453
|
+
return false;
|
|
454
|
+
// tail should match starting at byte 5
|
|
455
|
+
if (5 + tail.length > frame.length)
|
|
456
|
+
return false;
|
|
457
|
+
for (let i = 0; i < tail.length; i++) {
|
|
458
|
+
if (frame[5 + i] !== tail[i])
|
|
459
|
+
return false;
|
|
460
|
+
}
|
|
461
|
+
if (frame[frame.length - 1] !== 0xfd)
|
|
462
|
+
return false;
|
|
463
|
+
return true;
|
|
464
|
+
}
|
|
465
|
+
async waitForCiv(predicate, timeoutMs, onSend) {
|
|
466
|
+
return new Promise((resolve) => {
|
|
467
|
+
let done = false;
|
|
468
|
+
const onFrame = (data) => {
|
|
469
|
+
// our event emits Buffer of CI-V payload
|
|
470
|
+
const frame = data;
|
|
471
|
+
if (!done && predicate(frame)) {
|
|
472
|
+
done = true;
|
|
473
|
+
this.ev.off('civ', onFrame);
|
|
474
|
+
resolve(frame);
|
|
475
|
+
}
|
|
476
|
+
};
|
|
477
|
+
this.ev.on('civ', onFrame);
|
|
478
|
+
if (onSend)
|
|
479
|
+
onSend();
|
|
480
|
+
setTimeout(() => {
|
|
481
|
+
if (!done) {
|
|
482
|
+
this.ev.off('civ', onFrame);
|
|
483
|
+
resolve(null);
|
|
484
|
+
}
|
|
485
|
+
}, timeoutMs);
|
|
486
|
+
});
|
|
487
|
+
}
|
|
488
|
+
// Parse CI-V reply for command 0x03 (read operating frequency)
|
|
489
|
+
static parseIcomFreqFromReply(frame) {
|
|
490
|
+
// Expect: FE FE [ctr] [rig] 0x03 [bcd0..bcd4] FD
|
|
491
|
+
if (!(frame && frame.length >= 7))
|
|
492
|
+
return null;
|
|
493
|
+
if (frame[0] !== 0xfe || frame[1] !== 0xfe)
|
|
494
|
+
return null;
|
|
495
|
+
if (frame[4] !== 0x03)
|
|
496
|
+
return null;
|
|
497
|
+
// Some radios may include extra bytes; find 0x03 and read next 5 bytes
|
|
498
|
+
let idx = frame.indexOf(0x03, 5);
|
|
499
|
+
if (idx < 0 || idx + 5 >= frame.length)
|
|
500
|
+
idx = 4; // fallback to standard position
|
|
501
|
+
if (idx + 5 >= frame.length)
|
|
502
|
+
return null;
|
|
503
|
+
const d0 = frame[idx + 1];
|
|
504
|
+
const d1 = frame[idx + 2];
|
|
505
|
+
const d2 = frame[idx + 3];
|
|
506
|
+
const d3 = frame[idx + 4];
|
|
507
|
+
const d4 = frame[idx + 5];
|
|
508
|
+
const bcdToInt = (b) => ((b >> 4) & 0x0f) * 10 + (b & 0x0f);
|
|
509
|
+
const v0 = bcdToInt(d0);
|
|
510
|
+
const v1 = bcdToInt(d1);
|
|
511
|
+
const v2 = bcdToInt(d2);
|
|
512
|
+
const v3 = bcdToInt(d3);
|
|
513
|
+
const v4 = bcdToInt(d4);
|
|
514
|
+
const hz = v0 + v1 * 100 + v2 * 10000 + v3 * 1000000 + v4 * 100000000;
|
|
515
|
+
return hz;
|
|
516
|
+
}
|
|
517
|
+
sendAudioFloat32(samples, addLeadingBuffer = false) {
|
|
518
|
+
this.audio.enqueueFloat32(samples, addLeadingBuffer);
|
|
519
|
+
}
|
|
520
|
+
sendAudioPcm16(samples) { this.audio.enqueuePcm16(samples); }
|
|
521
|
+
onData(buf) {
|
|
522
|
+
// common demux by length
|
|
523
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
524
|
+
// dbg/dbgV imported at top
|
|
525
|
+
switch (buf.length) {
|
|
526
|
+
case IcomPackets_1.Sizes.CONTROL: {
|
|
527
|
+
const type = IcomPackets_1.ControlPacket.getType(buf);
|
|
528
|
+
(0, debug_1.dbg)('CTRL <= type=0x' + type.toString(16));
|
|
529
|
+
if (type === IcomPackets_1.Cmd.I_AM_HERE) {
|
|
530
|
+
this.sess.remoteId = IcomPackets_1.ControlPacket.getSentId(buf);
|
|
531
|
+
(0, debug_1.dbg)('I_AM_HERE remoteId=', this.sess.remoteId);
|
|
532
|
+
this.sess.stopAreYouThere();
|
|
533
|
+
this.sess.startPing();
|
|
534
|
+
// ask ready
|
|
535
|
+
this.sess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.ARE_YOU_READY, 1, this.sess.localId, this.sess.remoteId));
|
|
536
|
+
}
|
|
537
|
+
else if (type === IcomPackets_1.Cmd.I_AM_READY) {
|
|
538
|
+
(0, debug_1.dbg)('I_AM_READY -> send login');
|
|
539
|
+
// send login
|
|
540
|
+
const login = IcomPackets_1.LoginPacket.build(0, this.sess.localId, this.sess.remoteId, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken, this.options.userName, this.options.password, 'FT8CN-Node');
|
|
541
|
+
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
542
|
+
this.sess.sendTracked(login);
|
|
543
|
+
this.sess.startIdle();
|
|
544
|
+
}
|
|
545
|
+
break;
|
|
546
|
+
}
|
|
547
|
+
case IcomPackets_1.Sizes.TOKEN: {
|
|
548
|
+
// token renewal / confirm responses
|
|
549
|
+
const reqType = IcomPackets_1.TokenPacket.getRequestType(buf);
|
|
550
|
+
const reqReply = IcomPackets_1.TokenPacket.getRequestReply(buf);
|
|
551
|
+
(0, debug_1.dbg)('TOKEN <= type=', reqType, 'reply=', reqReply);
|
|
552
|
+
if (reqType === IcomPackets_1.TokenType.RENEWAL && reqReply === 0x02 && IcomPackets_1.ControlPacket.getType(buf) !== IcomPackets_1.Cmd.RETRANSMIT) {
|
|
553
|
+
const response = IcomPackets_1.TokenPacket.getResponse(buf);
|
|
554
|
+
(0, debug_1.dbgV)('TOKEN renewal response=', response);
|
|
555
|
+
if (response === 0x00000000) {
|
|
556
|
+
// ok
|
|
557
|
+
}
|
|
558
|
+
else if (response === 0xffffffff) {
|
|
559
|
+
// rejected; attempt re-connect
|
|
560
|
+
this.sess.remoteId = IcomPackets_1.ControlPacket.getSentId(buf);
|
|
561
|
+
this.sess.localToken = IcomPackets_1.TokenPacket.getTokRequest(buf);
|
|
562
|
+
this.sess.rigToken = IcomPackets_1.TokenPacket.getToken(buf);
|
|
563
|
+
this.sendConnectionRequest();
|
|
564
|
+
}
|
|
565
|
+
}
|
|
566
|
+
break;
|
|
567
|
+
}
|
|
568
|
+
case IcomPackets_1.Sizes.STATUS: {
|
|
569
|
+
const civPort = IcomPackets_1.StatusPacket.getRigCivPort(buf);
|
|
570
|
+
const audioPort = IcomPackets_1.StatusPacket.getRigAudioPort(buf);
|
|
571
|
+
(0, debug_1.dbg)('STATUS <= civPort=', civPort, 'audioPort=', audioPort, 'authOK=', IcomPackets_1.StatusPacket.authOK(buf), 'connected=', IcomPackets_1.StatusPacket.getIsConnected(buf));
|
|
572
|
+
const info = { civPort, audioPort, authOK: true, connected: true };
|
|
573
|
+
this.ev.emit('status', info);
|
|
574
|
+
// set remote ports and start AYT for civ/audio
|
|
575
|
+
if (this.civSess) {
|
|
576
|
+
this.civSess.setRemote(this.options.control.ip, civPort);
|
|
577
|
+
this.civSess.startAreYouThere();
|
|
578
|
+
this.civ.start();
|
|
579
|
+
}
|
|
580
|
+
if (this.audioSess) {
|
|
581
|
+
this.audioSess.setRemote(this.options.control.ip, audioPort);
|
|
582
|
+
this.audioSess.startAreYouThere();
|
|
583
|
+
}
|
|
584
|
+
break;
|
|
585
|
+
}
|
|
586
|
+
case IcomPackets_1.Sizes.LOGIN_RESPONSE: {
|
|
587
|
+
const ok = IcomPackets_1.LoginResponsePacket.authOK(buf);
|
|
588
|
+
(0, debug_1.dbg)('LOGIN_RESPONSE ok=', ok, 'conn=', IcomPackets_1.LoginResponsePacket.getConnection(buf));
|
|
589
|
+
if (ok) {
|
|
590
|
+
this.sess.rigToken = IcomPackets_1.LoginResponsePacket.getToken(buf);
|
|
591
|
+
// send token confirm
|
|
592
|
+
const tok = IcomPackets_1.TokenPacket.build(0, this.sess.localId, this.sess.remoteId, IcomPackets_1.TokenType.CONFIRM, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken);
|
|
593
|
+
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
594
|
+
this.sess.sendTracked(tok);
|
|
595
|
+
// start token renewal timer (60s)
|
|
596
|
+
if (!this.tokenTimer) {
|
|
597
|
+
this.tokenTimer = setInterval(() => {
|
|
598
|
+
(0, debug_1.dbg)('TOKEN -> renewal');
|
|
599
|
+
const renew = IcomPackets_1.TokenPacket.build(0, this.sess.localId, this.sess.remoteId, IcomPackets_1.TokenType.RENEWAL, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken);
|
|
600
|
+
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
601
|
+
this.sess.sendTracked(renew);
|
|
602
|
+
}, 60000);
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
const res = { ok, errorCode: IcomPackets_1.LoginResponsePacket.errorNum(buf), connection: IcomPackets_1.LoginResponsePacket.getConnection(buf) };
|
|
606
|
+
this.ev.emit('login', res);
|
|
607
|
+
if (ok) {
|
|
608
|
+
(0, debug_1.dbg)('Login ready - resolving loginReady promise');
|
|
609
|
+
this.resolveLoginReady();
|
|
610
|
+
}
|
|
611
|
+
break;
|
|
612
|
+
}
|
|
613
|
+
case IcomPackets_1.Sizes.CAP_CAP: {
|
|
614
|
+
const cap = IcomPackets_1.CapCapabilitiesPacket.getRadioCapPacket(buf, 0);
|
|
615
|
+
if (cap) {
|
|
616
|
+
const info = {
|
|
617
|
+
civAddress: IcomPackets_1.RadioCapPacket.getCivAddress(cap),
|
|
618
|
+
audioName: IcomPackets_1.RadioCapPacket.getAudioName(cap),
|
|
619
|
+
supportTX: IcomPackets_1.RadioCapPacket.getSupportTX(cap)
|
|
620
|
+
};
|
|
621
|
+
if (info.civAddress != null)
|
|
622
|
+
this.civ.civAddress = info.civAddress;
|
|
623
|
+
if (info.supportTX != null)
|
|
624
|
+
this.civ.supportTX = info.supportTX;
|
|
625
|
+
(0, debug_1.dbgV)('CAP <= civAddr=', info.civAddress, 'audioName=', info.audioName, 'supportTX=', info.supportTX);
|
|
626
|
+
this.ev.emit('capabilities', info);
|
|
627
|
+
}
|
|
628
|
+
break;
|
|
629
|
+
}
|
|
630
|
+
case IcomPackets_1.Sizes.CONNINFO: {
|
|
631
|
+
// rig sends twice; first time busy=false, reply with our ports
|
|
632
|
+
const busy = IcomPackets_1.ConnInfoPacket.getBusy(buf);
|
|
633
|
+
this.macAddress = IcomPackets_1.ConnInfoPacket.getMacAddress(buf);
|
|
634
|
+
this.rigName = IcomPackets_1.ConnInfoPacket.getRigName(buf);
|
|
635
|
+
(0, debug_1.dbg)('CONNINFO <= busy=', busy, 'rigName=', this.rigName);
|
|
636
|
+
if (!busy) {
|
|
637
|
+
const reply = IcomPackets_1.ConnInfoPacket.connInfoPacketData(buf, 0, this.sess.localId, this.sess.remoteId, 0x01, 0x03, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken, this.rigName, this.options.userName, IcomPackets_1.AUDIO_SAMPLE_RATE, IcomPackets_1.AUDIO_SAMPLE_RATE, this.civSess.localPort, this.audioSess.localPort, IcomPackets_1.XIEGU_TX_BUFFER_SIZE);
|
|
638
|
+
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
639
|
+
this.sess.sendTracked(reply);
|
|
640
|
+
try {
|
|
641
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
642
|
+
const { hex } = require('../utils/codec');
|
|
643
|
+
(0, debug_1.dbg)('CONNINFO -> reply with local civPort=', this.civSess.localPort, 'audioPort=', this.audioSess.localPort);
|
|
644
|
+
(0, debug_1.dbgV)('CONNINFO reply hex (first 0x60):', hex(Buffer.from(reply.subarray(0, 0x60))));
|
|
645
|
+
(0, debug_1.dbgV)('CONNINFO reply hex (0x60..0x90):', hex(Buffer.from(reply.subarray(0x60, 0x90))));
|
|
646
|
+
}
|
|
647
|
+
catch { }
|
|
648
|
+
}
|
|
649
|
+
break;
|
|
650
|
+
}
|
|
651
|
+
default: {
|
|
652
|
+
// CIV and Audio are variable length; route by headers
|
|
653
|
+
if (IcomPackets_1.CivPacket.isCiv(buf)) {
|
|
654
|
+
// CIV
|
|
655
|
+
const payload = IcomPackets_1.CivPacket.getCivData(buf);
|
|
656
|
+
(0, debug_1.dbg)('CIV <=', payload.length, 'bytes');
|
|
657
|
+
this.ev.emit('civ', payload);
|
|
658
|
+
}
|
|
659
|
+
else if (buf.length >= 0x18 &&
|
|
660
|
+
(buf[0x10] === 0x97 || buf[0x10] === 0x00) &&
|
|
661
|
+
((buf[0x11] === 0x81) || (buf[0x11] === 0x80))) {
|
|
662
|
+
const len = buf.readUInt16BE(0x16);
|
|
663
|
+
const audio = Buffer.from(buf.subarray(0x18, 0x18 + len));
|
|
664
|
+
(0, debug_1.dbg)('AUDIO <=', audio.length, 'bytes');
|
|
665
|
+
this.ev.emit('audio', { pcm16: audio });
|
|
666
|
+
}
|
|
667
|
+
else if (buf.length === IcomPackets_1.Sizes.CONTROL && IcomPackets_1.ControlPacket.getType(buf) === IcomPackets_1.Cmd.RETRANSMIT) {
|
|
668
|
+
(0, debug_1.dbgV)('RETRANSMIT <= single', IcomPackets_1.ControlPacket.getSeq(buf));
|
|
669
|
+
// single retransmit
|
|
670
|
+
this.sess.retransmit(IcomPackets_1.ControlPacket.getSeq(buf));
|
|
671
|
+
}
|
|
672
|
+
else if (IcomPackets_1.ControlPacket.getType(buf) === IcomPackets_1.Cmd.RETRANSMIT && buf.length > IcomPackets_1.Sizes.CONTROL) {
|
|
673
|
+
(0, debug_1.dbgV)('RETRANSMIT <= multi count=', Math.floor((buf.length - 0x10) / 2));
|
|
674
|
+
for (let i = 0x10; i + 1 < buf.length; i += 2) {
|
|
675
|
+
const seq = buf.readUInt16LE(i);
|
|
676
|
+
this.sess.retransmit(seq);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
else if (buf.length === IcomPackets_1.Sizes.PING && IcomPackets_1.Cmd.PING === IcomPackets_1.ControlPacket.getType(buf)) {
|
|
680
|
+
// ping handling
|
|
681
|
+
if (buf[0x10] === 0x00) {
|
|
682
|
+
// reply to radio ping
|
|
683
|
+
const rep = IcomPackets_1.PingPacket.buildReply(buf, this.sess.localId, this.sess.remoteId);
|
|
684
|
+
(0, debug_1.dbgV)('PING <= request -> reply');
|
|
685
|
+
this.sess.sendUntracked(rep);
|
|
686
|
+
}
|
|
687
|
+
else {
|
|
688
|
+
// reply to our ping; seq ok -> bump
|
|
689
|
+
if (IcomPackets_1.ControlPacket.getSeq(buf) === this.sess.pingSeq)
|
|
690
|
+
this.sess.pingSeq = (this.sess.pingSeq + 1) & 0xffff;
|
|
691
|
+
(0, debug_1.dbgV)('PING <= reply seq=', IcomPackets_1.ControlPacket.getSeq(buf));
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
onCivData(buf) {
|
|
698
|
+
// mirror some generic handling on sub-session
|
|
699
|
+
if (buf.length === IcomPackets_1.Sizes.CONTROL) {
|
|
700
|
+
const type = IcomPackets_1.ControlPacket.getType(buf);
|
|
701
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
702
|
+
(0, debug_1.dbg)('CIV CTRL <= type=0x' + type.toString(16));
|
|
703
|
+
if (type === IcomPackets_1.Cmd.I_AM_HERE) {
|
|
704
|
+
this.civSess.remoteId = IcomPackets_1.ControlPacket.getSentId(buf);
|
|
705
|
+
this.civSess.stopAreYouThere();
|
|
706
|
+
this.civSess.startPing();
|
|
707
|
+
// send ARE_YOU_READY with seq=1
|
|
708
|
+
this.civSess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.ARE_YOU_READY, 1, this.civSess.localId, this.civSess.remoteId));
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
if (type === IcomPackets_1.Cmd.I_AM_READY) {
|
|
712
|
+
this.civ.sendOpenClose(true);
|
|
713
|
+
this.civSess.startIdle();
|
|
714
|
+
(0, debug_1.dbg)('CIV ready - resolving civReady promise');
|
|
715
|
+
this.resolveCivReady();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
// CIV data frames
|
|
720
|
+
// Diagnostic: check for potential CIV packets
|
|
721
|
+
if (buf.length > 0x15 && buf[0x10] === 0xc1) {
|
|
722
|
+
(0, debug_1.dbg)(`CIV? len=${buf.length} [0x10]=0xc1 validating...`);
|
|
723
|
+
if (IcomPackets_1.CivPacket.isCiv(buf)) {
|
|
724
|
+
const payload = IcomPackets_1.CivPacket.getCivData(buf);
|
|
725
|
+
(0, debug_1.dbg)('CIV <=', payload.length, 'bytes');
|
|
726
|
+
// Emit raw CIV payload for backward compatibility
|
|
727
|
+
this.ev.emit('civ', payload);
|
|
728
|
+
// Reassemble CIV frames (FE FE ... FD) and emit per-frame events
|
|
729
|
+
this.processCivPayload(payload);
|
|
730
|
+
return;
|
|
731
|
+
}
|
|
732
|
+
else {
|
|
733
|
+
(0, debug_1.dbg)(`CIV validation FAILED len=${buf.length}`);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
if (buf.length === IcomPackets_1.Sizes.PING && IcomPackets_1.Cmd.PING === IcomPackets_1.ControlPacket.getType(buf)) {
|
|
737
|
+
if (buf[0x10] === 0x00) {
|
|
738
|
+
const rep = IcomPackets_1.PingPacket.buildReply(buf, this.civSess.localId, this.civSess.remoteId);
|
|
739
|
+
this.civSess.sendUntracked(rep);
|
|
740
|
+
}
|
|
741
|
+
else if (IcomPackets_1.ControlPacket.getSeq(buf) === (this.civSess.pingSeq)) {
|
|
742
|
+
this.civSess.pingSeq = (this.civSess.pingSeq + 1) & 0xffff;
|
|
743
|
+
}
|
|
744
|
+
}
|
|
745
|
+
if (buf.length === IcomPackets_1.Sizes.CONTROL && IcomPackets_1.ControlPacket.getType(buf) === IcomPackets_1.Cmd.RETRANSMIT) {
|
|
746
|
+
this.civSess?.retransmit(IcomPackets_1.ControlPacket.getSeq(buf));
|
|
747
|
+
}
|
|
748
|
+
else if (IcomPackets_1.ControlPacket.getType(buf) === IcomPackets_1.Cmd.RETRANSMIT && buf.length > IcomPackets_1.Sizes.CONTROL) {
|
|
749
|
+
for (let i = 0x10; i + 1 < buf.length; i += 2)
|
|
750
|
+
this.civSess?.retransmit(buf.readUInt16LE(i));
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
// Append CIV payload bytes and emit complete CI-V frames (FE FE ... FD)
|
|
754
|
+
processCivPayload(payload) {
|
|
755
|
+
// Append new data
|
|
756
|
+
this.civAssembleBuf = Buffer.concat([this.civAssembleBuf, payload]);
|
|
757
|
+
// Try to extract frames in a loop
|
|
758
|
+
while (true) {
|
|
759
|
+
// Find start marker FE FE
|
|
760
|
+
let start = -1;
|
|
761
|
+
for (let i = 0; i + 1 < this.civAssembleBuf.length; i++) {
|
|
762
|
+
if (this.civAssembleBuf[i] === 0xfe && this.civAssembleBuf[i + 1] === 0xfe) {
|
|
763
|
+
start = i;
|
|
764
|
+
break;
|
|
765
|
+
}
|
|
766
|
+
}
|
|
767
|
+
if (start < 0) {
|
|
768
|
+
// No start marker: drop noise before potential next packet
|
|
769
|
+
if (this.civAssembleBuf.length > 1024)
|
|
770
|
+
this.civAssembleBuf = Buffer.alloc(0);
|
|
771
|
+
return;
|
|
772
|
+
}
|
|
773
|
+
// Trim leading noise
|
|
774
|
+
if (start > 0)
|
|
775
|
+
this.civAssembleBuf = this.civAssembleBuf.subarray(start);
|
|
776
|
+
// Find end marker FD after start
|
|
777
|
+
let end = -1;
|
|
778
|
+
for (let i = 2; i < this.civAssembleBuf.length; i++) {
|
|
779
|
+
if (this.civAssembleBuf[i] === 0xfd) {
|
|
780
|
+
end = i;
|
|
781
|
+
break;
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (end < 0) {
|
|
785
|
+
// Incomplete frame, wait for more data
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
// Extract frame [0..end]
|
|
789
|
+
const frame = Buffer.from(this.civAssembleBuf.subarray(0, end + 1));
|
|
790
|
+
// Advance buffer
|
|
791
|
+
this.civAssembleBuf = this.civAssembleBuf.subarray(end + 1);
|
|
792
|
+
// Emit event
|
|
793
|
+
this.ev.emit('civFrame', frame);
|
|
794
|
+
// Continue loop in case multiple frames are in buffer
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
// Wait for single CI-V frame that matches predicate (fed by civFrame event)
|
|
798
|
+
async waitForCivFrame(predicate, timeoutMs, onSend) {
|
|
799
|
+
return new Promise((resolve) => {
|
|
800
|
+
let done = false;
|
|
801
|
+
const onFrame = (frame) => {
|
|
802
|
+
if (!done && predicate(frame)) {
|
|
803
|
+
done = true;
|
|
804
|
+
this.ev.off('civFrame', onFrame);
|
|
805
|
+
resolve(frame);
|
|
806
|
+
}
|
|
807
|
+
};
|
|
808
|
+
this.ev.on('civFrame', onFrame);
|
|
809
|
+
if (onSend)
|
|
810
|
+
onSend();
|
|
811
|
+
setTimeout(() => {
|
|
812
|
+
if (!done) {
|
|
813
|
+
this.ev.off('civFrame', onFrame);
|
|
814
|
+
resolve(null);
|
|
815
|
+
}
|
|
816
|
+
}, timeoutMs);
|
|
817
|
+
});
|
|
818
|
+
}
|
|
819
|
+
// Strict meter reply matcher: FE FE [ctr|00] [rig] 0x15 [sub] ... FD
|
|
820
|
+
static isMeterReply(frame, subcmd, ctrAddr, rigAddr) {
|
|
821
|
+
if (!(frame && frame.length >= 9))
|
|
822
|
+
return false;
|
|
823
|
+
if (frame[0] !== 0xfe || frame[1] !== 0xfe)
|
|
824
|
+
return false;
|
|
825
|
+
const addrCtrOk = frame[2] === (ctrAddr & 0xff) || frame[2] === 0x00;
|
|
826
|
+
const addrRigOk = frame[3] === (rigAddr & 0xff);
|
|
827
|
+
if (!addrCtrOk || !addrRigOk)
|
|
828
|
+
return false;
|
|
829
|
+
if (frame[4] !== 0x15)
|
|
830
|
+
return false;
|
|
831
|
+
if (frame[5] !== (subcmd & 0xff))
|
|
832
|
+
return false;
|
|
833
|
+
if (frame[frame.length - 1] !== 0xfd)
|
|
834
|
+
return false;
|
|
835
|
+
return true;
|
|
836
|
+
}
|
|
837
|
+
// Start meter polling like Java (every 500ms when PTT is on)
|
|
838
|
+
startMeterPolling() {
|
|
839
|
+
this.stopMeterPolling();
|
|
840
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
841
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
842
|
+
this.meterTimer = setInterval(() => {
|
|
843
|
+
if (!this.audio.isPttOn)
|
|
844
|
+
return; // safety
|
|
845
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.getSWRState(ctrAddr, rigAddr));
|
|
846
|
+
this.sendCiv(IcomRigCommands_1.IcomRigCommands.getALCState(ctrAddr, rigAddr));
|
|
847
|
+
}, IcomConstants_1.METER_TIMER_PERIOD_MS);
|
|
848
|
+
}
|
|
849
|
+
stopMeterPolling() {
|
|
850
|
+
if (this.meterTimer) {
|
|
851
|
+
clearInterval(this.meterTimer);
|
|
852
|
+
this.meterTimer = undefined;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
onAudioData(buf) {
|
|
856
|
+
if (buf.length === IcomPackets_1.Sizes.CONTROL) {
|
|
857
|
+
const type = IcomPackets_1.ControlPacket.getType(buf);
|
|
858
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
|
859
|
+
(0, debug_1.dbg)('AUDIO CTRL <= type=0x' + type.toString(16));
|
|
860
|
+
if (type === IcomPackets_1.Cmd.I_AM_HERE) {
|
|
861
|
+
this.audioSess.remoteId = IcomPackets_1.ControlPacket.getSentId(buf);
|
|
862
|
+
this.audioSess.stopAreYouThere();
|
|
863
|
+
this.audioSess.startPing();
|
|
864
|
+
this.audioSess.sendUntracked(IcomPackets_1.ControlPacket.toBytes(IcomPackets_1.Cmd.ARE_YOU_READY, 1, this.audioSess.localId, this.audioSess.remoteId));
|
|
865
|
+
return;
|
|
866
|
+
}
|
|
867
|
+
if (type === IcomPackets_1.Cmd.I_AM_READY) {
|
|
868
|
+
// Start continuous audio transmission (like Java's startTxAudio on I_AM_READY)
|
|
869
|
+
this.audio.start();
|
|
870
|
+
this.audioSess.startIdle();
|
|
871
|
+
(0, debug_1.dbg)('Audio ready - started continuous audio stream, resolving audioReady promise');
|
|
872
|
+
this.resolveAudioReady();
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
// AUDIO frames (len >= 0x18 and datalen matches). We don't strictly validate ident here to support variants.
|
|
877
|
+
if (buf.length >= 0x18) {
|
|
878
|
+
// datalen is BE (Java uses shortToByte which is big-endian)
|
|
879
|
+
const dataLen = buf.readUInt16BE(0x16);
|
|
880
|
+
if (buf.length === 0x18 + dataLen && dataLen > 0 && dataLen <= 2048) {
|
|
881
|
+
const audio = Buffer.from(buf.subarray(0x18, 0x18 + dataLen));
|
|
882
|
+
this.ev.emit('audio', { pcm16: audio });
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
if (buf.length === IcomPackets_1.Sizes.PING && IcomPackets_1.Cmd.PING === IcomPackets_1.ControlPacket.getType(buf)) {
|
|
887
|
+
if (buf[0x10] === 0x00) {
|
|
888
|
+
const rep = IcomPackets_1.PingPacket.buildReply(buf, this.audioSess.localId, this.audioSess.remoteId);
|
|
889
|
+
this.audioSess.sendUntracked(rep);
|
|
890
|
+
}
|
|
891
|
+
else if (IcomPackets_1.ControlPacket.getSeq(buf) === (this.audioSess.pingSeq)) {
|
|
892
|
+
this.audioSess.pingSeq = (this.audioSess.pingSeq + 1) & 0xffff;
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
if (buf.length === IcomPackets_1.Sizes.CONTROL && IcomPackets_1.ControlPacket.getType(buf) === IcomPackets_1.Cmd.RETRANSMIT) {
|
|
896
|
+
this.audioSess?.retransmit(IcomPackets_1.ControlPacket.getSeq(buf));
|
|
897
|
+
}
|
|
898
|
+
else if (IcomPackets_1.ControlPacket.getType(buf) === IcomPackets_1.Cmd.RETRANSMIT && buf.length > IcomPackets_1.Sizes.CONTROL) {
|
|
899
|
+
for (let i = 0x10; i + 1 < buf.length; i += 2)
|
|
900
|
+
this.audioSess?.retransmit(buf.readUInt16LE(i));
|
|
901
|
+
}
|
|
902
|
+
// audio data routed by main handler already (if using single session). Here we may add specific behaviors if needed.
|
|
903
|
+
}
|
|
904
|
+
sendConnectionRequest() {
|
|
905
|
+
if (!this.civSess || !this.audioSess)
|
|
906
|
+
return;
|
|
907
|
+
const pkt = IcomPackets_1.ConnInfoPacket.connectRequestPacket(0, this.sess.localId, this.sess.remoteId, 0x01, 0x03, this.sess.innerSeq, this.sess.localToken, this.sess.rigToken, this.macAddress, this.rigName, this.options.userName, IcomPackets_1.AUDIO_SAMPLE_RATE, this.civSess.localPort, this.audioSess.localPort, IcomPackets_1.XIEGU_TX_BUFFER_SIZE);
|
|
908
|
+
this.sess.innerSeq = (this.sess.innerSeq + 1) & 0xffff;
|
|
909
|
+
this.sess.sendTracked(pkt);
|
|
910
|
+
}
|
|
911
|
+
}
|
|
912
|
+
exports.IcomControl = IcomControl;
|