Reported on a Xiegu G90. With the blue programming cable — three wires, no modem lines — CAT works perfectly. Behind a DE-19, which carries audio, CAT and PTT, the radio went into transmit the moment OpsLog connected and stayed there. Windows raises DTR and RTS when a serial port is opened, and the DE-19 reads them as PTT; Xiegu's own documentation asks for both low. The CI-V backend has dropped them since it was written, for the same reason on Icom rigs with USB SEND mapped to a line. Xiegu, Yaesu and Kenwood did not. They do now. The second half of the report: WSJT-X through OpsLog's rigctld decoded fine and never transmitted. Nothing was broken in that chain — set_ptt reaches the backend, which sends the CI-V PTT command, which a G90 ignores. That is why Xiegu keys on a hardware line instead. The backend can now do that: Settings → CAT → Xiegu → how the rig is keyed (CI-V / RTS / DTR). Untested here — no G90 in reach. The operator who reported it offered to try.
328 lines
10 KiB
Go
328 lines
10 KiB
Go
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
|
|
// pttLine is "", "rts" or "dtr": which hardware line keys the rig, when the
|
|
// CI-V command does not.
|
|
pttLine string
|
|
}
|
|
|
|
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,
|
|
}
|
|
}
|
|
|
|
// SetPTTLine selects the hardware line that keys this rig: "rts", "dtr", or ""
|
|
// for the CI-V command. Set before Connect.
|
|
func (x *Xiegu) SetPTTLine(line string) {
|
|
x.mu.Lock()
|
|
x.pttLine = strings.ToLower(strings.TrimSpace(line))
|
|
x.mu.Unlock()
|
|
}
|
|
|
|
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)
|
|
// Deassert DTR and RTS.
|
|
//
|
|
// Windows raises both when a serial port is opened, and a great many
|
|
// interfaces read them as PTT: a Xiegu G90 behind a DE-19 goes into
|
|
// transmit the moment OpsLog connects and STAYS there — Xiegu's own
|
|
// documentation asks for RTS and DTR low. The same applies to an Icom with
|
|
// USB SEND mapped to a line (which is why the CI-V backend has done this
|
|
// from the start) and to any rig keyed by a home-made cable.
|
|
//
|
|
// PTT on this backend is a CAT command, so neither line should ever be
|
|
// asserted here. A station keying by RTS/DTR configures that separately,
|
|
// on its own port.
|
|
_ = p.SetDTR(false)
|
|
_ = p.SetRTS(false)
|
|
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)
|
|
}
|
|
|
|
// SetPTT keys the transmitter, by CI-V or by a hardware line.
|
|
//
|
|
// A G90 does not transmit on the CI-V PTT command. Xiegu's own interfaces key
|
|
// on RTS or DTR instead — the DE-19 does exactly that — which is why the line
|
|
// can be selected here. Without it, WSJT-X talking to OpsLog's rigctld server
|
|
// decoded perfectly and never transmitted: the command left, the radio ignored
|
|
// it, and nothing in the chain was wrong enough to complain.
|
|
func (x *Xiegu) SetPTT(on bool) error {
|
|
x.mu.Lock()
|
|
defer x.mu.Unlock()
|
|
if x.port == nil {
|
|
return fmt.Errorf("xiegu: not connected")
|
|
}
|
|
switch x.pttLine {
|
|
case "rts":
|
|
return x.port.SetRTS(on)
|
|
case "dtr":
|
|
return x.port.SetDTR(on)
|
|
}
|
|
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, ok := civ.BCDToFreq(d.Data)
|
|
if !ok || 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
|
|
}
|
|
}
|