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 // FR; → FR1; RECEIVE VFO (0=main/A, 1=sub/B) // VS; → VS0; selected VFO, on models without FR // 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 ( "errors" "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 // rxVFOCmd is "FR" when the rig reports its receive VFO that way, else empty // and VS is used — see ReadState. rxVFOCmd string curFreq int64 curRXFreq int64 curVFO string // "A" or "B" // Control-panel state and its slow-beat counter — see yaesu_panel.go. panel YaesuTXState panelCycle int panelLoaded bool // Commands this rig answered "?;" to — asked once, then never again. unsupported map[string]bool // Needle inertia for the TX meters — see meterPeak. powerPeak meterPeak powerWPeak meterPeak swrPeak meterPeak // metersLogged counts the RM1..RM6 samples taken during transmission, so the // survey follows a real carrier instead of catching one instant of it. metersLogged int } 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. // Which command reports the RECEIVE VFO. FR is the right one where it exists; // VS is a weaker substitute that an FTDX101 answers with the main VFO even // when the operator has moved both RX and TX to sub. if r, err := y.ask("FR;"); err == nil && strings.HasPrefix(r, "FR") { y.rxVFOCmd = "FR" debugLog.Printf("yaesu: receive VFO is read through FR (answered %q)", r) } else { debugLog.Printf("yaesu: no FR; — falling back to VS for the receive VFO") } 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 errors.Is(err, errYaesuUnsupported) { // A "?;" here is almost never about FA — the rig answers frequency queries // perfectly well. It is a rejection left over from the PREVIOUS command // that our read then attributed to this one. Retrying once costs a few // milliseconds; treating it as "lost the rig" tore the CAT link down and // reconnected it, which is what the operator saw on every CW macro. debugLog.Printf("yaesu: FA; got a stray rejection — retrying once") 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. // // FR is the command that answers that — it selects the receive VFO — and VS // does not: on an FTDX101 with both RX and TX moved to SUB, VS still reported // the main VFO, so OpsLog displayed VFO A while the operator was entirely on // B. Reported 2026-07-29. FR is asked first and VS is the fallback for models // that do not implement it. vfo := "A" switch { case y.rxVFOCmd != "": if r, err := y.ask(y.rxVFOCmd + ";"); err == nil && yaesuStateDigit(r, y.rxVFOCmd) == '1' { vfo = "B" } default: if r, err := y.ask("VS;"); err == nil && yaesuStateDigit(r, "VS") == '1' { vfo = "B" } } y.curVFO = vfo split := false if y.splitCmd != "" { if r, err := y.ask(y.splitCmd + ";"); err == nil { split = yaesuSplitFromReply(r, y.splitCmd, vfo) } } s.Vfo = vfo s.FreqHz, s.RxFreqHz, s.Split = resolveYaesuVFOs(freqA, freqB, vfo, split) y.curFreq = s.FreqHz // The frequency being LISTENED to, which is what a split offset is measured // from — under split that is RxFreqHz, not FreqHz. y.curRXFreq = s.FreqHz if s.Split && s.RxFreqHz > 0 { y.curRXFreq = s.RxFreqHz } if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 { // Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together, // but the panel has to show which sideband the rig is actually on. y.panel.RawMode = yaesuRawModeName(r[3]) 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) } } // s.FreqHz is the TX frequency by the ADIF convention, so it IS the split // transmit frequency when split is on. y.readPanel(s.Mode, s.Split, s.FreqHz) 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 } // Match the reply to the COMMAND, and drop anything else. // // Returning the first ';'-terminated string whatever it was is what made a CW // macro knock the CAT link over: KY produces no reply, so the next query — // FA; from the poll loop — collected a leftover frame, failed to parse as a // frequency, and the Manager treated that as "lost the rig" and reconnected. // The operator saw the CAT drop for a few seconds on every macro click. want := cmdPrefix(cmd) buf := make([]byte, 0, 64) tmp := make([]byte, 64) 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]...) for { i := strings.IndexByte(string(buf), ';') if i < 0 { break } frame := string(buf[:i+1]) buf = buf[i+1:] // "?;" is the rig saying it does not know this command. Confirmed on an // FTDX10, which answers it to KY; and MG;. Reporting it as such — rather // than discarding it and waiting out the timeout — is what lets callers // stop asking instead of paying 600 ms per poll for ever. if strings.TrimSpace(frame) == "?;" { return "", errYaesuUnsupported } if want == "" || strings.HasPrefix(strings.ToUpper(frame), want) { return frame, nil } debugLog.Printf("yaesu: discarding %q while waiting for %s (asked %q)", frame, want, cmd) } 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) } // errYaesuUnsupported is returned when the rig answers "?;" — it does not know // the command. Different models implement different subsets, and the only // reliable way to learn which is to ask once and remember the refusal. var errYaesuUnsupported = errors.New("yaesu: command not supported by this rig") // cmdPrefix is the leading letters of a command — what its reply starts with. // "FA;" → "FA", "MD0;" → "MD", "KY;" → "KY". func cmdPrefix(cmd string) string { c := strings.ToUpper(strings.TrimSpace(cmd)) for i := 0; i < len(c); i++ { if c[i] < 'A' || c[i] > 'Z' { return c[:i] } } return c } // 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 } // yaesuSplitFromReply turns the split reply into a yes or no. // // The two commands say DIFFERENT things and reading them alike is a real fault, // reported on an FTDX101 (F4NBZ, 2026-07-29) where the panel showed split ON with // the radio OFF and the reverse: // // ST is a split FLAG — ST1 means split, whatever VFO is in use. // FT names the TX VFO — FT0 = transmit on A, FT1 = transmit on B. // // Split is on when the rig TRANSMITS on a different VFO from the one it is // LISTENING to. Reading FT1 as "split" is therefore only right for an operator // on VFO A: on SUB it is exactly inverted, which is why the same model behaved // correctly for one operator and backwards for another — one was on MAIN, the // other on SUB. func yaesuSplitFromReply(reply, cmd, vfo string) bool { d := yaesuStateDigit(reply, cmd) if d == 0 { return false } if cmd == "ST" { return d == '1' } // FT: compare the transmit VFO with the one being listened to. txOnB := d == '1' rxOnB := strings.HasPrefix(strings.ToUpper(vfo), "B") return txOnB != rxOnB } // 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 } } // yaesuStateDigit returns the STATE digit of a reply — the FIRST digit after // the command — or 0 if the reply does not belong to this command. // // The parameter is not always one character: an FTDX101 answers "FR01;" where an // FTDX10 answers "FR0;". The state is the FIRST digit in both; the second is a // separate parameter. Reading the LAST digit inverted it — with RX and TX on // MAIN the rig answered FR01, OpsLog concluded SUB, the main frequency stopped // updating and a spot click tuned VFO B (F4NBZ, 2026-07-29). That was my own // correction of the previous evening, made the wrong way round: the earlier // "SUB shows MAIN" fault came from reading VS, not from this digit. func yaesuStateDigit(reply, cmd string) byte { r := strings.TrimSpace(reply) if !strings.HasPrefix(r, cmd) || len(r) <= len(cmd) { return 0 } d := r[len(cmd)] if d < '0' || d > '9' { return 0 } return d }