feat: Yaesu control panel (meters, bands, DSP, TX), and drop a stale Flex hint

A console pane for the native Yaesu backend, in the same shape as the Icom and
Flex ones: S/PO/SWR meters, band and mode rows, AF/RF/squelch, AGC, the IPO/AMP1/
AMP2 front-end selector, ATT, NB, DNR + level, narrow filter, power in watts, mic
gain, VOX, split and ATU tune.

Three decisions worth keeping:

Panel reads are STAGGERED and live in their own file, away from ReadState. Meters
poll every cycle; settings only change when someone turns a knob, so they refresh
every 8th cycle and right after any set. Polling all of it every cycle would put
twenty queries a second on the serial link the frequency display shares.

Band buttons use the rig's own band memory (BS) rather than a frequency we pick,
so 20 m lands where the operator last was on 20 m — what the radio's own band
keys do.

A set is followed by a read-back, so the panel shows what the RIG ended up with,
not what we asked for; the two differ whenever a value is out of range or the
mode forbids the control. And a control the model lacks keeps its previous value
instead of dropping to zero, which reads as a setting that reset itself.

The S9 point of the S-meter scale is a hypothesis (the manual does not state it)
and is commented as such — one number to correct if reports come out an S unit
off, rather than a fudge spread through the RST helper.

