Skip to content

Commit a362474

Browse files
committed
feat: add rig control for Doppler correction
Add CAT protocol drivers (Kenwood, Yaesu modern/legacy, Icom CI-V, rigctld) to send Doppler-corrected frequencies to radios. Extends DopplerWindow with Chart/Setup tabs, mode selector with SatNOGS mapping, and live correction status bar. - Shared serial transports (text + binary) in src/serial/transport.ts with configurable delimiter, fixed-length reads, and shared baud rates - Five protocol drivers validated against official docs and Hamlib source - Rig store with timer-based correction loop and push model from DopplerWindow - Auto-select next pass when DopplerWindow opens - Setup UI with protocol/baud/CI-V address config and bridge tool guide - Disabled styles for Button/Input/Select, footerPadding prop on DraggableWindow - Unified baud rates, renamed mode labels to serial/rotctld/rigctld - Matched DopplerWindow width to RotatorWindow (400px, noPad)
1 parent e65a3ed commit a362474

17 files changed

Lines changed: 1698 additions & 177 deletions

src/app.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -54,6 +54,7 @@ import { palette } from './ui/shared/theme';
5454
import { chart } from './ui/shared/touch-metrics';
5555
import { feedbackStore } from './stores/feedback.svelte';
5656
import { rotatorStore } from './stores/rotator.svelte';
57+
import { rigStore } from './stores/rig.svelte';
5758
import { FeedbackEvent } from './feedback/types';
5859

