Files
OpsLog/internal/cat/yaesu.go
T
rouggy 6501e97895 fix: read the FIRST digit of the Yaesu VFO reply — I inverted it yesterday
Reported both ways round, which is what settles it. With RX and TX on SUB
everything worked; with them on MAIN the frequency froze and a spot click tuned
the sub VFO. The log shows the rig answering FR01 in the MAIN case.

So on an FTDX101 the state is the FIRST digit — 0 = main — and the second is a
separate parameter. Yesterday I changed this to the LAST digit, which read FR01
as SUB and inverted the whole thing. The fault that change was meant to fix ("SUB
shows MAIN") came from reading VS instead of FR, and probing FR had already fixed
it on its own; the digit change was an unnecessary guess layered on a real fix.

Nothing else needed changing for the operator's two questions: the display and
SetFrequency already follow curVFO, so a spot now tunes whichever VFO holds RX
and TX, and the main frequency is read when on main. A test states that in those
terms — active VFO and which command a spot writes — rather than only as a byte,
because the byte is what I got wrong while believing the logic was right.
2026-07-29 21:39:22 +02:00

530 lines
18 KiB
Go

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
}