feat: Support for Antenna Genius
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
// Package civ implements the Icom CI-V protocol independently of the transport
|
||||
// carrying it. The exact same frames travel over a USB/serial port (local
|
||||
// control) and, wrapped in Icom's UDP "serial" stream, over the network
|
||||
// (remote control). Keeping the wire format in one place means the USB backend
|
||||
// (icomserial) and a future network backend (icomnet) share all of it — only
|
||||
// the transport differs.
|
||||
//
|
||||
// Frame layout: FE FE <to> <from> <cmd> [sub] [data…] FD
|
||||
package civ
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Protocol bytes.
|
||||
const (
|
||||
Pre = 0xFE // preamble (sent twice at the start of every frame)
|
||||
End = 0xFD // end-of-message
|
||||
OK = 0xFB // rig acknowledged a set command
|
||||
NG = 0xFA // rig rejected a set command
|
||||
|
||||
// AddrController is the conventional address software uses for itself.
|
||||
AddrController = 0xE0
|
||||
)
|
||||
|
||||
// Commands (the few Phase-1 control needs; more get added with the panel).
|
||||
const (
|
||||
CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned)
|
||||
CmdTransceiveMode = 0x01 // unsolicited mode update
|
||||
CmdReadFreq = 0x03
|
||||
CmdReadMode = 0x04
|
||||
CmdSetFreq = 0x05
|
||||
CmdSetMode = 0x06
|
||||
CmdPTT = 0x1C // sub 0x00 = PTT
|
||||
CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms
|
||||
CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model)
|
||||
|
||||
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
|
||||
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
|
||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||
|
||||
SubDataMode = 0x06
|
||||
SubPTT = 0x00
|
||||
|
||||
// CmdLevel sub-commands.
|
||||
SubLevelAF = 0x01 // AF (volume)
|
||||
SubLevelRF = 0x02 // RF gain
|
||||
SubLevelNR = 0x06 // noise-reduction depth
|
||||
SubLevelNB = 0x12 // noise-blanker depth
|
||||
|
||||
// CmdSwitch sub-commands.
|
||||
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
|
||||
SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW
|
||||
SubSwNB = 0x22 // noise blanker on/off
|
||||
SubSwNR = 0x40 // noise reduction on/off
|
||||
SubSwANF = 0x41 // auto-notch on/off
|
||||
)
|
||||
|
||||
// Icom mode codes (used by CmdReadMode / CmdSetMode).
|
||||
const (
|
||||
ModeLSB = 0x00
|
||||
ModeUSB = 0x01
|
||||
ModeAM = 0x02
|
||||
ModeCW = 0x03
|
||||
ModeRTTY = 0x04
|
||||
ModeFM = 0x05
|
||||
ModeCWR = 0x07
|
||||
ModeRTTYR = 0x08
|
||||
)
|
||||
|
||||
// Frame builds a complete CI-V frame (preamble … end) for payload, which is the
|
||||
// command byte followed by any sub-command/data bytes.
|
||||
func Frame(to, from byte, payload ...byte) []byte {
|
||||
f := make([]byte, 0, len(payload)+5)
|
||||
f = append(f, Pre, Pre, to, from)
|
||||
f = append(f, payload...)
|
||||
f = append(f, End)
|
||||
return f
|
||||
}
|
||||
|
||||
// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom
|
||||
// expects (10 digits, 2 per byte, least-significant byte first).
|
||||
func FreqToBCD(hz int64) []byte {
|
||||
if hz < 0 {
|
||||
hz = 0
|
||||
}
|
||||
b := make([]byte, 5)
|
||||
for i := 0; i < 5; i++ {
|
||||
lo := hz % 10
|
||||
hz /= 10
|
||||
hi := hz % 10
|
||||
hz /= 10
|
||||
b[i] = byte(lo) | byte(hi)<<4
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz.
|
||||
func BCDToFreq(b []byte) int64 {
|
||||
var hz int64
|
||||
mult := int64(1)
|
||||
for i := 0; i < len(b) && i < 5; i++ {
|
||||
hz += int64(b[i]&0x0F) * mult
|
||||
mult *= 10
|
||||
hz += int64(b[i]>>4) * mult
|
||||
mult *= 10
|
||||
}
|
||||
return hz
|
||||
}
|
||||
|
||||
// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's
|
||||
// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55).
|
||||
func LevelToBCD(v int) []byte {
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 255 {
|
||||
v = 255
|
||||
}
|
||||
return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)}
|
||||
}
|
||||
|
||||
// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255.
|
||||
func BCDToLevel(b []byte) int {
|
||||
if len(b) < 2 {
|
||||
return 0
|
||||
}
|
||||
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||
}
|
||||
|
||||
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||
func ByteToBCD(v int) byte {
|
||||
if v < 0 {
|
||||
v = 0
|
||||
}
|
||||
if v > 99 {
|
||||
v = 99
|
||||
}
|
||||
return byte((v/10)<<4 | v%10)
|
||||
}
|
||||
|
||||
func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) }
|
||||
|
||||
// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode
|
||||
// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute
|
||||
// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend.
|
||||
func ModeToADIF(m byte, data bool) string {
|
||||
switch m {
|
||||
case ModeCW, ModeCWR:
|
||||
return "CW"
|
||||
case ModeRTTY, ModeRTTYR:
|
||||
return "RTTY"
|
||||
case ModeAM:
|
||||
return "AM"
|
||||
case ModeFM:
|
||||
return "FM"
|
||||
case ModeLSB, ModeUSB:
|
||||
if data {
|
||||
return "DATA"
|
||||
}
|
||||
return "SSB"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable
|
||||
// model. Unknown addresses fall back to a hex label.
|
||||
func ModelName(addr byte) string {
|
||||
switch addr {
|
||||
case 0x94:
|
||||
return "IC-7300"
|
||||
case 0x98:
|
||||
return "IC-7610"
|
||||
case 0xA2:
|
||||
return "IC-9700"
|
||||
case 0xA4:
|
||||
return "IC-705"
|
||||
case 0x88:
|
||||
return "IC-7700"
|
||||
case 0x80:
|
||||
return "IC-7800"
|
||||
}
|
||||
return fmt.Sprintf("Icom (0x%02X)", addr)
|
||||
}
|
||||
|
||||
// Decoded is one parsed CI-V frame. Data is everything after the command byte
|
||||
// (so it still carries the sub-command for multi-byte commands like 1A 06).
|
||||
type Decoded struct {
|
||||
To byte
|
||||
From byte
|
||||
Cmd byte
|
||||
Data []byte
|
||||
}
|
||||
|
||||
// Scan extracts every complete frame from buf and reports how many leading
|
||||
// bytes the caller may now discard. A trailing partial frame (or a lone
|
||||
// preamble byte) is left unconsumed so it can be completed by the next read.
|
||||
func Scan(buf []byte) (frames []Decoded, consumed int) {
|
||||
pos := 0
|
||||
for {
|
||||
p := indexPreamble(buf, pos)
|
||||
if p < 0 {
|
||||
// No further preamble. Keep a trailing FE (possible start of the
|
||||
// next preamble); otherwise everything seen is consumable.
|
||||
if len(buf) > 0 && buf[len(buf)-1] == Pre {
|
||||
return frames, len(buf) - 1
|
||||
}
|
||||
return frames, len(buf)
|
||||
}
|
||||
start := p + 2
|
||||
for start < len(buf) && buf[start] == Pre { // tolerate padding FEs
|
||||
start++
|
||||
}
|
||||
end := bytes.IndexByte(buf[start:], End)
|
||||
if end < 0 {
|
||||
return frames, p // incomplete frame — keep from its preamble
|
||||
}
|
||||
end += start
|
||||
if body := buf[start:end]; len(body) >= 3 {
|
||||
frames = append(frames, Decoded{
|
||||
To: body[0],
|
||||
From: body[1],
|
||||
Cmd: body[2],
|
||||
Data: append([]byte(nil), body[3:]...),
|
||||
})
|
||||
}
|
||||
pos = end + 1
|
||||
consumed = pos
|
||||
}
|
||||
}
|
||||
|
||||
// indexPreamble returns the index of the next FE FE pair at or after from.
|
||||
func indexPreamble(buf []byte, from int) int {
|
||||
for i := from; i+1 < len(buf); i++ {
|
||||
if buf[i] == Pre && buf[i+1] == Pre {
|
||||
return i
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package civ
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestFreqBCDRoundTrip(t *testing.T) {
|
||||
cases := []int64{0, 1, 7074000, 14250000, 28074000, 50313000, 144174000, 1296000000}
|
||||
for _, hz := range cases {
|
||||
b := FreqToBCD(hz)
|
||||
if len(b) != 5 {
|
||||
t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b))
|
||||
}
|
||||
if got := BCDToFreq(b); got != hz {
|
||||
t.Errorf("round trip %d → % X → %d", hz, b, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFreqBCDKnownEncoding(t *testing.T) {
|
||||
// 14.250.000 Hz → little-endian BCD 00 00 25 14 00.
|
||||
want := []byte{0x00, 0x00, 0x25, 0x14, 0x00}
|
||||
if got := FreqToBCD(14250000); !bytes.Equal(got, want) {
|
||||
t.Errorf("FreqToBCD(14250000) = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFrame(t *testing.T) {
|
||||
// Read-frequency request to a 7610 (0x98) from the controller (0xE0).
|
||||
got := Frame(0x98, AddrController, CmdReadFreq)
|
||||
want := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD}
|
||||
if !bytes.Equal(got, want) {
|
||||
t.Errorf("Frame = % X, want % X", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSingleFreqResponse(t *testing.T) {
|
||||
// Rig (0x98) → controller (0xE0): freq read response for 14.250 MHz.
|
||||
in := Frame(AddrController, 0x98, CmdReadFreq, 0x00, 0x00, 0x25, 0x14, 0x00)
|
||||
frames, consumed := Scan(in)
|
||||
if consumed != len(in) {
|
||||
t.Fatalf("consumed=%d, want %d", consumed, len(in))
|
||||
}
|
||||
if len(frames) != 1 {
|
||||
t.Fatalf("got %d frames, want 1", len(frames))
|
||||
}
|
||||
f := frames[0]
|
||||
if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq {
|
||||
t.Errorf("addrs/cmd wrong: %+v", f)
|
||||
}
|
||||
if hz := BCDToFreq(f.Data); hz != 14250000 {
|
||||
t.Errorf("decoded freq %d, want 14250000", hz)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScanSkipsEchoAndKeepsPartial(t *testing.T) {
|
||||
echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back)
|
||||
resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response
|
||||
buf := append(append([]byte{}, echo...), resp...)
|
||||
buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet)
|
||||
|
||||
frames, consumed := Scan(buf)
|
||||
if len(frames) != 2 {
|
||||
t.Fatalf("got %d frames, want 2", len(frames))
|
||||
}
|
||||
// The partial frame must be left unconsumed so the next read can finish it.
|
||||
if consumed != len(echo)+len(resp) {
|
||||
t.Errorf("consumed=%d, want %d (partial frame retained)", consumed, len(echo)+len(resp))
|
||||
}
|
||||
if frames[1].Cmd != CmdReadMode || len(frames[1].Data) < 1 || frames[1].Data[0] != ModeCW {
|
||||
t.Errorf("second frame wrong: %+v", frames[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestModeToADIF(t *testing.T) {
|
||||
cases := []struct {
|
||||
m byte
|
||||
data bool
|
||||
want string
|
||||
}{
|
||||
{ModeUSB, false, "SSB"},
|
||||
{ModeLSB, false, "SSB"},
|
||||
{ModeUSB, true, "DATA"},
|
||||
{ModeCW, false, "CW"},
|
||||
{ModeCWR, false, "CW"},
|
||||
{ModeRTTY, false, "RTTY"},
|
||||
{ModeAM, false, "AM"},
|
||||
{ModeFM, false, "FM"},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if got := ModeToADIF(c.m, c.data); got != c.want {
|
||||
t.Errorf("ModeToADIF(0x%02X, %v) = %q, want %q", c.m, c.data, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLevelBCDRoundTrip(t *testing.T) {
|
||||
for _, v := range []int{0, 1, 50, 99, 100, 128, 200, 255} {
|
||||
b := LevelToBCD(v)
|
||||
if len(b) != 2 {
|
||||
t.Fatalf("LevelToBCD(%d) len=%d", v, len(b))
|
||||
}
|
||||
if got := BCDToLevel(b); got != v {
|
||||
t.Errorf("level round trip %d → % X → %d", v, b, got)
|
||||
}
|
||||
}
|
||||
// Known encodings from the Icom CI-V reference.
|
||||
if got := LevelToBCD(128); !bytes.Equal(got, []byte{0x01, 0x28}) {
|
||||
t.Errorf("LevelToBCD(128) = % X, want 01 28", got)
|
||||
}
|
||||
if got := LevelToBCD(255); !bytes.Equal(got, []byte{0x02, 0x55}) {
|
||||
t.Errorf("LevelToBCD(255) = % X, want 02 55", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestByteBCDRoundTrip(t *testing.T) {
|
||||
for _, v := range []int{0, 6, 12, 18, 21} {
|
||||
if got := BCDToByte(ByteToBCD(v)); got != v {
|
||||
t.Errorf("byte BCD round trip %d → %d", v, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestModelName(t *testing.T) {
|
||||
if got := ModelName(0x98); got != "IC-7610" {
|
||||
t.Errorf("ModelName(0x98) = %q, want IC-7610", got)
|
||||
}
|
||||
if got := ModelName(0x12); got != "Icom (0x12)" {
|
||||
t.Errorf("ModelName(0x12) = %q, want fallback", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user