One point could not reveal the curve. Scaling 207 = 100 W straight down read 30 W where the radio showed 10, and 75 where it showed 50 — wrong everywhere except at the single point it was fitted to. Three readings taken against the rig's own display give the shape: raw 62 → 10 W raw 155 → 50 W raw 207 → 100 W Interpolating between them reproduces the radio exactly at those points and stays close in between. I did not fit a formula: three samples can be made to support several curves, and the operator can check a table against their own meter. Above the top the last segment's slope continues rather than clamping, so a rig driving an amplifier does not sit pinned at 100 W. A test pins the measured pairs and the monotonicity, so a later change that breaks them fails against the radio rather than against taste.
803 lines
25 KiB
Go
803 lines
25 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 (
|
|
"errors"
|
|
"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
|
|
// SWR is the RATIO (1.0, 1.5…), computed from the reflection coefficient —
|
|
// what an operator reads on the rig, not a percentage of meter travel.
|
|
SWR float64 `json:"swr"`
|
|
// PowerW is the output in WATTS, taken from the METER rather than from the
|
|
// power setting — the setting says what was asked for, not what came out.
|
|
PowerW float64 `json:"power_w"`
|
|
VOX bool `json:"vox"`
|
|
// CW-only controls. Read (and shown) only in CW, where MIC and VOX mean
|
|
// nothing and these are what an operator reaches for.
|
|
KeySpeed int `json:"key_speed"` // WPM
|
|
BreakIn bool `json:"break_in"`
|
|
}
|
|
|
|
// 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
|
|
SetYaesuKeySpeed(int) error
|
|
SetYaesuBreakIn(bool) error
|
|
YaesuZeroIn() error
|
|
// CW keying through the rig's own keyer (KY) — the fifth CW engine.
|
|
SendCW(string) error
|
|
StopCW() 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 {
|
|
// One-shot survey of every meter while transmitting.
|
|
//
|
|
// Which RM index carries which meter is NOT the same across the family, and
|
|
// an operator reported an SWR bar at 80 with a real SWR of 1.1 — the shape
|
|
// of reading the wrong index (ALC, say) rather than of a scaling error.
|
|
// Guessing again would just move the wrong number; this prints all six
|
|
// once, and the log then says which is which on THIS radio.
|
|
// Sampled on EVERY poll while transmitting, capped — one snapshot taken as
|
|
// the transmission starts catches the meters still at rest (RM4=13, the
|
|
// rest zero), which says nothing. What identifies a meter is which index
|
|
// TRACKS the power over a few seconds of steady carrier.
|
|
if y.metersLogged < 40 {
|
|
y.metersLogged++
|
|
raw := make([]string, 0, 6)
|
|
for i := 1; i <= 6; i++ {
|
|
cmd := fmt.Sprintf("RM%d;", i)
|
|
if v, ok := y.askNum(cmd, fmt.Sprintf("RM%d", i), 3); ok {
|
|
raw = append(raw, fmt.Sprintf("RM%d=%d", i, v))
|
|
} else {
|
|
raw = append(raw, fmt.Sprintf("RM%d=-", i))
|
|
}
|
|
}
|
|
// The POWER SETTING goes on the same line. Which index is the wattmeter
|
|
// cannot be read off one transmission — RM4 rose while RM5 sat still,
|
|
// but RM5 differed BETWEEN transmissions (208 then 105), so both are
|
|
// candidates. What settles it is transmitting at two different power
|
|
// settings and seeing which index follows: printing the setting here
|
|
// makes that a one-line comparison instead of a memory exercise.
|
|
debugLog.Printf("yaesu: meters at PC=%dW: %s (compare two different power settings)",
|
|
y.panel.RFPower, strings.Join(raw, " "))
|
|
}
|
|
// Measured on an FTDX10 (2026-07-29). The FM carrier was inconclusive —
|
|
// constant by definition — so the answer came from CW at 100 W, where the
|
|
// keying itself is the experiment:
|
|
//
|
|
// key down: RM4=25 RM5=207 RM6=13
|
|
// key up: RM4=25 RM5=0 RM6=0
|
|
//
|
|
// RM5 follows the RF envelope exactly, so RM5 is the POWER meter. RM4 sits
|
|
// near 25 whether the key is down or up, so it is not measuring output at
|
|
// all — reading it as power is what showed 8 W on a 100 W transmission.
|
|
// Peak-hold. In CW the key is up between elements and the meters genuinely
|
|
// read 0 there — the log shows RM5 alternating 207, 0, 207 — so following
|
|
// the raw value makes the bars flicker to nothing several times a second
|
|
// and the number unreadable. A real meter has needle inertia; this is the
|
|
// same idea, and it only ever holds a value the radio actually reported.
|
|
now := time.Now()
|
|
if v, ok := y.askNum("RM5;", "RM5", 3); ok {
|
|
y.panel.PowerMeter = y.powerPeak.update(scale255(v), now)
|
|
y.panel.PowerW = float64(y.powerWPeak.update(int(yaesuWatts(v)+0.5), now))
|
|
}
|
|
// SWR is RM6, and a second measurement at a KNOWN mismatch settled both the
|
|
// index and the scale: 0 at SWR 1.1, then 52 at SWR 1.5. 52/255 = 0.204,
|
|
// which is the reflection coefficient of a 1.5 SWR to three decimals — so
|
|
// the raw value is rho scaled to 255, and the ratio follows from physics
|
|
// rather than from a fitted curve.
|
|
if v, ok := y.askNum("RM6;", "RM6", 3); ok {
|
|
y.panel.SWRMeter = y.swrPeak.update(scale255(v), now)
|
|
// The RATIO is only meaningful while power is going out: between words
|
|
// the reading is 0, which would display as a perfect 1.0 match — worse
|
|
// than a stale figure, because it looks like good news.
|
|
if v > 0 {
|
|
y.panel.SWR = swrFromReflection(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
|
|
}
|
|
// CW keyer. Read unconditionally — it is two more queries on the SLOW beat,
|
|
// and having the value ready means the CW card is populated the instant the
|
|
// operator switches mode rather than a poll cycle later.
|
|
if v, ok := y.askNum("KS;", "KS", 3); ok {
|
|
y.panel.KeySpeed = v
|
|
}
|
|
if v, ok := y.askNum("BI;", "BI", 1); ok {
|
|
y.panel.BreakIn = 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) {
|
|
// A command this model refused once is never asked again. Models implement
|
|
// different subsets — an FTDX10 answers "?;" to MG; — and re-asking costs a
|
|
// 600 ms timeout on every slow beat, for a control that will never answer.
|
|
if y.unsupported[cmd] {
|
|
return 0, false
|
|
}
|
|
r, err := y.ask(cmd)
|
|
if err != nil {
|
|
if errors.Is(err, errYaesuUnsupported) {
|
|
if y.unsupported == nil {
|
|
y.unsupported = map[string]bool{}
|
|
}
|
|
y.unsupported[cmd] = true
|
|
debugLog.Printf("yaesu: this rig does not support %q — not asking again", cmd)
|
|
}
|
|
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 {
|
|
// Turning split ON also PLACES the transmit VFO. Flipping the flag alone
|
|
// transmits wherever the other VFO happens to sit — it held 18.115 from an
|
|
// earlier session while the operator was listening on 14.244, so pressing
|
|
// SPLIT threw them onto another band entirely. The other VFO is stale by
|
|
// nature; the only frequency that makes sense is one derived from where the
|
|
// operator is listening NOW.
|
|
//
|
|
// The distance is the mode's usual one: 1 kHz on CW and the data modes,
|
|
// 5 kHz on phone. The +1k / +5k buttons remain for anything else.
|
|
if on {
|
|
return y.SetYaesuSplitOffset(y.defaultSplitOffset())
|
|
}
|
|
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("%s0;", cmd))
|
|
}
|
|
|
|
// defaultSplitOffset is what SPLIT means on this mode: up 1 kHz on CW and the
|
|
// data modes, up 5 kHz on phone — the offsets operators actually call.
|
|
func (y *Yaesu) defaultSplitOffset() int64 {
|
|
y.mu.Lock()
|
|
raw := strings.ToUpper(y.panel.RawMode)
|
|
y.mu.Unlock()
|
|
switch {
|
|
case strings.HasPrefix(raw, "CW"), strings.HasPrefix(raw, "RTTY"), strings.HasPrefix(raw, "DATA"):
|
|
return 1000
|
|
}
|
|
return 5000
|
|
}
|
|
|
|
// 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
|
|
}
|
|
|
|
// SetYaesuKeySpeed sets the internal keyer speed in words per minute. The rig
|
|
// clamps to its own 4-60 range; clamping here too keeps a slider from sending a
|
|
// value that would simply be ignored, which reads as a dead control.
|
|
func (y *Yaesu) SetYaesuKeySpeed(wpm int) error {
|
|
return y.setAndRefresh(fmt.Sprintf("KS%03d;", clampInt(wpm, 4, 60)))
|
|
}
|
|
|
|
// SetYaesuBreakIn toggles CW break-in (BK).
|
|
func (y *Yaesu) SetYaesuBreakIn(on bool) error {
|
|
return y.setAndRefresh(fmt.Sprintf("BI%d;", boolDigit(on)))
|
|
}
|
|
|
|
// YaesuZeroIn is the CW ZIN function: the rig retunes itself so the station
|
|
// being received lands exactly on the operator's CW pitch. It is a one-shot
|
|
// action with no state to read back, so no settings refresh follows — and the
|
|
// frequency change arrives through the normal poll like any other.
|
|
func (y *Yaesu) YaesuZeroIn() error {
|
|
y.mu.Lock()
|
|
defer y.mu.Unlock()
|
|
if y.port == nil {
|
|
return fmt.Errorf("yaesu: not connected")
|
|
}
|
|
return y.write("ZI;")
|
|
}
|
|
|
|
// swrFromReflection turns the rig's SWR meter reading into the ratio itself.
|
|
//
|
|
// The raw value is the reflection coefficient scaled to 255 — established on an
|
|
// FTDX10 by measuring at two known matches (0 at 1.1, 52 at 1.5; 52/255 = 0.204,
|
|
// which is rho for a 1.5 SWR). So SWR = (1+rho)/(1-rho), physics rather than a
|
|
// curve fitted to two points.
|
|
//
|
|
// Capped at 9.9: past that the number stops meaning anything to an operator, and
|
|
// rho approaching 1 sends the ratio to infinity.
|
|
func swrFromReflection(raw int) float64 {
|
|
if raw <= 0 {
|
|
return 1.0
|
|
}
|
|
rho := float64(raw) / 255.0
|
|
if rho >= 0.98 {
|
|
return 9.9
|
|
}
|
|
swr := (1 + rho) / (1 - rho)
|
|
if swr > 9.9 {
|
|
return 9.9
|
|
}
|
|
return swr
|
|
}
|
|
|
|
// yaesuWatts converts the power-meter reading to watts.
|
|
//
|
|
// The meter is NOT linear in power, which one calibration point could never
|
|
// reveal: scaling 207 = 100 W straight down read 30 W where the rig showed 10,
|
|
// and 75 where it showed 50. Three points measured against the radio's own
|
|
// display settle the curve:
|
|
//
|
|
// raw 62 → 10 W
|
|
// raw 155 → 50 W
|
|
// raw 207 → 100 W
|
|
//
|
|
// Interpolating between them reproduces the rig exactly at those points and
|
|
// stays close in between — better than fitting a formula to three samples and
|
|
// pretending it holds everywhere. Above the last point it keeps the final
|
|
// segment's slope, so an amplifier-driving rig does not flatten at 100 W.
|
|
//
|
|
// Measured on an FTDX10 (2026-07-29). Another model may well need its own row.
|
|
var yaesuPowerCurve = []struct {
|
|
raw int
|
|
watts float64
|
|
}{
|
|
{0, 0},
|
|
{62, 10},
|
|
{155, 50},
|
|
{207, 100},
|
|
}
|
|
|
|
func yaesuWatts(raw int) float64 {
|
|
if raw <= 0 {
|
|
return 0
|
|
}
|
|
for i := 1; i < len(yaesuPowerCurve); i++ {
|
|
hi := yaesuPowerCurve[i]
|
|
if raw <= hi.raw {
|
|
lo := yaesuPowerCurve[i-1]
|
|
span := float64(hi.raw - lo.raw)
|
|
if span <= 0 {
|
|
return hi.watts
|
|
}
|
|
f := float64(raw-lo.raw) / span
|
|
return lo.watts + f*(hi.watts-lo.watts)
|
|
}
|
|
}
|
|
// Past the top of the curve: extend the last segment rather than clamp.
|
|
n := len(yaesuPowerCurve)
|
|
lo, hi := yaesuPowerCurve[n-2], yaesuPowerCurve[n-1]
|
|
slope := (hi.watts - lo.watts) / float64(hi.raw-lo.raw)
|
|
return hi.watts + float64(raw-hi.raw)*slope
|
|
}
|
|
|
|
// meterPeak gives a meter the inertia a needle has: it jumps to a higher reading
|
|
// at once, HOLDS it for a moment, then falls back gradually.
|
|
//
|
|
// The hold is the part that matters on the air. Between CW elements the gap is
|
|
// milliseconds, but between the words of a CQ — in CW as in SSB — it is most of
|
|
// a second, and a meter that decays straight away reads 0 in every one of those
|
|
// gaps: the operator sees a bar flashing rather than the power they are running.
|
|
type meterPeak struct {
|
|
val int
|
|
at time.Time
|
|
}
|
|
|
|
const (
|
|
meterHold = 1500 * time.Millisecond // how long a peak stands before it starts to fall
|
|
meterDecay = 4 // then a quarter of the remaining gap per poll
|
|
)
|
|
|
|
// update returns the value to show for this sample.
|
|
func (m *meterPeak) update(sample int, now time.Time) int {
|
|
if sample >= m.val {
|
|
m.val, m.at = sample, now
|
|
return m.val
|
|
}
|
|
if now.Sub(m.at) < meterHold {
|
|
return m.val // still inside the hold — the needle has not started to fall
|
|
}
|
|
// At least one step down, always. A proportional decay on integers stalls
|
|
// near the end: with the needle at 13 and the truth at 10, a quarter of the
|
|
// gap rounds to zero and the meter sits three units high for ever.
|
|
step := (m.val - sample) / meterDecay
|
|
if step < 1 {
|
|
step = 1
|
|
}
|
|
m.val -= step
|
|
if m.val <= sample {
|
|
m.val = sample
|
|
}
|
|
return m.val
|
|
}
|