Also removes the FlexRadio settings blurb, which explained the backend to
someone who had already chosen it.
This commit is contained in:
2026-07-29 11:17:08 +02:00
parent e2aba828a9
commit 842d4708a7
12 changed files with 1072 additions and 15 deletions
+404
View File
@@ -0,0 +1,404 @@
package cat
// The Yaesu control panel: meters and the settings an operator reaches for
// mid-QSO. Split out from yaesu.go, which stays the small fast path the whole
// application depends on — a panel read that hangs must never delay the
// frequency display.
//
// Reads are STAGGERED. Meters change constantly and are polled every cycle;
// settings (power, gains, AGC, filters) only change when someone turns a knob,
// so they are refreshed every 8th cycle and immediately after any set. At the
// default 250 ms cycle that is a two-second worst case for a knob turned on the
// radio, against a serial link that would otherwise carry twenty queries a
// second and starve the frequency poll it shares.
//
// Verified on: FTDX10. Commands come from its CAT reference; a control a model
// does not implement simply never answers, and askNum keeps the previous value
// rather than showing a zero that reads as "it reset itself".
import (
"fmt"
"strconv"
"strings"
"time"
)
// YaesuTXState is the panel snapshot handed to the frontend.
type YaesuTXState struct {
Available bool `json:"available"`
Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"`
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
SMeter int `json:"s_meter"` // 0-100 (raw 0-255)
PowerMeter int `json:"power_meter"` // 0-100, TX only
SWRMeter int `json:"swr_meter"` // 0-100, TX only
RFPower int `json:"rf_power"` // watts
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
AGC string `json:"agc,omitempty"`
Preamp int `json:"preamp"` // 0=IPO, 1=AMP1, 2=AMP2
Att int `json:"att"` // 0=off, else dB
NB bool `json:"nb"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"` // 1-15
Narrow bool `json:"narrow"` // NAR filter
VOX bool `json:"vox"`
}
// YaesuController is the typed escape the Manager exposes for the panel, in the
// same shape as FlexController and IcomController.
type YaesuController interface {
YaesuState() YaesuTXState
RefreshYaesu() error
SetYaesuPower(int) error
SetYaesuMicGain(int) error
SetYaesuAFGain(int) error
SetYaesuRFGain(int) error
SetYaesuSquelch(int) error
SetYaesuAGC(string) error
SetYaesuPreamp(int) error
SetYaesuAtt(int) error
SetYaesuNB(bool) error
SetYaesuNR(bool) error
SetYaesuNRLevel(int) error
SetYaesuNarrow(bool) error
SetYaesuVOX(bool) error
SetYaesuSplit(bool) error
SetYaesuBand(string) error
TuneYaesuATU() error
}
func (y *Yaesu) YaesuState() YaesuTXState {
y.mu.Lock()
defer y.mu.Unlock()
st := y.panel
st.Available = y.port != nil
st.Model = y.model
return st
}
// readPanel refreshes the meters, and the settings on the slower beat. Called
// from ReadState with the mutex HELD, so it shares the same serialised link.
func (y *Yaesu) readPanel(mode string, split bool) {
y.panel.Mode = mode
y.panel.Split = split
// TX state first: which meters mean anything depends on it, and a power
// reading shown while receiving is how a panel lies.
if r, err := y.ask("TX;"); err == nil && len(r) >= 3 {
y.panel.Transmitting = r[2] != '0'
}
if v, ok := y.askNum("SM0;", "SM0", 3); ok {
y.panel.SMeter = scale255(v)
}
if y.panel.Transmitting {
if v, ok := y.askNum("RM4;", "RM4", 3); ok {
y.panel.PowerMeter = scale255(v)
}
if v, ok := y.askNum("RM5;", "RM5", 3); ok {
y.panel.SWRMeter = scale255(v)
}
} else {
// Zeroed rather than frozen: a stale SWR bar from the last transmission
// reads as a live measurement.
y.panel.PowerMeter, y.panel.SWRMeter = 0, 0
}
y.panelCycle++
if y.panelLoaded && y.panelCycle < 8 {
return
}
y.panelCycle = 0
y.panelLoaded = true
y.readPanelSettings()
}
// readPanelSettings re-reads everything a knob can change. Separate so a set can
// force it without waiting for the slow beat.
func (y *Yaesu) readPanelSettings() {
if v, ok := y.askNum("PC;", "PC", 3); ok {
y.panel.RFPower = v // watts, not a 0-255 scale
}
if v, ok := y.askNum("MG;", "MG", 3); ok {
y.panel.MicGain = scale255(v)
}
if v, ok := y.askNum("AG0;", "AG0", 3); ok {
y.panel.AFGain = scale255(v)
}
if v, ok := y.askNum("RG0;", "RG0", 3); ok {
y.panel.RFGain = scale255(v)
}
if v, ok := y.askNum("SQ0;", "SQ0", 3); ok {
y.panel.Squelch = scale255(v)
}
if v, ok := y.askNum("GT0;", "GT0", 1); ok {
y.panel.AGC = yaesuAGCName(v)
}
if v, ok := y.askNum("PA0;", "PA0", 1); ok {
y.panel.Preamp = v
}
if v, ok := y.askNum("RA0;", "RA0", 1); ok {
y.panel.Att = yaesuAttDB(v)
}
if v, ok := y.askNum("NB0;", "NB0", 1); ok {
y.panel.NB = v != 0
}
if v, ok := y.askNum("NR0;", "NR0", 1); ok {
y.panel.NR = v != 0
}
if v, ok := y.askNum("RL0;", "RL0", 2); ok {
y.panel.NRLevel = v
}
if v, ok := y.askNum("NA0;", "NA0", 1); ok {
y.panel.Narrow = v != 0
}
if v, ok := y.askNum("VX;", "VX", 1); ok {
y.panel.VOX = v != 0
}
}
// askNum sends a query and reads a fixed-width decimal field out of the reply.
// ok=false when the rig does not answer, or answers something else — a control
// this model lacks then keeps its previous value instead of dropping to zero,
// which would look like a setting that reset itself.
func (y *Yaesu) askNum(cmd, prefix string, digits int) (int, bool) {
r, err := y.ask(cmd)
if err != nil {
return 0, false
}
if !strings.HasPrefix(r, prefix) || len(r) < len(prefix)+digits {
debugLog.Printf("yaesu: unexpected reply %q to %q", r, cmd)
return 0, false
}
n, err := strconv.Atoi(r[len(prefix) : len(prefix)+digits])
if err != nil {
return 0, false
}
return n, true
}
// setAndRefresh writes a command then re-reads the settings, so the panel shows
// what the RIG ended up with rather than what we asked for — the two differ
// whenever a value is out of range or the current mode forbids the control.
func (y *Yaesu) setAndRefresh(cmd string) error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
if err := y.write(cmd); err != nil {
return err
}
time.Sleep(30 * time.Millisecond) // let the rig apply it before reading back
y.readPanelSettings()
return nil
}
func (y *Yaesu) RefreshYaesu() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
y.readPanelSettings()
return nil
}
func (y *Yaesu) SetYaesuPower(w int) error {
return y.setAndRefresh(fmt.Sprintf("PC%03d;", clampInt(w, 5, 100)))
}
func (y *Yaesu) SetYaesuMicGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("MG%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuAFGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("AG0%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuRFGain(p int) error {
return y.setAndRefresh(fmt.Sprintf("RG0%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuSquelch(p int) error {
return y.setAndRefresh(fmt.Sprintf("SQ0%03d;", from100(p)))
}
func (y *Yaesu) SetYaesuAGC(name string) error {
return y.setAndRefresh(fmt.Sprintf("GT0%d;", yaesuAGCCode(name)))
}
func (y *Yaesu) SetYaesuPreamp(n int) error {
return y.setAndRefresh(fmt.Sprintf("PA0%d;", clampInt(n, 0, 2)))
}
func (y *Yaesu) SetYaesuAtt(db int) error {
return y.setAndRefresh(fmt.Sprintf("RA0%d;", yaesuAttCode(db)))
}
func (y *Yaesu) SetYaesuNB(on bool) error {
return y.setAndRefresh(fmt.Sprintf("NB0%d;", boolDigit(on)))
}
func (y *Yaesu) SetYaesuNR(on bool) error {
return y.setAndRefresh(fmt.Sprintf("NR0%d;", boolDigit(on)))
}
func (y *Yaesu) SetYaesuNRLevel(n int) error {
return y.setAndRefresh(fmt.Sprintf("RL0%02d;", clampInt(n, 1, 15)))
}
func (y *Yaesu) SetYaesuNarrow(on bool) error {
return y.setAndRefresh(fmt.Sprintf("NA0%d;", boolDigit(on)))
}
func (y *Yaesu) SetYaesuVOX(on bool) error {
return y.setAndRefresh(fmt.Sprintf("VX%d;", boolDigit(on)))
}
// SetYaesuSplit uses whichever command this rig answered at connect. Sending the
// other one would be silently ignored, and the operator would get a split button
// that does nothing.
func (y *Yaesu) SetYaesuSplit(on bool) error {
y.mu.Lock()
cmd := y.splitCmd
y.mu.Unlock()
if cmd == "" {
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
}
return y.setAndRefresh(fmt.Sprintf("%s%d;", cmd, boolDigit(on)))
}
// SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used
// frequency for that band — the radio's band memory, not a frequency we choose.
func (y *Yaesu) SetYaesuBand(band string) error {
code, ok := yaesuBandCode(band)
if !ok {
return fmt.Errorf("yaesu: no band code for %q", band)
}
return y.setAndRefresh(fmt.Sprintf("BS%02d;", code))
}
// TuneYaesuATU starts a tuning cycle. Deliberately not followed by a settings
// read: the rig is transmitting into the tuner for several seconds and answers
// little, so the reads would just time out one after another.
func (y *Yaesu) TuneYaesuATU() error {
y.mu.Lock()
defer y.mu.Unlock()
if y.port == nil {
return fmt.Errorf("yaesu: not connected")
}
return y.write("AC002;")
}
// ── small mappings ────────────────────────────────────────────────────────
func scale255(v int) int {
if v <= 0 {
return 0
}
if v >= 255 {
return 100
}
return v * 100 / 255
}
func from100(p int) int { return clampInt(p, 0, 100) * 255 / 100 }
func clampInt(v, lo, hi int) int {
if v < lo {
return lo
}
if v > hi {
return hi
}
return v
}
func boolDigit(b bool) int {
if b {
return 1
}
return 0
}
func yaesuAGCName(code int) string {
switch code {
case 0:
return "OFF"
case 1:
return "FAST"
case 2:
return "MID"
case 3:
return "SLOW"
case 4:
return "AUTO"
}
return ""
}
func yaesuAGCCode(name string) int {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "OFF":
return 0
case "FAST":
return 1
case "MID", "MEDIUM":
return 2
case "SLOW":
return 3
}
return 4 // AUTO — the safe default for anything unrecognised
}
// The FTDX10's attenuator is a single 12 dB step (RA00/RA01). Reporting the dB
// rather than the raw code lets the panel label the button honestly, and leaves
// room for models with more steps.
func yaesuAttDB(code int) int {
if code == 0 {
return 0
}
return 12
}
func yaesuAttCode(db int) int {
if db <= 0 {
return 0
}
return 1
}
// yaesuBandCode maps an ADIF band to the BS command's band number.
func yaesuBandCode(band string) (int, bool) {
switch strings.ToLower(strings.TrimSpace(band)) {
case "160m":
return 0, true
case "80m":
return 1, true
case "60m":
return 2, true
case "40m":
return 3, true
case "30m":
return 4, true
case "20m":
return 5, true
case "17m":
return 6, true
case "15m":
return 7, true
case "12m":
return 8, true
case "10m":
return 9, true
case "6m":
return 10, true
}
return 0, false
}