feat: native Xiegu CAT backend (G90 / X6100 / X6200 / X5105)

Xiegu speaks CI-V with a reduced command set: frames, BCD encoding, addressing
and the opcodes for frequency, mode, PTT and split are Icom's, so this reuses
internal/cat/civ wholesale instead of re-deriving a codec.

It is a SEPARATE backend rather than the Icom one at address 0x70, because what
the two rigs do NOT share is the deciding part. The Icom backend reads the
spectrum scope, the DSP block, data mode via 1A 06 and the model id via 19 —
none of which a Xiegu implements. Pointed at a G90 it would poll every cycle for
answers that never come, and spend its silence tolerance on commands the radio
was never going to support.

Two consequences of the rig's smaller mode table are handled explicitly rather
than left to fail: there is no data mode, so a digital QSO is set to plain
sideband (what the operator does on the radio anyway) instead of being refused;
and the split TX frequency is NOT reported, because reading the unselected VFO
needs 0x25, which the Xiegu table does not list — a split flag carrying a wrong
TX frequency is worse than the flag alone, since the frequency is what gets
logged.

The published command table has rows that slipped during typesetting (0x07 and
0x0F share a block). Where it contradicts itself the Icom meaning is used, the
rest of the table matching Icom exactly, and every unexpected reply is logged
raw so a first on-air run settles it.

