Three corrections from the operator's second pass. The sideband gesture was wrong: I used double-click, which hides the action. Clicking a button that is ALREADY active now flips its sideband — CW-U → CW-L → CW-U. One button, one finger, nothing to discover. A lit SPLIT chip does not tell an operator anything useful: it says split is on, not where they transmit. The header now shows the TX frequency and the offset in kHz whenever split is active. And the offset that matters is set in one action: up 1 kHz on CW, up 5 kHz on phone. Doing it by hand means swapping VFOs, retuning and swapping back — exactly the fumbling a panel exists to remove. Both are offered rather than picked from the mode, because which one is idiomatic is the operator's call, and the button turns split on at the same time. The offset is measured from the RECEIVE frequency and written to the VFO we are not listening on, so it stays correct when the operator works on VFO B, where the roles are mirrored.
408 lines
13 KiB
Go
408 lines
13 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
|
|
// 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
|
|
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
|
|
}
|
|
|
|
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
|
|
// 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
|
|
}
|
|
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
|
|
}
|
|
}
|