5960
export class App {
@@ -741,6 +742,7 @@ export class App {
741742
feedbackStore.load();
742743
feedbackStore.installGlobalListeners();
743744
rotatorStore.load();
745+
rigStore.load();
744746
beamStore.onTrackingUpdate = (state) => rotatorStore.handleTrackingUpdate(state);
745747
uiStore.loadPassFilters();
746748
uiStore.loadMarkerGroups(this.cfg.markerGroups);

src/rig/civ.ts

Lines changed: 185 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,185 @@
1+
import type { RigDriver, RigConnectOptions } from './protocol';
2+
import { BinarySerialTransport } from '../serial/transport';
3+
4+
/**
5+
* Icom CI-V binary protocol over serial.
6+
*
7+
* Frame format: [FE FE] [toAddr] [fromAddr] [cmd] [subcmd?] [data...] [FD]
8+
* - FE FE = preamble
9+
* - toAddr = radio's CI-V address (default 0x94 for IC-7300)
10+
* - fromAddr = controller address (always 0xE0)
11+
* - FD = end of message
12+
*
13+
* Frequency is 5 BCD bytes, LSB first (10 digits of Hz).
14+
* Response: radio echoes command, then sends reply with FB (ok) or FA (error).
15+
*
16+
* Common CI-V addresses:
17+
* IC-705 = 0xA4 IC-7300 = 0x94
18+
* IC-9700 = 0xA2 IC-7100 = 0x88
19+
* IC-7610 = 0x98 IC-R8600 = 0x96
20+
*/
21+
const PREAMBLE = 0xFE;
22+
const EOM = 0xFD;
23+
const CONTROLLER_ADDR = 0xE0;
24+
const CMD_READ_FREQ = 0x03;
25+
const CMD_SET_FREQ = 0x05;
26+
const CMD_SET_MODE = 0x06;
27+
const ACK = 0xFB;
28+
const NAK = 0xFA;
29+
30+
/** Encode Hz as 5 BCD bytes, LSB first (Icom CI-V format). */
31+
function hzToBcd(hz: number): Uint8Array {
32+
const s = Math.round(hz).toString().padStart(10, '0');
33+
const bcd = new Uint8Array(5);
34+
// s = "0145800000" (10 digits) → BCD pairs from right to left
35+
for (let i = 0; i < 5; i++) {
36+
const hi = parseInt(s[9 - 2 * i - 1], 10);
37+
const lo = parseInt(s[9 - 2 * i], 10);
38+
bcd[i] = (hi << 4) | lo;
39+
}
40+
return bcd;
41+
}
42+
43+
/** Decode 5 BCD bytes (LSB first) to Hz. */
44+
function bcdToHz(bcd: Uint8Array, offset: number): number {
45+
let hz = 0;
46+
let mult = 1;
47+
for (let i = 0; i < 5; i++) {
48+
const b = bcd[offset + i];
49+
hz += ((b >> 4) * 10 + (b & 0x0F)) * mult;
50+
mult *= 100;
51+
}
52+
return hz;
53+
}
54+
55+
const MODE_CODES: Record<string, number> = {
56+
LSB: 0x00, USB: 0x01, AM: 0x02, CW: 0x03, FM: 0x05, 'CW-R': 0x07, 'RTTY': 0x04,
57+
};
58+
59+
export class CivDriver implements RigDriver {
60+
readonly name = 'Icom CI-V';
61+
private transport = new BinarySerialTransport();
62+
private civAddress: number = 0x94;
63+
64+
get connected() { return this.transport.connected; }
65+
66+
set onDisconnect(cb: (() => void) | null) { this.transport.onDisconnect = cb; }
67+
get onDisconnect() { return this.transport.onDisconnect; }
68+
69+
isSupported(): boolean {
70+
return this.transport.isSupported();
71+
}
72+
73+
async connect(options: RigConnectOptions): Promise<void> {
74+
if (options.civAddress !== undefined) this.civAddress = options.civAddress;
75+
await this.transport.open(options.baudRate ?? 19200);
76+
}
77+
78+
async disconnect(): Promise<void> {
79+
await this.transport.close();
80+
}
81+
82+
async setFrequency(hz: number): Promise<void> {
83+
const bcd = hzToBcd(hz);
84+
const frame = new Uint8Array([
85+
PREAMBLE, PREAMBLE, this.civAddress, CONTROLLER_ADDR,
86+
CMD_SET_FREQ, ...bcd, EOM,
87+
]);
88+
const response = await this.transport.sendAndReceive(frame, EOM);
89+
if (response.length === 0) return;
90+
if (this.isEcho(response, frame)) {
91+
const ack = await this.readResponse();
92+
this.checkAck(ack);
93+
} else if (this.isForUs(response)) {
94+
this.checkAck(response);
95+
} else {
96+
// Unsolicited frame — try to read the real response
97+
const ack = await this.readResponse();
98+
this.checkAck(ack);
99+
}
100+
}
101+
102+
async getFrequency(): Promise<number | null> {
103+
const frame = new Uint8Array([
104+
PREAMBLE, PREAMBLE, this.civAddress, CONTROLLER_ADDR,
105+
CMD_READ_FREQ, EOM,
106+
]);
107+
const response = await this.transport.sendAndReceive(frame, EOM);
108+
if (response.length === 0) return null;
109+
110+
// Skip echo and unsolicited frames
111+
let data = response;
112+
if (this.isEcho(response, frame)) {
113+
data = await this.readResponse();
114+
} else if (!this.isForUs(response)) {
115+
data = await this.readResponse();
116+
}
117+
if (data.length === 0) return null;
118+
119+
// Response: FE FE E0 <addr> <cmd> <5 BCD bytes> FD
120+
const cmdIdx = this.findCmd(data, CMD_READ_FREQ) ?? this.findCmd(data, CMD_SET_FREQ);
121+
if (cmdIdx === null || cmdIdx + 6 > data.length) return null;
122+
return bcdToHz(data, cmdIdx + 1);
123+
}
124+
125+
async setMode(mode: string): Promise<void> {
126+
const code = MODE_CODES[mode.toUpperCase()];
127+
if (code === undefined) return;
128+
const frame = new Uint8Array([
129+
PREAMBLE, PREAMBLE, this.civAddress, CONTROLLER_ADDR,
130+
CMD_SET_MODE, code, EOM, // omit filter byte — radio uses its default
131+
]);
132+
const response = await this.transport.sendAndReceive(frame, EOM);
133+
if (response.length === 0) return;
134+
if (this.isEcho(response, frame)) {
135+
const ack = await this.readResponse();
136+
this.checkAck(ack);
137+
} else if (this.isForUs(response)) {
138+
this.checkAck(response);
139+
} else {
140+
const ack = await this.readResponse();
141+
this.checkAck(ack);
142+
}
143+
}
144+
145+
/** Check if frame is addressed to us (from radio to controller). */
146+
private isForUs(frame: Uint8Array): boolean {
147+
return frame.length >= 4
148+
&& frame[0] === PREAMBLE && frame[1] === PREAMBLE
149+
&& frame[2] === CONTROLLER_ADDR
150+
&& frame[3] === this.civAddress;
151+
}
152+
153+
/** Read next frame addressed to us, skipping unsolicited transceive broadcasts. */
154+
private async readResponse(): Promise<Uint8Array> {
155+
for (let attempt = 0; attempt < 4; attempt++) {
156+
const frame = await this.transport.sendAndReceive(new Uint8Array(0), EOM);
157+
if (frame.length === 0) return frame;
158+
if (this.isForUs(frame)) return frame;
159+
// Not for us — unsolicited transceive broadcast, skip and try again
160+
}
161+
return new Uint8Array(0);
162+
}
163+
164+
private isEcho(response: Uint8Array, sent: Uint8Array): boolean {
165+
if (response.length !== sent.length) return false;
166+
for (let i = 0; i < sent.length; i++) {
167+
if (response[i] !== sent[i]) return false;
168+
}
169+
return true;
170+
}
171+
172+
private checkAck(frame: Uint8Array): void {
173+
if (frame.length === 0) return;
174+
for (let i = 0; i < frame.length; i++) {
175+
if (frame[i] === NAK) throw new Error('CI-V: command rejected (NAK)');
176+
}
177+
}
178+
179+
private findCmd(frame: Uint8Array, cmd: number): number | null {
180+
for (let i = 4; i < frame.length; i++) {
181+
if (frame[i] === cmd) return i;
182+
}
183+
return null;
184+
}
185+
}

src/rig/kenwood.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import type { RigDriver, RigConnectOptions } from './protocol';
2+
import { SerialTransport } from '../serial/transport';
3+
4+
/**
5+
* Kenwood/Elecraft CAT protocol over serial.
6+
* Uses `;` as command terminator. 11-digit frequency format.
7+
*
8+
* Commands:
9+
* FA00145800000; — set VFO A frequency (11 digits, Hz)
10+
* FA; — query VFO A frequency
11+
* MD1; — set mode (1=LSB, 2=USB, 3=CW, 4=FM, 5=AM, 6=FSK, 7=CW-R)
12+
*/
13+
const MODE_CODES: Record<string, string> = {
14+
LSB: '1', USB: '2', CW: '3', FM: '4', AM: '5', FSK: '6', 'CW-R': '7',
15+
};
16+
17+
export class KenwoodDriver implements RigDriver {
18+
readonly name = 'Kenwood';
19+
private transport = new SerialTransport(';');
20+
21+
get connected() { return this.transport.connected; }
22+
23+
set onDisconnect(cb: (() => void) | null) { this.transport.onDisconnect = cb; }
24+
get onDisconnect() { return this.transport.onDisconnect; }
25+
26+
isSupported(): boolean {
27+
return this.transport.isSupported();
28+
}
29+
30+
async connect(options: RigConnectOptions): Promise<void> {
31+
await this.transport.open(options.baudRate ?? 9600);
32+
// Disable auto-information mode to prevent unsolicited status messages
33+
await this.transport.sendOnly('AI0;');
34+
}
35+
36+
async disconnect(): Promise<void> {
37+
await this.transport.close();
38+
}
39+
40+
async setFrequency(hz: number): Promise<void> {
41+
const freq = Math.round(hz).toString().padStart(11, '0');
42+
await this.transport.sendOnly(`FA${freq};`);
43+
}
44+
45+
async getFrequency(): Promise<number | null> {
46+
let response = await this.transport.sendCommand('FA;');
47+
// If radio echoes the query back, read the actual response
48+
if (response === 'FA') {
49+
response = await this.transport.sendCommand('');
50+
}
51+
const match = response.match(/FA(\d+)/);
52+
if (!match) return null;
53+
const hz = parseInt(match[1], 10);
54+
return isNaN(hz) ? null : hz;
55+
}
56+
57+
async setMode(mode: string): Promise<void> {
58+
const code = MODE_CODES[mode.toUpperCase()];
59+
if (!code) return;
60+
await this.transport.sendOnly(`MD${code};`);
61+
}
62+
}

src/rig/protocol.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
/** Standard radio operating modes sent to rigs via setMode(). */
2+
export const RADIO_MODES = ['FM', 'USB', 'LSB', 'CW', 'AM'] as const;
3+
4+
/** Map SatNOGS signal mode names to radio operating modes. */
5+
const SATNOGS_TO_RADIO: Record<string, string> = {
6+
FM: 'FM', AFSK: 'FM', APT: 'FM', LRPT: 'FM', NFM: 'FM',
7+
USB: 'USB', BPSK: 'USB', SSTV: 'USB', FSK: 'USB', GFSK: 'USB',
8+
LSB: 'LSB', CW: 'CW', AM: 'AM',
9+
};
10+
11+
/** Convert a SatNOGS mode string to a radio mode, or '' if unknown. */
12+
export function satnogsModeToRadio(satnogsMode: string | null): string {
13+
if (!satnogsMode) return '';
14+
return SATNOGS_TO_RADIO[satnogsMode.toUpperCase()] ?? '';
15+
}
16+
17+
/** Connection mode for the rig. */
18+
export type RigMode = 'serial' | 'network';
19+
20+
/** Serial protocol variants for rig control. */
21+
export type RigSerialProtocol = 'kenwood' | 'yaesu' | 'yaesu-legacy' | 'civ';
22+
23+
export interface RigConnectOptions {
24+
baudRate?: number;
25+
wsUrl?: string;
26+
civAddress?: number;
27+
}
28+
29+
/** Abstract interface all rig protocol drivers implement. */
30+
export interface RigDriver {
31+
readonly name: string;
32+
readonly connected: boolean;
33+
isSupported(): boolean;
34+
connect(options: RigConnectOptions): Promise<void>;
35+
disconnect(): Promise<void>;
36+
setFrequency(hz: number): Promise<void>;
37+
getFrequency(): Promise<number | null>;
38+
setMode?(mode: string): Promise<void>;
39+
onDisconnect: (() => void) | null;
40+
}

0 commit comments

Comments
 (0)