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.
557 lines
19 KiB
Go
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
|
|
}
|