icom-wlan-node 0.3.0 → 0.5.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 +106 -26
- package/dist/index.d.ts +2 -0
- package/dist/index.js +5 -1
- package/dist/rig/IcomControl.d.ts +12 -1
- package/dist/rig/IcomControl.js +45 -0
- package/dist/scope/IcomScopeCommands.d.ts +7 -0
- package/dist/scope/IcomScopeCommands.js +37 -0
- package/dist/scope/IcomScopeParser.d.ts +5 -0
- package/dist/scope/IcomScopeParser.js +72 -0
- package/dist/scope/IcomScopeService.d.ts +17 -0
- package/dist/scope/IcomScopeService.js +112 -0
- package/dist/types.d.ts +32 -0
- package/package.json +15 -4
package/README.md
CHANGED
|
@@ -4,6 +4,7 @@ Icom WLAN (UDP) protocol implementation in Node.js + TypeScript, featuring:
|
|
|
4
4
|
|
|
5
5
|
- Control channel handshake (AreYouThere/AreYouReady), login (0x80/0x60), token confirm/renew (0x40)
|
|
6
6
|
- CI‑V over UDP encapsulation (open/close keep‑alive + CIV frame transport)
|
|
7
|
+
- Scope/spectrum data capture over CI‑V `0x27`, with automatic segment assembly into friendly frame events
|
|
7
8
|
- Audio stream send/receive (LPCM 16‑bit mono @ 12 kHz; 20 ms frames)
|
|
8
9
|
- Typed, event‑based API; designed for use as a dependency in other Node projects
|
|
9
10
|
|
|
@@ -63,6 +64,15 @@ rig.events.on('audio', (frame) => {
|
|
|
63
64
|
// frame.pcm16 is raw 16‑bit PCM mono @ 12 kHz
|
|
64
65
|
});
|
|
65
66
|
|
|
67
|
+
rig.events.on('scopeFrame', (frame) => {
|
|
68
|
+
console.log(
|
|
69
|
+
'Scope:',
|
|
70
|
+
`${frame.startFreqHz}..${frame.endFreqHz} Hz`,
|
|
71
|
+
`pixels=${frame.pixels.length}`,
|
|
72
|
+
`mode=${frame.mode}`
|
|
73
|
+
);
|
|
74
|
+
});
|
|
75
|
+
|
|
66
76
|
rig.events.on('error', (err) => console.error('UDP error', err));
|
|
67
77
|
|
|
68
78
|
(async () => {
|
|
@@ -93,6 +103,37 @@ rig.sendAudioFloat32(tone, true);
|
|
|
93
103
|
await rig.setPtt(false);
|
|
94
104
|
```
|
|
95
105
|
|
|
106
|
+
### Scope / Spectrum
|
|
107
|
+
|
|
108
|
+
```ts
|
|
109
|
+
await rig.connect();
|
|
110
|
+
|
|
111
|
+
rig.events.on('scopeSegment', (segment) => {
|
|
112
|
+
console.log(`scope segment ${segment.sequence}/${segment.sequenceMax}`);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
rig.events.on('scopeFrame', (frame) => {
|
|
116
|
+
console.log('scope frame ready', {
|
|
117
|
+
startFreqHz: frame.startFreqHz,
|
|
118
|
+
endFreqHz: frame.endFreqHz,
|
|
119
|
+
pixelCount: frame.pixels.length,
|
|
120
|
+
outOfRange: frame.outOfRange
|
|
121
|
+
});
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// Enable basic scope output
|
|
125
|
+
await rig.enableScope();
|
|
126
|
+
|
|
127
|
+
// Wait for one complete frame
|
|
128
|
+
const frame = await rig.waitForScopeFrame({ timeout: 3000 });
|
|
129
|
+
if (frame) {
|
|
130
|
+
console.log(frame.pixels[0], frame.pixels[1]);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Disable scope output when finished
|
|
134
|
+
await rig.disableScope();
|
|
135
|
+
```
|
|
136
|
+
|
|
96
137
|
## API Overview
|
|
97
138
|
|
|
98
139
|
- `new IcomControl(options)`
|
|
@@ -104,6 +145,8 @@ await rig.setPtt(false);
|
|
|
104
145
|
- `capabilities(CapabilitiesInfo)` — civ address, audio name (0xA8)
|
|
105
146
|
- `civ(Buffer)` — raw CI‑V payload bytes as transported over UDP
|
|
106
147
|
- `civFrame(Buffer)` — one complete CI‑V frame (FE FE ... FD)
|
|
148
|
+
- `scopeSegment(IcomScopeSegmentInfo)` — one parsed `0x27` scope segment
|
|
149
|
+
- `scopeFrame(IcomScopeFrame)` — one assembled spectrum/waterfall frame
|
|
107
150
|
- `audio({ pcm16: Buffer })` — audio frames
|
|
108
151
|
- `error(Error)` — UDP errors
|
|
109
152
|
- `connectionLost(ConnectionLostInfo)` — session timeout detected
|
|
@@ -114,6 +157,7 @@ await rig.setPtt(false);
|
|
|
114
157
|
- **Connection**: `connect()` / `disconnect(options?)` — connects control + CIV + audio sub‑sessions; resolves when all ready
|
|
115
158
|
- `disconnect()` accepts optional `DisconnectOptions` or `DisconnectReason` for better error handling
|
|
116
159
|
- **Raw CI‑V**: `sendCiv(buf: Buffer)` — send a raw CI‑V frame
|
|
160
|
+
- **Scope / Spectrum**: `scope`, `enableScope()`, `disableScope()`, `waitForScopeFrame()`
|
|
117
161
|
- **Audio TX**: `setPtt(on: boolean)`, `sendAudioFloat32()`, `sendAudioPcm16()`
|
|
118
162
|
- **Rig Control**: `setFrequency()`, `setMode()`, `setConnectorDataMode()`, `setConnectorWLanLevel()`
|
|
119
163
|
- **Rig Query**: `readOperatingFrequency()`, `readOperatingMode()`, `readTransmitFrequency()`, `readTransceiverState()`, `readBandEdges()`
|
|
@@ -257,6 +301,47 @@ The library exposes common CI‑V operations as friendly methods. Addresses are
|
|
|
257
301
|
- `readTransceiverState(options?: QueryOptions) => Promise<'TX' | 'RX' | 'UNKNOWN' | null>`
|
|
258
302
|
- `readBandEdges(options?: QueryOptions) => Promise<Buffer|null>`
|
|
259
303
|
|
|
304
|
+
#### Scope / Spectrum
|
|
305
|
+
|
|
306
|
+
- `scope: IcomScopeService` — Standalone scope service object that can be reused with other CI‑V transport paths in the future
|
|
307
|
+
- `enableScope() => Promise<void>` — Send the minimal command sequence to enable basic scope output
|
|
308
|
+
- `disableScope() => Promise<void>` — Send the minimal command sequence to disable scope output
|
|
309
|
+
- `readScopeSpan(options?: QueryOptions & { receiver?: 0 | 1 }) => Promise<{ receiver: 0 | 1; spanHz: number } | null>` — Read current scope span
|
|
310
|
+
- `setScopeSpan(spanHz: number, options?: { receiver?: 0 | 1 }) => Promise<void>` — Set scope span using CI‑V `0x27 0x15`
|
|
311
|
+
- `waitForScopeFrame(options?: QueryOptions) => Promise<IcomScopeFrame | null>` — Wait for the next complete scope frame
|
|
312
|
+
|
|
313
|
+
`IcomScopeFrame` shape:
|
|
314
|
+
|
|
315
|
+
```ts
|
|
316
|
+
interface IcomScopeFrame {
|
|
317
|
+
valid: boolean;
|
|
318
|
+
receiver: 0 | 1;
|
|
319
|
+
sequence: number;
|
|
320
|
+
sequenceMax: number;
|
|
321
|
+
mode: 0 | 1 | 2 | 3;
|
|
322
|
+
outOfRange: boolean;
|
|
323
|
+
startFreqHz: number;
|
|
324
|
+
endFreqHz: number;
|
|
325
|
+
pixels: Uint8Array;
|
|
326
|
+
rawCivPayloads: Buffer[];
|
|
327
|
+
transport: 'lan-civ' | 'serial';
|
|
328
|
+
}
|
|
329
|
+
```
|
|
330
|
+
|
|
331
|
+
Current implementation notes:
|
|
332
|
+
|
|
333
|
+
- Currently implements basic on/off controls, `0x27 0x15` span read/write, and `0x27 00 00` scope data capture
|
|
334
|
+
- The parsing layer is decoupled from the UDP session layer and only depends on complete CI‑V frames
|
|
335
|
+
- Frequency fields are currently parsed with `freqLen=5` by default
|
|
336
|
+
- LAN aggregate waterfall payload splitting is not implemented yet; standard segment input is supported
|
|
337
|
+
- The `scope` logic is designed to be reusable for future serial CI‑V or Hamlib CI‑V integration
|
|
338
|
+
|
|
339
|
+
#### Antenna Tuner (ATU)
|
|
340
|
+
|
|
341
|
+
- `readTunerStatus(options?: QueryOptions) => Promise<{ raw: number; state: 'OFF'|'ON'|'TUNING' } | null>` — Read tuner status (CI‑V 0x1A/0x00)
|
|
342
|
+
- `setTunerEnabled(enabled: boolean) => Promise<void>` — Enable/disable internal tuner (CI‑V 0x1A/0x01)
|
|
343
|
+
- `startManualTune() => Promise<void>` — Trigger one manual tune cycle (CI‑V 0x1A/0x02/0x00)
|
|
344
|
+
|
|
260
345
|
#### Meters & Levels
|
|
261
346
|
|
|
262
347
|
**Reception Meters** (available anytime):
|
|
@@ -383,6 +468,23 @@ await rig.setConnectorDataMode('WLAN');
|
|
|
383
468
|
// Or numeric: await rig.setConnectorDataMode(0x03);
|
|
384
469
|
|
|
385
470
|
await rig.setConnectorWLanLevel(120); // Set WLAN audio level
|
|
471
|
+
|
|
472
|
+
// Scope capture
|
|
473
|
+
await rig.enableScope();
|
|
474
|
+
const scope = await rig.waitForScopeFrame({ timeout: 3000 });
|
|
475
|
+
if (scope) {
|
|
476
|
+
console.log(`Scope ${scope.startFreqHz}..${scope.endFreqHz}, ${scope.pixels.length} pixels`);
|
|
477
|
+
}
|
|
478
|
+
await rig.disableScope();
|
|
479
|
+
|
|
480
|
+
// Antenna tuner
|
|
481
|
+
const atu = await rig.readTunerStatus({ timeout: 2000 });
|
|
482
|
+
if (atu) {
|
|
483
|
+
console.log('ATU:', atu.state);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
await rig.setTunerEnabled(true);
|
|
487
|
+
await rig.startManualTune();
|
|
386
488
|
```
|
|
387
489
|
|
|
388
490
|
## Design Notes
|
|
@@ -393,6 +495,7 @@ await rig.setConnectorWLanLevel(120); // Set WLAN audio level
|
|
|
393
495
|
- Credentials use the same simple substitution cipher as FT8CN’s Android client (`passCode`).
|
|
394
496
|
- The 0x90/0x50 handshake strictly follows FT8CN’s timing and endianness. We pre‑open local CIV/Audio sockets, reply with local ports on first 0x90, then set remote ports upon 0x50.
|
|
395
497
|
- CIV/audio sub‑sessions each run their own Ping/Idle and (for CIV) OpenClose keep‑alive.
|
|
498
|
+
- Scope data is treated as CI‑V business payload, not as a separate UDP stream. `IcomControl` only bridges CI‑V frames into the reusable `IcomScopeService`.
|
|
396
499
|
|
|
397
500
|
### Endianness and parsing tips
|
|
398
501
|
|
|
@@ -418,33 +521,10 @@ ICOM_IP=192.168.31.253 ICOM_PORT=50001 ICOM_USER=icom ICOM_PASS=icomicom npm tes
|
|
|
418
521
|
- Full token renewal loop and advanced status flag parsing simplified.
|
|
419
522
|
- Audio receive/playback is library‑only; playback is up to the integrator.
|
|
420
523
|
- Robust retransmit/multi‑retransmit handling can be extended.
|
|
524
|
+
- Scope support is currently limited to basic on/off commands plus standard `0x27 00 00` segment parsing.
|
|
525
|
+
- LAN aggregate waterfall payload splitting is not implemented yet.
|
|
526
|
+
- Scope control subcommands beyond basic enable/disable are not implemented yet.
|
|
421
527
|
|
|
422
528
|
## License
|
|
423
529
|
|
|
424
530
|
MIT
|
|
425
|
-
#### Antenna Tuner (ATU)
|
|
426
|
-
|
|
427
|
-
- `readTunerStatus(options?: QueryOptions) => Promise<{ raw: number; state: 'OFF'|'ON'|'TUNING' } | null>` — 读取天调状态(CI‑V 0x1A/0x00)
|
|
428
|
-
- `setTunerEnabled(enabled: boolean) => Promise<void>` — 开启/关闭内置天调(CI‑V 0x1A/0x01 00/01)
|
|
429
|
-
- `startManualTune() => Promise<void>` — 触发一次手动调谐(相当于 [TUNE] 键,CI‑V 0x1A/0x02/0x00)
|
|
430
|
-
|
|
431
|
-
示例:
|
|
432
|
-
|
|
433
|
-
```ts
|
|
434
|
-
// 读取天调状态
|
|
435
|
-
const atu = await rig.readTunerStatus({ timeout: 2000 });
|
|
436
|
-
if (atu) console.log('ATU:', atu.state); // OFF / ON / TUNING
|
|
437
|
-
|
|
438
|
-
// 启用内置天调
|
|
439
|
-
await rig.setTunerEnabled(true);
|
|
440
|
-
|
|
441
|
-
// 触发一次手动调谐
|
|
442
|
-
await rig.startManualTune();
|
|
443
|
-
|
|
444
|
-
// 可选:轮询状态直到结束
|
|
445
|
-
let status;
|
|
446
|
-
do {
|
|
447
|
-
await new Promise(r => setTimeout(r, 300));
|
|
448
|
-
status = await rig.readTunerStatus({ timeout: 1000 });
|
|
449
|
-
} while (status && status.state === 'TUNING');
|
|
450
|
-
```
|
package/dist/index.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
export * from './types';
|
|
2
2
|
export { IcomControl } from './rig/IcomControl';
|
|
3
|
+
export { IcomScopeService } from './scope/IcomScopeService';
|
|
4
|
+
export { IcomScopeCommands } from './scope/IcomScopeCommands';
|
|
3
5
|
export { MODE_MAP, CONNECTOR_MODE_MAP, DEFAULT_CONTROLLER_ADDR, METER_THRESHOLDS, METER_CALIBRATION, getModeCode, getConnectorModeCode, getModeString, getConnectorModeString, getFilterString, rawToPowerPercent, rawToVoltage, rawToCurrent } from './rig/IcomConstants';
|
|
4
6
|
export { parseTwoByteBcd, intToTwoByteBcd } from './utils/bcd';
|
|
5
7
|
export { IcomRigCommands } from './rig/IcomRigCommands';
|
package/dist/index.js
CHANGED
|
@@ -14,12 +14,16 @@ var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
|
14
14
|
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
15
|
};
|
|
16
16
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
-
exports.getDisconnectMessage = exports.ConnectionAbortedError = exports.setupBasicErrorProtection = exports.setupGlobalErrorHandlers = exports.AUDIO_RATE = exports.IcomRigCommands = exports.intToTwoByteBcd = exports.parseTwoByteBcd = exports.rawToCurrent = exports.rawToVoltage = exports.rawToPowerPercent = exports.getFilterString = exports.getConnectorModeString = exports.getModeString = exports.getConnectorModeCode = exports.getModeCode = exports.METER_CALIBRATION = exports.METER_THRESHOLDS = exports.DEFAULT_CONTROLLER_ADDR = exports.CONNECTOR_MODE_MAP = exports.MODE_MAP = exports.IcomControl = void 0;
|
|
17
|
+
exports.getDisconnectMessage = exports.ConnectionAbortedError = exports.setupBasicErrorProtection = exports.setupGlobalErrorHandlers = exports.AUDIO_RATE = exports.IcomRigCommands = exports.intToTwoByteBcd = exports.parseTwoByteBcd = exports.rawToCurrent = exports.rawToVoltage = exports.rawToPowerPercent = exports.getFilterString = exports.getConnectorModeString = exports.getModeString = exports.getConnectorModeCode = exports.getModeCode = exports.METER_CALIBRATION = exports.METER_THRESHOLDS = exports.DEFAULT_CONTROLLER_ADDR = exports.CONNECTOR_MODE_MAP = exports.MODE_MAP = exports.IcomScopeCommands = exports.IcomScopeService = exports.IcomControl = void 0;
|
|
18
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");
|
|
22
22
|
Object.defineProperty(exports, "IcomControl", { enumerable: true, get: function () { return IcomControl_1.IcomControl; } });
|
|
23
|
+
var IcomScopeService_1 = require("./scope/IcomScopeService");
|
|
24
|
+
Object.defineProperty(exports, "IcomScopeService", { enumerable: true, get: function () { return IcomScopeService_1.IcomScopeService; } });
|
|
25
|
+
var IcomScopeCommands_1 = require("./scope/IcomScopeCommands");
|
|
26
|
+
Object.defineProperty(exports, "IcomScopeCommands", { enumerable: true, get: function () { return IcomScopeCommands_1.IcomScopeCommands; } });
|
|
23
27
|
// Export constants and enums
|
|
24
28
|
var IcomConstants_1 = require("./rig/IcomConstants");
|
|
25
29
|
Object.defineProperty(exports, "MODE_MAP", { enumerable: true, get: function () { return IcomConstants_1.MODE_MAP; } });
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import { IcomRigOptions, RigEventEmitter, IcomMode, ConnectorDataMode, SetModeOptions, QueryOptions, SwrReading, AlcReading, WlanLevelReading, LevelMeterReading, SquelchStatusReading, AudioSquelchReading, OvfStatusReading, PowerLevelReading, CompLevelReading, VoltageReading, CurrentReading, ConnectionState, ConnectionMonitorConfig, ConnectionPhase, ConnectionMetrics, DisconnectReason, DisconnectOptions, TunerStatusReading, LevelReading } from '../types';
|
|
1
|
+
import { IcomRigOptions, RigEventEmitter, IcomMode, ConnectorDataMode, SetModeOptions, QueryOptions, SwrReading, AlcReading, WlanLevelReading, LevelMeterReading, SquelchStatusReading, AudioSquelchReading, OvfStatusReading, PowerLevelReading, CompLevelReading, VoltageReading, CurrentReading, ConnectionState, ConnectionMonitorConfig, ConnectionPhase, ConnectionMetrics, DisconnectReason, DisconnectOptions, TunerStatusReading, LevelReading, IcomScopeSpanInfo } from '../types';
|
|
2
2
|
import { IcomCiv } from './IcomCiv';
|
|
3
3
|
import { IcomAudio } from './IcomAudio';
|
|
4
|
+
import { IcomScopeService } from '../scope/IcomScopeService';
|
|
4
5
|
export declare class IcomControl {
|
|
5
6
|
private ev;
|
|
6
7
|
private sess;
|
|
@@ -8,6 +9,7 @@ export declare class IcomControl {
|
|
|
8
9
|
private audioSess;
|
|
9
10
|
civ: IcomCiv;
|
|
10
11
|
audio: IcomAudio;
|
|
12
|
+
scope: IcomScopeService;
|
|
11
13
|
private options;
|
|
12
14
|
private rigName;
|
|
13
15
|
private macAddress;
|
|
@@ -73,6 +75,15 @@ export declare class IcomControl {
|
|
|
73
75
|
*/
|
|
74
76
|
disconnect(options?: DisconnectOptions | DisconnectReason): Promise<void>;
|
|
75
77
|
sendCiv(data: Buffer): void;
|
|
78
|
+
enableScope(): Promise<void>;
|
|
79
|
+
disableScope(): Promise<void>;
|
|
80
|
+
readScopeSpan(options?: QueryOptions & {
|
|
81
|
+
receiver?: 0 | 1;
|
|
82
|
+
}): Promise<IcomScopeSpanInfo | null>;
|
|
83
|
+
setScopeSpan(spanHz: number, options?: {
|
|
84
|
+
receiver?: 0 | 1;
|
|
85
|
+
}): Promise<void>;
|
|
86
|
+
waitForScopeFrame(options?: QueryOptions): Promise<import("../types").IcomScopeFrame | null>;
|
|
76
87
|
/**
|
|
77
88
|
* Set PTT (Push-To-Talk) state
|
|
78
89
|
* @param on - true to key transmitter, false to unkey
|
package/dist/rig/IcomControl.js
CHANGED
|
@@ -46,6 +46,9 @@ const IcomConstants_1 = require("./IcomConstants");
|
|
|
46
46
|
const bcd_1 = require("../utils/bcd");
|
|
47
47
|
const errors_1 = require("../utils/errors");
|
|
48
48
|
const smeter_1 = require("../utils/smeter");
|
|
49
|
+
const IcomScopeCommands_1 = require("../scope/IcomScopeCommands");
|
|
50
|
+
const IcomScopeParser_1 = require("../scope/IcomScopeParser");
|
|
51
|
+
const IcomScopeService_1 = require("../scope/IcomScopeService");
|
|
49
52
|
class IcomControl {
|
|
50
53
|
constructor(options) {
|
|
51
54
|
this.ev = new events_1.EventEmitter();
|
|
@@ -92,6 +95,9 @@ class IcomControl {
|
|
|
92
95
|
this.audioSess.open();
|
|
93
96
|
this.civ = new IcomCiv_1.IcomCiv(this.civSess);
|
|
94
97
|
this.audio = new IcomAudio_1.IcomAudio(this.audioSess);
|
|
98
|
+
this.scope = new IcomScopeService_1.IcomScopeService();
|
|
99
|
+
this.scope.on('scopeSegment', (segment) => this.ev.emit('scopeSegment', segment));
|
|
100
|
+
this.scope.on('scopeFrame', (frame) => this.ev.emit('scopeFrame', frame));
|
|
95
101
|
}
|
|
96
102
|
get events() { return this.ev; }
|
|
97
103
|
// ============================================================================
|
|
@@ -510,6 +516,44 @@ class IcomControl {
|
|
|
510
516
|
}
|
|
511
517
|
}
|
|
512
518
|
sendCiv(data) { this.civ.sendCivData(data); }
|
|
519
|
+
async enableScope() {
|
|
520
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
521
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
522
|
+
this.sendCiv(IcomScopeCommands_1.IcomScopeCommands.setScopeDisplay(ctrAddr, rigAddr, true));
|
|
523
|
+
this.sendCiv(IcomScopeCommands_1.IcomScopeCommands.setScopeDataOutput(ctrAddr, rigAddr, true));
|
|
524
|
+
}
|
|
525
|
+
async disableScope() {
|
|
526
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
527
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
528
|
+
this.sendCiv(IcomScopeCommands_1.IcomScopeCommands.setScopeDataOutput(ctrAddr, rigAddr, false));
|
|
529
|
+
this.sendCiv(IcomScopeCommands_1.IcomScopeCommands.setScopeDisplay(ctrAddr, rigAddr, false));
|
|
530
|
+
}
|
|
531
|
+
async readScopeSpan(options) {
|
|
532
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
533
|
+
const receiver = options?.receiver ?? 0;
|
|
534
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
535
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
536
|
+
const req = IcomScopeCommands_1.IcomScopeCommands.readScopeSpan(ctrAddr, rigAddr, receiver);
|
|
537
|
+
const resp = await this.waitForCivFrame((frame) => IcomControl.matchCommandFrame(frame, 0x27, [0x15, receiver], ctrAddr, rigAddr), timeoutMs, () => this.sendCiv(req));
|
|
538
|
+
if (!resp || resp.length < 13) {
|
|
539
|
+
return null;
|
|
540
|
+
}
|
|
541
|
+
const spanHz = (0, IcomScopeParser_1.parseIcomBcdFreqLE)(resp.subarray(7, 12));
|
|
542
|
+
return {
|
|
543
|
+
receiver,
|
|
544
|
+
spanHz,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
async setScopeSpan(spanHz, options) {
|
|
548
|
+
const receiver = options?.receiver ?? 0;
|
|
549
|
+
const ctrAddr = IcomConstants_1.DEFAULT_CONTROLLER_ADDR;
|
|
550
|
+
const rigAddr = this.civ.civAddress & 0xff;
|
|
551
|
+
this.sendCiv(IcomScopeCommands_1.IcomScopeCommands.setScopeSpan(ctrAddr, rigAddr, spanHz, receiver));
|
|
552
|
+
}
|
|
553
|
+
async waitForScopeFrame(options) {
|
|
554
|
+
const timeoutMs = options?.timeout ?? 3000;
|
|
555
|
+
return this.scope.waitForScopeFrame(timeoutMs);
|
|
556
|
+
}
|
|
513
557
|
/**
|
|
514
558
|
* Set PTT (Push-To-Talk) state
|
|
515
559
|
* @param on - true to key transmitter, false to unkey
|
|
@@ -1514,6 +1558,7 @@ class IcomControl {
|
|
|
1514
1558
|
this.civAssembleBuf = this.civAssembleBuf.subarray(end + 1);
|
|
1515
1559
|
// Emit event
|
|
1516
1560
|
this.ev.emit('civFrame', frame);
|
|
1561
|
+
this.scope.handleCivFrame(frame, 'lan-civ');
|
|
1517
1562
|
// Continue loop in case multiple frames are in buffer
|
|
1518
1563
|
}
|
|
1519
1564
|
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
export declare const IcomScopeCommands: {
|
|
2
|
+
setScopeDataOutput(ctrAddr: number, rigAddr: number, enabled: boolean): Buffer;
|
|
3
|
+
setScopeDisplay(ctrAddr: number, rigAddr: number, enabled: boolean): Buffer;
|
|
4
|
+
readScopeSpan(ctrAddr: number, rigAddr: number, receiver?: 0 | 1): Buffer;
|
|
5
|
+
setScopeSpan(ctrAddr: number, rigAddr: number, spanHz: number, receiver?: 0 | 1): Buffer;
|
|
6
|
+
encodeScopeSpanHz(spanHz: number): Buffer;
|
|
7
|
+
};
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IcomScopeCommands = void 0;
|
|
4
|
+
exports.IcomScopeCommands = {
|
|
5
|
+
setScopeDataOutput(ctrAddr, rigAddr, enabled) {
|
|
6
|
+
return Buffer.from([
|
|
7
|
+
0xfe, 0xfe, rigAddr & 0xff, ctrAddr & 0xff, 0x27, 0x11, enabled ? 0x01 : 0x00, 0xfd
|
|
8
|
+
]);
|
|
9
|
+
},
|
|
10
|
+
setScopeDisplay(ctrAddr, rigAddr, enabled) {
|
|
11
|
+
return Buffer.from([
|
|
12
|
+
0xfe, 0xfe, rigAddr & 0xff, ctrAddr & 0xff, 0x27, 0x10, enabled ? 0x01 : 0x00, 0xfd
|
|
13
|
+
]);
|
|
14
|
+
},
|
|
15
|
+
readScopeSpan(ctrAddr, rigAddr, receiver = 0) {
|
|
16
|
+
return Buffer.from([
|
|
17
|
+
0xfe, 0xfe, rigAddr & 0xff, ctrAddr & 0xff, 0x27, 0x15, receiver & 0xff, 0xfd
|
|
18
|
+
]);
|
|
19
|
+
},
|
|
20
|
+
setScopeSpan(ctrAddr, rigAddr, spanHz, receiver = 0) {
|
|
21
|
+
const bytes = exports.IcomScopeCommands.encodeScopeSpanHz(spanHz);
|
|
22
|
+
return Buffer.from([
|
|
23
|
+
0xfe, 0xfe, rigAddr & 0xff, ctrAddr & 0xff, 0x27, 0x15, receiver & 0xff, ...bytes, 0xfd
|
|
24
|
+
]);
|
|
25
|
+
},
|
|
26
|
+
encodeScopeSpanHz(spanHz) {
|
|
27
|
+
const safeSpanHz = Math.max(0, Math.round(spanHz));
|
|
28
|
+
const out = Buffer.alloc(5);
|
|
29
|
+
let remaining = safeSpanHz;
|
|
30
|
+
for (let i = 0; i < out.length; i++) {
|
|
31
|
+
const twoDigits = remaining % 100;
|
|
32
|
+
out[i] = ((((twoDigits / 10) | 0) & 0x0f) << 4) | (twoDigits % 10);
|
|
33
|
+
remaining = Math.floor(remaining / 100);
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
};
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
import { IcomScopeSegmentInfo } from '../types';
|
|
2
|
+
export declare function isScopeFrame(frame: Buffer): boolean;
|
|
3
|
+
export declare function bcdByteToInt(v: number): number;
|
|
4
|
+
export declare function parseIcomBcdFreqLE(bytes: Buffer): number;
|
|
5
|
+
export declare function parseScopeSegment(frame: Buffer, transport: 'lan-civ' | 'serial', freqLen?: number): IcomScopeSegmentInfo | null;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.isScopeFrame = isScopeFrame;
|
|
4
|
+
exports.bcdByteToInt = bcdByteToInt;
|
|
5
|
+
exports.parseIcomBcdFreqLE = parseIcomBcdFreqLE;
|
|
6
|
+
exports.parseScopeSegment = parseScopeSegment;
|
|
7
|
+
const SCOPE_PREFIX = [0x27, 0x00, 0x00];
|
|
8
|
+
function isScopeFrame(frame) {
|
|
9
|
+
if (frame.length < 9)
|
|
10
|
+
return false;
|
|
11
|
+
if (frame[0] !== 0xfe || frame[1] !== 0xfe)
|
|
12
|
+
return false;
|
|
13
|
+
if (frame[frame.length - 1] !== 0xfd)
|
|
14
|
+
return false;
|
|
15
|
+
return frame[4] === SCOPE_PREFIX[0] && frame[5] === SCOPE_PREFIX[1] && frame[6] === SCOPE_PREFIX[2];
|
|
16
|
+
}
|
|
17
|
+
function bcdByteToInt(v) {
|
|
18
|
+
return (v & 0x0f) + (((v >> 4) & 0x0f) * 10);
|
|
19
|
+
}
|
|
20
|
+
function parseIcomBcdFreqLE(bytes) {
|
|
21
|
+
let hz = 0;
|
|
22
|
+
let multiplier = 1;
|
|
23
|
+
for (const byte of bytes) {
|
|
24
|
+
hz += (byte & 0x0f) * multiplier;
|
|
25
|
+
multiplier *= 10;
|
|
26
|
+
hz += ((byte >> 4) & 0x0f) * multiplier;
|
|
27
|
+
multiplier *= 10;
|
|
28
|
+
}
|
|
29
|
+
return hz;
|
|
30
|
+
}
|
|
31
|
+
function parseScopeSegment(frame, transport, freqLen = 5) {
|
|
32
|
+
if (!isScopeFrame(frame))
|
|
33
|
+
return null;
|
|
34
|
+
const payload = frame.subarray(4, frame.length - 1);
|
|
35
|
+
if (payload.length < 5)
|
|
36
|
+
return null;
|
|
37
|
+
const sequence = bcdByteToInt(payload[3]);
|
|
38
|
+
const sequenceMax = bcdByteToInt(payload[4]);
|
|
39
|
+
if (sequence <= 0 || sequenceMax <= 0 || sequence > sequenceMax)
|
|
40
|
+
return null;
|
|
41
|
+
const segment = {
|
|
42
|
+
receiver: 0,
|
|
43
|
+
sequence,
|
|
44
|
+
sequenceMax,
|
|
45
|
+
rawCivPayload: Buffer.from(payload),
|
|
46
|
+
transport
|
|
47
|
+
};
|
|
48
|
+
if (sequence === 1) {
|
|
49
|
+
const minimumHeaderLength = 3 + 2 + (freqLen * 2) + 1;
|
|
50
|
+
if (payload.length < minimumHeaderLength)
|
|
51
|
+
return null;
|
|
52
|
+
const mode = payload[5];
|
|
53
|
+
const primaryFreq = parseIcomBcdFreqLE(payload.subarray(6, 6 + freqLen));
|
|
54
|
+
const secondaryFreq = parseIcomBcdFreqLE(payload.subarray(6 + freqLen, 6 + (freqLen * 2)));
|
|
55
|
+
const outOfRange = payload[6 + (freqLen * 2)] !== 0x00;
|
|
56
|
+
let startFreqHz = primaryFreq;
|
|
57
|
+
let endFreqHz = secondaryFreq;
|
|
58
|
+
if (mode === 0) {
|
|
59
|
+
startFreqHz = Math.max(0, primaryFreq - secondaryFreq);
|
|
60
|
+
endFreqHz = primaryFreq + secondaryFreq;
|
|
61
|
+
}
|
|
62
|
+
segment.mode = mode;
|
|
63
|
+
segment.outOfRange = outOfRange;
|
|
64
|
+
segment.startFreqHz = startFreqHz;
|
|
65
|
+
segment.endFreqHz = endFreqHz;
|
|
66
|
+
const pixelOffset = 7 + (freqLen * 2);
|
|
67
|
+
segment.pixels = new Uint8Array(payload.subarray(pixelOffset));
|
|
68
|
+
return segment;
|
|
69
|
+
}
|
|
70
|
+
segment.pixels = new Uint8Array(payload.subarray(5));
|
|
71
|
+
return segment;
|
|
72
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { EventEmitter } from 'events';
|
|
2
|
+
import { IcomScopeFrame, IcomScopeSegmentInfo, IcomScopeTransport } from '../types';
|
|
3
|
+
export interface IcomScopeServiceOptions {
|
|
4
|
+
assemblyTimeoutMs?: number;
|
|
5
|
+
freqLen?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class IcomScopeService extends EventEmitter {
|
|
8
|
+
private readonly assemblyTimeoutMs;
|
|
9
|
+
private readonly freqLen;
|
|
10
|
+
private readonly assemblies;
|
|
11
|
+
constructor(options?: IcomScopeServiceOptions);
|
|
12
|
+
handleCivFrame(frame: Buffer, transport: IcomScopeTransport): IcomScopeFrame | null;
|
|
13
|
+
handleScopeSegment(segment: IcomScopeSegmentInfo): IcomScopeFrame | null;
|
|
14
|
+
waitForScopeFrame(timeoutMs?: number): Promise<IcomScopeFrame | null>;
|
|
15
|
+
private cleanupExpiredAssemblies;
|
|
16
|
+
private concatChunks;
|
|
17
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.IcomScopeService = void 0;
|
|
4
|
+
const events_1 = require("events");
|
|
5
|
+
const IcomScopeParser_1 = require("./IcomScopeParser");
|
|
6
|
+
class IcomScopeService extends events_1.EventEmitter {
|
|
7
|
+
constructor(options) {
|
|
8
|
+
super();
|
|
9
|
+
this.assemblies = new Map();
|
|
10
|
+
this.assemblyTimeoutMs = options?.assemblyTimeoutMs ?? 500;
|
|
11
|
+
this.freqLen = options?.freqLen ?? 5;
|
|
12
|
+
}
|
|
13
|
+
handleCivFrame(frame, transport) {
|
|
14
|
+
const segment = (0, IcomScopeParser_1.parseScopeSegment)(frame, transport, this.freqLen);
|
|
15
|
+
if (!segment)
|
|
16
|
+
return null;
|
|
17
|
+
return this.handleScopeSegment(segment);
|
|
18
|
+
}
|
|
19
|
+
handleScopeSegment(segment) {
|
|
20
|
+
this.cleanupExpiredAssemblies();
|
|
21
|
+
this.emit('scopeSegment', segment);
|
|
22
|
+
const key = segment.receiver;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
if (segment.sequence === 1) {
|
|
25
|
+
this.assemblies.set(key, {
|
|
26
|
+
receiver: segment.receiver,
|
|
27
|
+
expectedMax: segment.sequenceMax,
|
|
28
|
+
mode: segment.mode,
|
|
29
|
+
startFreqHz: segment.startFreqHz,
|
|
30
|
+
endFreqHz: segment.endFreqHz,
|
|
31
|
+
outOfRange: segment.outOfRange,
|
|
32
|
+
chunks: segment.pixels ? [segment.pixels] : [],
|
|
33
|
+
rawCivPayloads: [Buffer.from(segment.rawCivPayload)],
|
|
34
|
+
updatedAt: now
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
else {
|
|
38
|
+
const state = this.assemblies.get(key);
|
|
39
|
+
if (!state)
|
|
40
|
+
return null;
|
|
41
|
+
if (state.expectedMax !== segment.sequenceMax) {
|
|
42
|
+
this.assemblies.delete(key);
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
state.chunks.push(segment.pixels ?? new Uint8Array());
|
|
46
|
+
state.rawCivPayloads.push(Buffer.from(segment.rawCivPayload));
|
|
47
|
+
state.updatedAt = now;
|
|
48
|
+
}
|
|
49
|
+
const state = this.assemblies.get(key);
|
|
50
|
+
if (!state)
|
|
51
|
+
return null;
|
|
52
|
+
if (segment.sequence !== state.expectedMax)
|
|
53
|
+
return null;
|
|
54
|
+
if (state.mode === undefined || state.startFreqHz === undefined || state.endFreqHz === undefined || state.outOfRange === undefined) {
|
|
55
|
+
this.assemblies.delete(key);
|
|
56
|
+
return null;
|
|
57
|
+
}
|
|
58
|
+
const pixels = this.concatChunks(state.chunks);
|
|
59
|
+
const frame = {
|
|
60
|
+
valid: true,
|
|
61
|
+
receiver: state.receiver,
|
|
62
|
+
sequence: segment.sequence,
|
|
63
|
+
sequenceMax: state.expectedMax,
|
|
64
|
+
mode: state.mode,
|
|
65
|
+
outOfRange: state.outOfRange,
|
|
66
|
+
startFreqHz: state.startFreqHz,
|
|
67
|
+
endFreqHz: state.endFreqHz,
|
|
68
|
+
pixels,
|
|
69
|
+
rawCivPayloads: state.rawCivPayloads.map((payload) => Buffer.from(payload)),
|
|
70
|
+
transport: segment.transport
|
|
71
|
+
};
|
|
72
|
+
this.assemblies.delete(key);
|
|
73
|
+
this.emit('scopeFrame', frame);
|
|
74
|
+
return frame;
|
|
75
|
+
}
|
|
76
|
+
async waitForScopeFrame(timeoutMs = 3000) {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
let done = false;
|
|
79
|
+
const onFrame = (frame) => {
|
|
80
|
+
done = true;
|
|
81
|
+
this.off('scopeFrame', onFrame);
|
|
82
|
+
resolve(frame);
|
|
83
|
+
};
|
|
84
|
+
this.on('scopeFrame', onFrame);
|
|
85
|
+
setTimeout(() => {
|
|
86
|
+
if (done)
|
|
87
|
+
return;
|
|
88
|
+
this.off('scopeFrame', onFrame);
|
|
89
|
+
resolve(null);
|
|
90
|
+
}, timeoutMs);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
cleanupExpiredAssemblies() {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
for (const [key, state] of this.assemblies.entries()) {
|
|
96
|
+
if (now - state.updatedAt > this.assemblyTimeoutMs) {
|
|
97
|
+
this.assemblies.delete(key);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
concatChunks(chunks) {
|
|
102
|
+
const total = chunks.reduce((sum, chunk) => sum + chunk.length, 0);
|
|
103
|
+
const out = new Uint8Array(total);
|
|
104
|
+
let offset = 0;
|
|
105
|
+
for (const chunk of chunks) {
|
|
106
|
+
out.set(chunk, offset);
|
|
107
|
+
offset += chunk.length;
|
|
108
|
+
}
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
exports.IcomScopeService = IcomScopeService;
|
package/dist/types.d.ts
CHANGED
|
@@ -39,6 +39,8 @@ export interface IcomRigEvents {
|
|
|
39
39
|
capabilities: (c: CapabilitiesInfo) => void;
|
|
40
40
|
civ: (data: Buffer) => void;
|
|
41
41
|
civFrame: (frame: Buffer) => void;
|
|
42
|
+
scopeSegment: (segment: IcomScopeSegmentInfo) => void;
|
|
43
|
+
scopeFrame: (frame: IcomScopeFrame) => void;
|
|
42
44
|
audio: (frame: AudioFrame) => void;
|
|
43
45
|
error: (err: Error) => void;
|
|
44
46
|
connectionLost: (info: ConnectionLostInfo) => void;
|
|
@@ -82,6 +84,36 @@ export interface QueryOptions {
|
|
|
82
84
|
*/
|
|
83
85
|
timeout?: number;
|
|
84
86
|
}
|
|
87
|
+
export type IcomScopeTransport = 'lan-civ' | 'serial';
|
|
88
|
+
export interface IcomScopeSegmentInfo {
|
|
89
|
+
receiver: 0 | 1;
|
|
90
|
+
sequence: number;
|
|
91
|
+
sequenceMax: number;
|
|
92
|
+
mode?: 0 | 1 | 2 | 3;
|
|
93
|
+
outOfRange?: boolean;
|
|
94
|
+
startFreqHz?: number;
|
|
95
|
+
endFreqHz?: number;
|
|
96
|
+
pixels?: Uint8Array;
|
|
97
|
+
rawCivPayload: Buffer;
|
|
98
|
+
transport: IcomScopeTransport;
|
|
99
|
+
}
|
|
100
|
+
export interface IcomScopeFrame {
|
|
101
|
+
valid: boolean;
|
|
102
|
+
receiver: 0 | 1;
|
|
103
|
+
sequence: number;
|
|
104
|
+
sequenceMax: number;
|
|
105
|
+
mode: 0 | 1 | 2 | 3;
|
|
106
|
+
outOfRange: boolean;
|
|
107
|
+
startFreqHz: number;
|
|
108
|
+
endFreqHz: number;
|
|
109
|
+
pixels: Uint8Array;
|
|
110
|
+
rawCivPayloads: Buffer[];
|
|
111
|
+
transport: IcomScopeTransport;
|
|
112
|
+
}
|
|
113
|
+
export interface IcomScopeSpanInfo {
|
|
114
|
+
receiver: 0 | 1;
|
|
115
|
+
spanHz: number;
|
|
116
|
+
}
|
|
85
117
|
/**
|
|
86
118
|
* Result of a meter reading operation (SWR, ALC, etc.)
|
|
87
119
|
* @deprecated Use specific types like SwrReading, AlcReading instead
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "icom-wlan-node",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Icom WLAN (CI‑V, audio) protocol implementation for Node.js/TypeScript.",
|
|
5
5
|
"main": "dist/index.js",
|
|
6
6
|
"types": "dist/index.d.ts",
|
|
@@ -10,16 +10,27 @@
|
|
|
10
10
|
"test": "jest",
|
|
11
11
|
"prepublishOnly": "npm run build",
|
|
12
12
|
"lint": "eslint .",
|
|
13
|
-
"diagnose": "tsx scripts/diagnose.ts"
|
|
13
|
+
"diagnose": "tsx scripts/diagnose.ts",
|
|
14
|
+
"test:scope:real": "tsx scripts/test-scope-real.ts"
|
|
14
15
|
},
|
|
15
|
-
"keywords": [
|
|
16
|
+
"keywords": [
|
|
17
|
+
"icom",
|
|
18
|
+
"civ",
|
|
19
|
+
"cat",
|
|
20
|
+
"udp",
|
|
21
|
+
"hamradio",
|
|
22
|
+
"wlan",
|
|
23
|
+
"audio"
|
|
24
|
+
],
|
|
16
25
|
"author": "boybook",
|
|
17
26
|
"license": "MIT",
|
|
18
27
|
"repository": {
|
|
19
28
|
"type": "git",
|
|
20
29
|
"url": "https://github.com/boybook/icom-wlan-node.git"
|
|
21
30
|
},
|
|
22
|
-
"files": [
|
|
31
|
+
"files": [
|
|
32
|
+
"dist"
|
|
33
|
+
],
|
|
23
34
|
"devDependencies": {
|
|
24
35
|
"@types/jest": "^29.5.12",
|
|
25
36
|
"@types/node": "^20.10.6",
|