Files
OpsLog/internal/cat/yaesu.go
T
rouggy 2d7469a7f7 fix: do not report a Yaesu as connected when it answers nothing
The seven minutes of reconnects in the operator's log turned out to be the radio
switched off — no defect. But the log said otherwise:

  20:14:42 yaesu: ID query failed (timeout) — continuing…
  20:14:44 yaesu: connected on COM7 @ 38400 baud, model="FTDX101D"

Opening the port was treated as connecting, and the model name was left over from
the previous session, so a powered-down rig produced a line claiming it was
connected as a named model. That is what sent me looking for a software fault.

Connect now tracks whether ANY probe answered. If none did, it clears the stale
model, says so in terms an operator can act on — is it powered on, is the CAT
rate right — and returns an error so the reconnect loop keeps trying rather than
believing it succeeded.
2026-07-29 21:53:26 +02:00

557 lines
19 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")
}
// Close any handle we still hold before opening another.
//
// Connect is called again on every reconnect, and it used to overwrite y.port
// with a fresh handle and leak the old one. Windows opens a serial port
// EXCLUSIVELY, so a leaked handle makes the next open fail with "Serial port
// busy" — seen in the field — and the retry loop then leaks one handle per
// attempt, once every few seconds, for as long as it keeps failing.
if y.port != nil {
_ = y.port.Close()
y.port = nil
}
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;")
answered := false
if id, err := y.ask("ID;"); err == nil {
answered = true
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") {
answered = true
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) {
answered = true
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.")
}
// A port that opens is not a rig that is there.
//
// The log used to announce "connected … model=FTDX101D" after every probe had
// timed out, with the model left over from the previous session — an operator
// whose radio was simply switched OFF got a line saying it was connected, and
// the real cause took a log study to find. Say what actually happened.
if !answered {
y.model = ""
debugLog.Printf("yaesu: %s opens at %d baud but the rig answers nothing — is it powered on, and is its CAT rate %d?",
y.portName, y.baud, y.baud)
return fmt.Errorf("yaesu: %s opened but the rig is not answering — check that it is switched on", y.portName)
}
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
}