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:
@@ -845,3 +845,27 @@ func BandFromHz(hz int64) string {
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// YaesuState returns the panel snapshot, or (zero, false) when the active
|
||||
// backend isn't a Yaesu — same shape as IcomState.
|
||||
func (m *Manager) YaesuState() (YaesuTXState, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if yc, ok := b.(YaesuController); ok {
|
||||
return yc.YaesuState(), true
|
||||
}
|
||||
return YaesuTXState{}, false
|
||||
}
|
||||
|
||||
// YaesuDo dispatches a Yaesu control onto the CAT goroutine, so a panel click
|
||||
// and the poll loop never share the serial port at the same instant.
|
||||
func (m *Manager) YaesuDo(fn func(YaesuController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
yc, ok := b.(YaesuController)
|
||||
if !ok {
|
||||
return fmt.Errorf("active CAT backend is not a Yaesu")
|
||||
}
|
||||
return fn(yc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -95,6 +95,11 @@ type Yaesu struct {
|
||||
|
||||
curFreq 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 {
|
||||
@@ -216,6 +221,7 @@ func (y *Yaesu) ReadState() (RigState, error) {
|
||||
debugLog.Printf("yaesu: unknown mode reply %q", r)
|
||||
}
|
||||
}
|
||||
y.readPanel(s.Mode, s.Split)
|
||||
return s, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user