NOT yet verified on a radio.
This commit is contained in:
2026-07-29 11:06:27 +02:00
parent 753d8d2ffa
commit e2aba828a9
7 changed files with 451 additions and 4 deletions
+289
View File
@@ -0,0 +1,289 @@
package cat
// Native Xiegu CAT (G90, X6100, X6200, X5105 and relatives) over the rig's
// serial/USB port.
//
// Xiegu speaks CI-V — Icom's bus protocol — with a REDUCED command set. Frames,
// BCD encoding, addressing and the opcodes for frequency (0x03/0x05), mode
// (0x04/0x06), PTT (0x1C 0x00), split (0x0F) and the meters (0x15) are the same
// as Icom's, which is why this backend reuses internal/cat/civ wholesale rather
// than re-deriving it.
//
// It is a SEPARATE backend rather than the Icom one with another address,
// because what the two rigs DON'T share is the important part. The Icom backend
// reads the spectrum scope, the DSP block, data-mode via 0x1A 0x06, the model id
// via 0x19 — none of which a Xiegu implements. Pointed at a G90 it would poll
// for answers that never come on every cycle, and its silence tolerance would
// spend itself on commands the radio was never going to support.
//
// Mode differences that matter: the Xiegu table lists LSB, USB, AM, CW and CWR
// only — no FM, no RTTY, and no data mode. A digital QSO therefore runs in USB
// (which is what the operator does on the radio anyway), and the mode is
// reported as the operator's configured digital mode when they select one, not
// invented from the rig.
//
// Verified on: nothing yet — written from the Xiegu CI-V command table. The
// command table published by Xiegu has rows that clearly slipped during
// typesetting (0x07 and 0x0F share a block), so where it contradicts itself the
// Icom meaning is used, since the rest of the table matches Icom exactly. Every
// unexpected reply is logged raw so a first on-air run settles it.
import (
"fmt"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/cat/civ"
)
// XieguDefaultAddr is the factory CI-V address of the G90/X6100 family.
const XieguDefaultAddr = 0x70
type Xiegu struct {
portName string
baud int
rigAddr byte
digital string
mu sync.Mutex
port serial.Port
curFreq int64
// splitSupported is cleared when the rig ignores the split query, so we stop
// asking every cycle — a Xiegu that has no split must not cost a timeout per
// poll, which would slow the whole loop to a crawl.
splitSupported bool
}
func NewXiegu(portName string, baud int, addr int, digital string) *Xiegu {
if baud <= 0 {
baud = 19200 // G90 factory default
}
if addr <= 0 || addr > 0xFF {
addr = XieguDefaultAddr
}
if strings.TrimSpace(digital) == "" {
digital = "FT8"
}
return &Xiegu{
portName: strings.TrimSpace(portName), baud: baud,
rigAddr: byte(addr), digital: digital, splitSupported: true,
}
}
func (x *Xiegu) Name() string { return "xiegu" }
func (x *Xiegu) Connect() error {
x.mu.Lock()
defer x.mu.Unlock()
if x.portName == "" {
return fmt.Errorf("xiegu: no serial port configured")
}
p, err := serial.Open(x.portName, &serial.Mode{BaudRate: x.baud})
if err != nil {
return fmt.Errorf("xiegu: open %s @ %d baud: %w", x.portName, x.baud, err)
}
p.SetReadTimeout(200 * time.Millisecond)
x.port = p
x.splitSupported = true
// Prove the link before declaring success: an open COM port says nothing
// about a radio being on the other end, and a backend that reports
// "connected" to a powered-off rig sends the operator hunting for a fault in
// the wrong place.
if _, err := x.readFreq(); err != nil {
_ = p.Close()
x.port = nil
return fmt.Errorf("xiegu: no answer on %s @ %d baud (address 0x%02X): %w", x.portName, x.baud, x.rigAddr, err)
}
debugLog.Printf("xiegu: connected on %s @ %d baud, CI-V address 0x%02X", x.portName, x.baud, x.rigAddr)
return nil
}
func (x *Xiegu) Disconnect() {
x.mu.Lock()
defer x.mu.Unlock()
if x.port != nil {
_ = x.port.Close()
x.port = nil
}
}
func (x *Xiegu) ReadState() (RigState, error) {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return RigState{}, fmt.Errorf("xiegu: not connected")
}
s := RigState{Backend: x.Name(), Connected: true, Rig: "Xiegu"}
hz, err := x.readFreq()
if err != nil {
return RigState{}, err // let the Manager reconnect
}
s.FreqHz = hz
x.curFreq = hz
if d, err := x.ask(civ.CmdReadMode); err == nil && len(d.Data) >= 1 {
s.Mode = civ.ModeToADIF(d.Data[0], false)
// The rig has no data mode, so it reports USB on the digital watering
// holes. Naming the operator's digital mode there is the frontend's job
// (it infers from frequency); reporting USB honestly is ours.
}
if x.splitSupported {
d, err := x.ask(civ.CmdSplit)
switch {
case err != nil:
debugLog.Printf("xiegu: split query got no answer (%v) — not asking again this session", err)
x.splitSupported = false
case len(d.Data) >= 1:
s.Split = d.Data[0] == 0x01
}
}
// Split TX frequency is deliberately NOT reported. Reading the unselected
// VFO needs 0x25, which the Xiegu table does not list — and a split flag with
// a wrong TX frequency is worse than a split flag alone, because it is the
// frequency that gets logged.
return s, nil
}
func (x *Xiegu) SetFrequency(hz int64) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
if hz <= 0 {
return fmt.Errorf("xiegu: invalid frequency %d", hz)
}
payload := append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)
return x.send(payload...)
}
func (x *Xiegu) SetMode(mode string) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
m, ok := xieguModeByte(mode, x.curFreq)
if !ok {
return fmt.Errorf("xiegu: no CAT mode for %q", mode)
}
return x.send(civ.CmdSetMode, m)
}
func (x *Xiegu) SetPTT(on bool) error {
x.mu.Lock()
defer x.mu.Unlock()
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
v := byte(0x00)
if on {
v = 0x01
}
return x.send(civ.CmdPTT, 0x00, v)
}
// ── helpers ───────────────────────────────────────────────────────────────
func (x *Xiegu) send(payload ...byte) error {
if x.port == nil {
return fmt.Errorf("xiegu: not connected")
}
_, err := x.port.Write(civ.Frame(x.rigAddr, civ.AddrController, payload...))
return err
}
// ask sends a query and returns the rig's answer frame.
//
// CI-V is a shared bus: the rig echoes back what we sent before answering, so
// our own frame has to be skipped. Matching on the SENDER (From == the rig)
// rather than on position is what makes this robust when an echo is dropped or
// an unsolicited frame arrives from the dial being turned.
func (x *Xiegu) ask(payload ...byte) (civ.Decoded, error) {
if err := x.send(payload...); err != nil {
return civ.Decoded{}, err
}
buf := make([]byte, 0, 64)
tmp := make([]byte, 64)
deadline := time.Now().Add(600 * time.Millisecond)
for time.Now().Before(deadline) {
n, err := x.port.Read(tmp)
if err != nil {
return civ.Decoded{}, err
}
if n == 0 {
continue
}
buf = append(buf, tmp[:n]...)
frames, consumed := civ.Scan(buf)
buf = buf[consumed:]
for _, f := range frames {
if f.From != x.rigAddr {
continue // our own echo on the bus
}
if f.Cmd == 0xFA {
return civ.Decoded{}, fmt.Errorf("xiegu: rig rejected command 0x%02X", payload[0])
}
if f.Cmd == payload[0] {
return f, nil
}
// An unsolicited update (the operator turning the dial) — useful, but
// not the answer we asked for.
debugLog.Printf("xiegu: unsolicited frame cmd=0x%02X data=% X", f.Cmd, f.Data)
}
}
return civ.Decoded{}, fmt.Errorf("xiegu: timeout answering 0x%02X", payload[0])
}
func (x *Xiegu) readFreq() (int64, error) {
d, err := x.ask(civ.CmdReadFreq)
if err != nil {
return 0, err
}
hz := civ.BCDToFreq(d.Data)
if hz <= 0 {
return 0, fmt.Errorf("xiegu: unusable frequency % X", d.Data)
}
return hz, nil
}
// xieguModeByte maps an ADIF mode to the Xiegu's mode byte.
//
// The rig has LSB, USB, AM, CW and CWR — nothing else. A digital mode therefore
// becomes USB (or LSB below 10 MHz), which is what the operator selects on the
// radio; claiming a DATA mode it does not have would just be refused.
func xieguModeByte(mode string, freqHz int64) (byte, bool) {
lowBand := freqHz > 0 && freqHz < 10_000_000
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "":
return 0, false
case "LSB":
return 0x00, true
case "USB":
return 0x01, true
case "SSB":
if lowBand {
return 0x00, true
}
return 0x01, true
case "AM":
return 0x02, true
case "CW":
return 0x03, true
case "CWR", "CW-R":
return 0x07, true
default:
// Every digital sub-mode rides on plain sideband here.
if lowBand {
return 0x00, true
}
return 0x01, true
}
}
+83
View File
@@ -0,0 +1,83 @@
package cat
import (
"testing"
"hamlog/internal/cat/civ"
)
// The Xiegu mode table is SHORTER than Icom's: LSB, USB, AM, CW, CWR and
// nothing else. The interesting cases are the ones with no equivalent — a
// digital mode must land on plain sideband rather than be refused, because
// refusing it would leave the rig on whatever it was and silently log the wrong
// mode.
func TestXieguModeByte(t *testing.T) {
cases := []struct {
mode string
hz int64
want byte
ok bool
}{
{"SSB", 7150000, 0x00, true}, // LSB below 10 MHz
{"SSB", 14250000, 0x01, true}, // USB above
{"LSB", 14250000, 0x00, true}, // explicit beats the convention
{"USB", 7150000, 0x01, true},
{"AM", 7150000, 0x02, true},
{"CW", 7030000, 0x03, true},
{"CWR", 7030000, 0x07, true},
{"FT8", 7074000, 0x00, true}, // no data mode on this rig → LSB
{"FT8", 14074000, 0x01, true}, // → USB
{"RTTY", 14080000, 0x01, true},
{"FM", 145000000, 0x01, true}, // no FM either; sideband is the honest fallback
{"", 14074000, 0x00, false},
}
for _, c := range cases {
got, ok := xieguModeByte(c.mode, c.hz)
if got != c.want || ok != c.ok {
t.Errorf("xieguModeByte(%q, %d) = 0x%02X,%v — want 0x%02X,%v", c.mode, c.hz, got, ok, c.want, c.ok)
}
}
}
// The Xiegu mode bytes must decode through the shared Icom table, since that is
// the whole reason this backend reuses internal/cat/civ instead of its own
// codec. If the two ever disagree, the radio would be set to one mode and read
// back as another.
func TestXieguModesRoundTripThroughCIV(t *testing.T) {
cases := []struct {
mode string
adif string
}{
// ADIF has no LSB/USB distinction — both sidebands ARE the SSB mode, and
// that is what gets logged. The sideband still matters to the radio, which
// is why xieguModeByte picks it from the frequency.
{"LSB", "SSB"},
{"USB", "SSB"},
{"AM", "AM"},
{"CW", "CW"},
}
for _, c := range cases {
b, ok := xieguModeByte(c.mode, 14200000)
if !ok {
t.Fatalf("xieguModeByte(%q) refused", c.mode)
}
if got := civ.ModeToADIF(b, false); got != c.adif {
t.Errorf("%s → 0x%02X → %q, want %q", c.mode, b, got, c.adif)
}
}
}
// Frequency encoding is shared with Icom, and it is the one field where a byte
// out of place mistunes the radio silently. Pinned here at the boundaries a
// Xiegu actually covers (it is an HF rig, so the 5-byte BCD upper bytes are 0).
func TestXieguFrequencyEncoding(t *testing.T) {
for _, hz := range []int64{1840000, 7074000, 14074000, 28500000, 50313000} {
b := civ.FreqToBCD(hz)
if len(b) != 5 {
t.Fatalf("FreqToBCD(%d) produced %d bytes, want 5", hz, len(b))
}
if got := civ.BCDToFreq(b); got != hz {
t.Errorf("round trip %d → % X → %d", hz, b, got)
}
}
}