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 } }