feat(antenna): three tracking modes for Ultrabeam and SteppIR

The follow loop only ever had one behaviour — re-tune once the rig moved
further than a fixed step. The SteppIR's own controller software offers three,
and operators arrive with that mental model: every frequency change, past a
threshold, or only on a band change. They are real operating trade-offs, not
preferences: "always" keeps resonance perfect at the cost of motors running
constantly (and on a SteppIR every move inhibits transmit while the elements
travel), "band" moves them a handful of times a day.

"Always" is implemented as the threshold mode with a 1 kHz threshold rather
than as a separate branch, so all modes keep the one deadband reference that
matters: the rig frequency last commanded for, NOT the antenna's own reported
frequency — a SteppIR flips its reported frequency between the commanded value
and its home value, which would re-issue a SET on nearly every poll and leave
the operator permanently unable to transmit.

Band mode compares against the band last COMMANDED for, not the rig's previous
band, so the first move after startup and any move made by hand still get
reconciled. It applies to the immediate spot-click path too: a click inside the
band the antenna is already resonant in moves nothing.

An unset or unrecognised mode resolves to the step mode, so every config
written before this option behaves exactly as it did.

Also routes the antenna settings block through t() — it was hardcoded English.
This commit is contained in:
2026-08-12 08:31:44 +02:00
parent 99d903eb44
commit 65bbaa85f3
9 changed files with 236 additions and 60 deletions
+108 -22
View File
@@ -233,6 +233,7 @@ const (
keyUltrabeamPort = "ultrabeam.port"
keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency
keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz
keyMotorTrackMode = "motor.track_mode" // "always" | "step" | "band"
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
@@ -12365,13 +12366,25 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
if ref <= 0 {
ref = c.LastSetKHz()
}
diff := khz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < step {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY
switch normMotorTrackMode(s.TrackMode) {
case motorTrackAlways:
// Every frequency change means every frequency change, including this one.
case motorTrackBand:
// The antenna is already resonant somewhere in this band — that is all the
// operator asked for in band mode, so a spot click inside it moves nothing.
if ref > 0 && bandForHz(int64(ref)*1000) == bandForHz(freqHz) {
applog.Printf("ultrabeam: followNow stays in band %q (antenna at %d kHz) — no move", bandForHz(freqHz), ref)
return
}
default:
diff := khz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < step {
applog.Printf("ultrabeam: followNow within deadband (%d kHz vs ref %d, step %d) — no move", khz, ref, step)
return // within the deadband — don't chase a tiny QSY
}
}
a.noteMotorMoveCommanded()
if err := c.SetFrequency(khz, st.Direction); err != nil {
@@ -14653,6 +14666,17 @@ type UltrabeamSettings struct {
Baud int `json:"baud"` // serial baud
Follow bool `json:"follow"` // re-tune the antenna to the rig's frequency
StepKHz int `json:"step_khz"` // re-tune only when the freq moved this far (25/50/100)
// When the follow loop is allowed to move the motors. The three choices the
// SteppIR's own controller software offers, because operators arrive with
// that mental model:
// "always" — every frequency change. Resonance is always right, at the cost
// of motors running constantly; on a SteppIR every move also
// inhibits transmit while the elements travel.
// "step" — only past a threshold (StepKHz). The default, and the sane
// middle: the antenna follows a QSY but ignores tuning around.
// "band" — only when the band changes. Motors move a handful of times a
// day; resonance is whatever the band-entry frequency gave.
TrackMode string `json:"track_mode"`
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
// Bands the antenna covers — the follow filter. The follow loop only re-tunes
// (and only lets TX-inhibit trigger) on a band in this set; on any other band
@@ -14667,16 +14691,39 @@ type UltrabeamSettings struct {
FreqMaxMHz int `json:"freq_max_mhz"`
}
// Tracking modes. Stored as strings rather than an int so a settings row stays
// readable when diagnosing an antenna that moves too much or not at all.
const (
motorTrackAlways = "always"
motorTrackStep = "step"
motorTrackBand = "band"
)
// normMotorTrackMode keeps an unknown or empty value on the threshold mode
// instead of guessing — a config written before this option existed then
// behaves exactly as it did.
func normMotorTrackMode(m string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case motorTrackAlways:
return motorTrackAlways
case motorTrackBand:
return motorTrackBand
default:
return motorTrackStep
}
}
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
// to the pre-SteppIR behaviour (Ultrabeam over TCP) so an existing install is
// unchanged.
func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50}
out := UltrabeamSettings{Type: "ultrabeam", Transport: "tcp", Port: 23, Baud: 9600, StepKHz: 50, TrackMode: motorTrackStep}
if a.settings == nil {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands)
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands,
keyMotorTrackMode)
if err != nil {
return out, err
}
@@ -14700,6 +14747,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
}
out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode])
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
// Bands is the follow filter. If it was saved, use it verbatim. Otherwise this
@@ -14765,6 +14813,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyUltrabeamPort: strconv.Itoa(s.Port),
keyUltrabeamFollow: boolStr(s.Follow),
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
keyMotorTrackMode: normMotorTrackMode(s.TrackMode),
keyMotorType: s.Type,
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
@@ -15004,10 +15053,21 @@ func (a *App) motorTXInhibitLoop(c motorAntenna, bands []string, stop <-chan str
}
}
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, stop <-chan struct{}) {
func (a *App) ultrabeamFollowLoop(c motorAntenna, mode string, stepKHz int, bands []string, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
}
mode = normMotorTrackMode(mode)
// "Every time the frequency changes" is the threshold mode with the smallest
// threshold there is: the loop already re-tunes when the rig has moved at
// least stepKHz from the last commanded frequency, and 1 kHz makes that
// "moved at all". Expressing it this way keeps ONE decision path, so the
// deadband reference — which is the rig, not the antenna's own flaky reported
// frequency — cannot drift out of step between modes.
if mode == motorTrackAlways {
stepKHz = 1
}
lastCmdBand := "" // band of the last commanded move — the reference in band mode
ticker := time.NewTicker(1500 * time.Millisecond)
defer ticker.Stop()
lastRigKHz := 0 // only log when the followed rig frequency actually changes
@@ -15065,19 +15125,35 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, s
ref = c.LastSetKHz()
}
}
diff := rigKHz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone
// Band mode ignores the threshold entirely: the antenna is re-tuned once
// on entering a band and then left alone however far the rig roams
// inside it. The reference is the band we last COMMANDED for, not the
// rig's previous band — otherwise a first move after startup, or any
// move the operator made by hand, would never be reconciled.
if mode == motorTrackBand {
b := bandForHz(rs.FreqHz)
if b == lastCmdBand {
continue
}
if newFreq {
applog.Printf("ultrabeam: band changed %q → %q — re-tuning to %d kHz", lastCmdBand, b, rigKHz)
}
} else {
diff := rigKHz - ref
if diff < 0 {
diff = -diff
}
if ref > 0 && diff < stepKHz {
continue // within the deadband — leave the motors alone
}
}
a.noteMotorMoveCommanded()
if err := c.SetFrequency(rigKHz, st.Direction); err != nil {
applog.Printf("ultrabeam: follow re-tune to %d kHz failed: %v", rigKHz, err)
} else {
lastCmdKHz = rigKHz
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, step %d)", rigKHz, st.Direction, ref, stepKHz)
lastCmdBand = bandForHz(rs.FreqHz)
applog.Printf("ultrabeam: followed rig → %d kHz (dir %d, was ref %d kHz, mode %s, step %d)", rigKHz, st.Direction, ref, mode, stepKHz)
}
}
}
@@ -15097,8 +15173,9 @@ type UltrabeamStatusInfo struct {
Elements []int `json:"elements"` // per-element lengths (mm); empty when unsupported
// Follow and StepKHz are mirrored here so the Station Control widget can show
// and change tracking without loading the whole settings block for a poll.
Follow bool `json:"follow"`
StepKHz int `json:"step_khz"`
Follow bool `json:"follow"`
StepKHz int `json:"step_khz"`
TrackMode string `json:"track_mode"`
// Bands the antenna is configured to cover — the widget offers exactly these
// as buttons rather than inventing its own list, so a band dropped in Settings
// cannot be clicked here.
@@ -15113,6 +15190,7 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out.Type = s.Type
out.Follow = s.Follow
out.StepKHz = s.StepKHz
out.TrackMode = normMotorTrackMode(s.TrackMode)
out.Bands = append(out.Bands, s.Bands...)
if a.motorAnt == nil {
return out
@@ -15218,7 +15296,7 @@ func (a *App) MotorNudgeKHz(deltaKHz int) error {
// opening Settings. Both are ordinary operating decisions — an operator turns
// tracking off to park the antenna and back on to resume — and a preferences
// dialog is the wrong place for something changed that often.
func (a *App) SetMotorFollow(on bool, stepKHz int) error {
func (a *App) SetMotorFollow(on bool, stepKHz int, mode string) error {
s, err := a.GetUltrabeamSettings()
if err != nil {
return err
@@ -15230,6 +15308,11 @@ func (a *App) SetMotorFollow(on bool, stepKHz int) error {
default:
return fmt.Errorf("step must be 25, 50 or 100 kHz")
}
// An empty mode leaves it alone, so the caller toggling tracking on and off
// does not have to know or resend it.
if strings.TrimSpace(mode) != "" {
s.TrackMode = normMotorTrackMode(mode)
}
s.Follow = on
// Persist WITHOUT the restart. SaveUltrabeamSettings tears the client down and
@@ -15251,6 +15334,9 @@ func (a *App) SetMotorFollow(on bool, stepKHz int) error {
if err := a.settings.Set(a.ctx, keyUltrabeamStep, strconv.Itoa(s.StepKHz)); err != nil {
return err
}
if err := a.settings.Set(a.ctx, keyMotorTrackMode, normMotorTrackMode(s.TrackMode)); err != nil {
return err
}
a.restartMotorFollow(s)
return nil
}
@@ -15268,8 +15354,8 @@ func (a *App) restartMotorFollow(s UltrabeamSettings) {
}
stop := make(chan struct{})
a.ubFollowStop = stop
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, step %d kHz", s.Bands, s.StepKHz)
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, s.Bands, stop)
applog.Printf("ultrabeam: follow loop restarting — covered bands %v, mode %s, step %d kHz", s.Bands, normMotorTrackMode(s.TrackMode), s.StepKHz)
go a.ultrabeamFollowLoop(a.motorAnt, s.TrackMode, s.StepKHz, s.Bands, stop)
}
// UltrabeamRetract retracts all elements (storage / safe position).