package cat // Native Yaesu CAT over the rig's serial/USB port — no OmniRig. // // Why this exists: OmniRig sits between OpsLog and the radio and adds its own // rig-description files, its own VFO/split interpretation and its own polling. // Every Yaesu problem reported so far came from that layer disagreeing with the // radio — a .ini that never exposes the VFO, a Freq property that means A on one // model and B on another, a split flag that alternates. Talking to the rig // directly removes the disagreement: what the radio answers is what we show. // // ── The protocol ────────────────────────────────────────────────────────── // Modern Yaesu CAT is plain ASCII: a command, its arguments, and a ';' // terminator. A query is the command with no argument; the rig echoes the same // command with the value. It is the same shape as Kenwood's, which is why an // FTDX10 answers a Kenwood-speaking logger for the basics. // // FA; → FA014074000; VFO A frequency, 9 digits, Hz // FB; → FB014100000; VFO B frequency // MD0; → MD02; operating mode of the main receiver // VS; → VS0; which VFO is selected (0=A, 1=B) // ST; → ST1; split (FTDX10/FTDX101) // FT; → FT1; TX VFO (FT-991A/FT-710/FT-891 family) // TX1; / TX0; key / unkey // ID; → ID0761; model identifier // // Two of these are genuinely uncertain across the family and are treated as // such rather than guessed at: SPLIT is read through ST and, if the rig does not // answer that, through FT — whichever replies wins, and the choice is // remembered. Every unrecognised reply is logged raw, because that log is the // only way to learn a model's real behaviour from an operator's shack. // // Verified on: FTDX10, 2026-07-29 — frequency, mode, VFO and split all correct // against the radio. The other models are still inference from the same CAT // reference; anything this file asserts about a rig it has not met should be // read as a hypothesis with a log line attached. import ( "fmt" "strconv" "strings" "sync" "time" "go.bug.st/serial" ) // yaesuModels maps the ID reply to a display name. An unknown id is shown as // itself rather than guessed — a wrong model name would be worse than a number, // because it silently implies capabilities the rig may not have. var yaesuModels = map[string]string{ "0761": "FTDX10", "0681": "FTDX101D", "0682": "FTDX101MP", "0800": "FT-710", "0570": "FT-991A", "0650": "FT-891", "0670": "FT-DX3000", "0460": "FT-450D", } // yaesuModeToADIF maps the MD digit to an ADIF mode. The DATA and RTTY variants // differ only by sideband, which ADIF does not record — they collapse to the // operator's configured digital mode and to RTTY respectively. var yaesuModeToADIF = map[byte]string{ '1': "LSB", '2': "USB", '3': "CW", '4': "FM", '5': "AM", '6': "RTTY", '7': "CW", '8': "DATA", '9': "RTTY", 'A': "FM", 'B': "FM", 'C': "DATA", 'D': "AM", 'E': "FM", // C4FM — digital voice, closest ADIF sense is FM } type Yaesu struct { portName string baud int digital string // ADIF mode reported for DATA (FT8, RTTY…) mu sync.Mutex port serial.Port model string // splitCmd is learned at connect: "ST" or "FT" depending on which the rig // answers. Empty means the rig answered neither, and split is reported as // off rather than invented. splitCmd string curFreq int64 curVFO string // "A" or "B" } func NewYaesu(portName string, baud int, digital string) *Yaesu { if baud <= 0 { baud = 38400 // FTDX10/FTDX101 factory default } if strings.TrimSpace(digital) == "" { digital = "FT8" } return &Yaesu{portName: strings.TrimSpace(portName), baud: baud, digital: digital, curVFO: "A"} } func (y *Yaesu) Name() string { return "yaesu" } func (y *Yaesu) Connect() error { y.mu.Lock() defer y.mu.Unlock() if y.portName == "" { return fmt.Errorf("yaesu: no serial port configured") } p, err := serial.Open(y.portName, &serial.Mode{BaudRate: y.baud}) if err != nil { return fmt.Errorf("yaesu: open %s @ %d baud: %w", y.portName, y.baud, err) } p.SetReadTimeout(300 * time.Millisecond) y.port = p // Silence unsolicited status reports. The rig can push them on every knob // movement (AI1), which interleaves with our request/response pairs and makes // a reply impossible to attribute — we poll instead, so the traffic is ours. _ = y.write("AI0;") if id, err := y.ask("ID;"); err == nil { code := strings.TrimSuffix(strings.TrimPrefix(id, "ID"), ";") if name, ok := yaesuModels[code]; ok { y.model = name } else { y.model = "Yaesu (" + code + ")" debugLog.Printf("yaesu: unknown model id %q — add it to yaesuModels", code) } } else { debugLog.Printf("yaesu: ID query failed (%v) — continuing, the model name is cosmetic", err) } // Which command carries split on THIS rig. Asking once at connect and // remembering the answer keeps the poll loop from paying for two round trips // per cycle, and makes "neither answered" an explicit, logged state instead // of a silent assumption that split is off. for _, c := range []string{"ST", "FT"} { if r, err := y.ask(c + ";"); err == nil && strings.HasPrefix(r, c) { y.splitCmd = c debugLog.Printf("yaesu: split is read through %s (answered %q)", c, r) break } } if y.splitCmd == "" { debugLog.Printf("yaesu: neither ST; nor FT; answered — split will be reported as OFF. Send this log if the rig does have split.") } debugLog.Printf("yaesu: connected on %s @ %d baud, model=%q", y.portName, y.baud, y.model) return nil } func (y *Yaesu) Disconnect() { y.mu.Lock() defer y.mu.Unlock() if y.port != nil { _ = y.port.Close() y.port = nil } } func (y *Yaesu) ReadState() (RigState, error) { y.mu.Lock() defer y.mu.Unlock() if y.port == nil { return RigState{}, fmt.Errorf("yaesu: not connected") } s := RigState{Backend: y.Name(), Connected: true, Rig: y.model} faRaw, err := y.ask("FA;") if err != nil { return RigState{}, err // the rig stopped answering — let the Manager reconnect } freqA, ok := parseYaesuFreq(faRaw, "FA") if !ok { return RigState{}, fmt.Errorf("yaesu: unparsable FA reply %q", faRaw) } freqB := int64(0) if r, err := y.ask("FB;"); err == nil { freqB, _ = parseYaesuFreq(r, "FB") } // Which VFO the operator is listening on. Unlike OmniRig there is no // interpretation to do: VS answers 0 or 1. vfo := "A" if r, err := y.ask("VS;"); err == nil && len(r) >= 3 && r[2] == '1' { vfo = "B" } y.curVFO = vfo split := false if y.splitCmd != "" { if r, err := y.ask(y.splitCmd + ";"); err == nil { split = yaesuSplitOn(r, y.splitCmd) } } s.Vfo = vfo s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split) y.curFreq = s.FreqHz if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 { if m, ok := yaesuModeToADIF[r[3]]; ok { if m == "DATA" { m = y.digital } s.Mode = m } else { debugLog.Printf("yaesu: unknown mode reply %q", r) } } return s, nil } func (y *Yaesu) SetFrequency(hz int64) error { y.mu.Lock() defer y.mu.Unlock() if y.port == nil { return fmt.Errorf("yaesu: not connected") } if hz <= 0 || hz > 999_999_999 { return fmt.Errorf("yaesu: frequency %d out of the 9-digit CAT range", hz) } // Write to the VFO the operator is ACTUALLY on. Always writing FA is what // makes a display disagree with the radio when the operator is on B. cmd := "FA" if y.curVFO == "B" { cmd = "FB" } return y.write(fmt.Sprintf("%s%09d;", cmd, hz)) } func (y *Yaesu) SetMode(mode string) error { y.mu.Lock() defer y.mu.Unlock() if y.port == nil { return fmt.Errorf("yaesu: not connected") } d := yaesuModeDigit(mode, y.curFreq) if d == 0 { return fmt.Errorf("yaesu: no CAT mode for %q", mode) } return y.write(fmt.Sprintf("MD0%c;", d)) } func (y *Yaesu) SetPTT(on bool) error { y.mu.Lock() defer y.mu.Unlock() if y.port == nil { return fmt.Errorf("yaesu: not connected") } if on { return y.write("TX1;") } return y.write("TX0;") } // ── helpers ─────────────────────────────────────────────────────────────── // write sends one command. The caller holds the mutex. func (y *Yaesu) write(cmd string) error { if y.port == nil { return fmt.Errorf("yaesu: not connected") } _, err := y.port.Write([]byte(cmd)) return err } // ask sends a query and reads the reply up to its ';'. The caller holds the // mutex, so a command and its answer are never interleaved with another's. func (y *Yaesu) ask(cmd string) (string, error) { if err := y.write(cmd); err != nil { return "", err } buf := make([]byte, 0, 32) tmp := make([]byte, 32) deadline := time.Now().Add(600 * time.Millisecond) for time.Now().Before(deadline) { n, err := y.port.Read(tmp) if err != nil { return "", err } if n == 0 { continue // read timeout — the rig may still be composing its answer } buf = append(buf, tmp[:n]...) if i := strings.IndexByte(string(buf), ';'); i >= 0 { return string(buf[:i+1]), nil } if len(buf) > 512 { return "", fmt.Errorf("yaesu: no ';' in %d bytes answering %q", len(buf), cmd) } } return "", fmt.Errorf("yaesu: timeout answering %q", cmd) } // parseYaesuFreq reads "FA014074000;" into Hz. func parseYaesuFreq(reply, prefix string) (int64, bool) { r := strings.TrimSpace(reply) if !strings.HasPrefix(r, prefix) { return 0, false } digits := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";") if digits == "" { return 0, false } hz, err := strconv.ParseInt(digits, 10, 64) if err != nil || hz <= 0 { return 0, false } return hz, true } // yaesuSplitOn reads the split reply for whichever command the rig answers. // // ST is a split flag: ST1 means split. FT names the TX VFO: FT1 means transmit // on VFO B, which IS split when the operator is listening on A. The two are not // the same statement, which is why the command in use is remembered rather than // both being tried and merged. func yaesuSplitOn(reply, cmd string) bool { r := strings.TrimSpace(reply) if !strings.HasPrefix(r, cmd) || len(r) < len(cmd)+1 { return false } return r[len(cmd)] == '1' } // resolveYaesuVFOs turns the two frequencies plus the VFO and split flags into // the ADIF pair: FreqHz is where we TRANSMIT, RxFreqHz only when split. // // Kept pure and separate from ReadState so the rules can be tested without a // radio — the equivalent OmniRig function is where every Yaesu bug lived. func resolveYaesuVFOs(freqA, freqB int64, vfo string, split bool) (tx, rx int64, isSplit bool) { listening, transmitting := freqA, freqB if vfo == "B" { listening, transmitting = freqB, freqA } if !split { return listening, 0, false } // Split with a missing or identical other VFO is not split: reporting it // would put a wrong TX frequency in the log, which is worse than ignoring a // flag the rig may have left set. if transmitting <= 0 || transmitting == listening { return listening, 0, false } return transmitting, listening, true } // yaesuModeDigit maps an ADIF mode to the MD digit. SSB has no single digit — // the sideband follows the worldwide convention (LSB below 10 MHz, USB above), // which is why the current frequency is part of the decision. func yaesuModeDigit(mode string, freqHz int64) byte { switch strings.ToUpper(strings.TrimSpace(mode)) { case "SSB": if freqHz > 0 && freqHz < 10_000_000 { return '1' // LSB } return '2' // USB case "LSB": return '1' case "USB": return '2' case "CW": return '3' case "FM": return '4' case "AM": return '5' case "RTTY": return '6' case "": return 0 default: // Everything else is a digital sub-mode (FT8, FT4, PSK31, JS8…). They all // ride on the rig's DATA mode; the sideband follows the same convention. if freqHz > 0 && freqHz < 10_000_000 { return '8' // DATA-LSB } return 'C' // DATA-USB } }