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.
541 lines
14 KiB
Go
541 lines
14 KiB
Go
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"`
|
|
// RawMode is the rig mode with its sideband (CW-U, DATA-L…), which ADIF
|
|
// deliberately does not record but the panel has to show.
|
|
RawMode string `json:"raw_mode,omitempty"`
|
|
|
|
Transmitting bool `json:"transmitting"`
|
|
Split bool `json:"split"`
|
|
// SplitTXHz is where the rig will TRANSMIT while split — the panel showed a
|
|
// lit SPLIT button and nothing else, which does not tell an operator whether
|
|
// they are up 1 or up 5.
|
|
SplitTXHz int64 `json:"split_tx_hz"`
|
|
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
|
|
SetYaesuSplitOffset(int64) error
|
|
SetYaesuBand(string) error
|
|
SetYaesuModeRaw(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, txHz int64) {
|
|
y.panel.Mode = mode
|
|
y.panel.Split = split
|
|
y.panel.SplitTXHz = 0
|
|
if split {
|
|
y.panel.SplitTXHz = txHz
|
|
}
|
|
|
|
// 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.
|
|
// SetYaesuModeRaw selects an exact rig mode, sideband included — "CW-U",
|
|
// "DATA-L", "RTTY-U"… The plain SetMode path takes an ADIF mode and can only
|
|
// choose a sideband by convention, but CW, RTTY and the data modes are routinely
|
|
// run on EITHER sideband and the operator is the one who knows which. This is
|
|
// what the panel's mode buttons use.
|
|
func (y *Yaesu) SetYaesuModeRaw(name string) error {
|
|
d, ok := yaesuRawModeDigit(name)
|
|
if !ok {
|
|
return fmt.Errorf("yaesu: unknown rig mode %q", name)
|
|
}
|
|
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
|
|
}
|
|
|
|
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
|
|
func yaesuRawModeDigit(name string) (byte, bool) {
|
|
switch strings.ToUpper(strings.TrimSpace(name)) {
|
|
case "LSB":
|
|
return '1', true
|
|
case "USB":
|
|
return '2', true
|
|
case "CW-U", "CWU":
|
|
return '3', true
|
|
case "CW-L", "CWL":
|
|
return '7', true
|
|
case "FM":
|
|
return '4', true
|
|
case "AM":
|
|
return '5', true
|
|
case "RTTY-L", "RTTYL":
|
|
return '6', true
|
|
case "RTTY-U", "RTTYU":
|
|
return '9', true
|
|
case "DATA-L", "DATAL", "DIGI-L":
|
|
return '8', true
|
|
case "DATA-U", "DATAU", "DIGI-U":
|
|
return 'C', true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
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/FTDX101 attenuator is a THREE-step pad (RA00..RA03 = off, 6, 12,
|
|
// 18 dB), not the single step this first assumed — a panel offering only one
|
|
// step hides two thirds of the control. Reporting the dB rather than the raw
|
|
// code lets the buttons label themselves honestly.
|
|
func yaesuAttDB(code int) int {
|
|
switch code {
|
|
case 1:
|
|
return 6
|
|
case 2:
|
|
return 12
|
|
case 3:
|
|
return 18
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func yaesuAttCode(db int) int {
|
|
switch {
|
|
case db >= 18:
|
|
return 3
|
|
case db >= 12:
|
|
return 2
|
|
case db >= 6:
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// yaesuRawModeName is the inverse of yaesuRawModeDigit: what the rig is on,
|
|
// sideband included, for the panel's mode buttons to highlight.
|
|
func yaesuRawModeName(d byte) string {
|
|
switch d {
|
|
case '1':
|
|
return "LSB"
|
|
case '2':
|
|
return "USB"
|
|
case '3':
|
|
return "CW-U"
|
|
case '4':
|
|
return "FM"
|
|
case '5':
|
|
return "AM"
|
|
case '6':
|
|
return "RTTY-L"
|
|
case '7':
|
|
return "CW-L"
|
|
case '8':
|
|
return "DATA-L"
|
|
case '9':
|
|
return "RTTY-U"
|
|
case 'C':
|
|
return "DATA-U"
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// SetYaesuSplitOffset puts the transmit VFO a fixed distance above the receive
|
|
// one and turns split on, in a single action.
|
|
//
|
|
// This is the split an operator actually uses when working a pile-up: listen on
|
|
// the DX, transmit up 5 kHz on phone or up 1 kHz on CW. Doing it by hand means
|
|
// swapping VFOs, retuning and swapping back, which is exactly the fumbling a
|
|
// panel should remove.
|
|
//
|
|
// The offset is applied to the RECEIVE frequency and written to the OTHER VFO —
|
|
// whichever that is. On VFO B the roles are mirrored, so listening on B writes A.
|
|
func (y *Yaesu) SetYaesuSplitOffset(offsetHz int64) error {
|
|
y.mu.Lock()
|
|
defer y.mu.Unlock()
|
|
if y.port == nil {
|
|
return fmt.Errorf("yaesu: not connected")
|
|
}
|
|
rx := y.curRXFreq
|
|
if rx <= 0 {
|
|
return fmt.Errorf("yaesu: no receive frequency read yet")
|
|
}
|
|
tx := rx + offsetHz
|
|
if tx <= 0 || tx > 999_999_999 {
|
|
return fmt.Errorf("yaesu: split frequency %d out of the CAT range", tx)
|
|
}
|
|
// Write the VFO we are NOT listening on.
|
|
cmd := "FB"
|
|
if y.curVFO == "B" {
|
|
cmd = "FA"
|
|
}
|
|
if err := y.write(fmt.Sprintf("%s%09d;", cmd, tx)); err != nil {
|
|
return err
|
|
}
|
|
if y.splitCmd == "" {
|
|
return fmt.Errorf("yaesu: this rig answered neither ST; nor FT; — split cannot be set")
|
|
}
|
|
time.Sleep(30 * time.Millisecond)
|
|
if err := y.write(fmt.Sprintf("%s1;", y.splitCmd)); err != nil {
|
|
return err
|
|
}
|
|
y.panel.Split = true
|
|
y.panel.SplitTXHz = tx
|
|
return nil
|
|
}
|