feat: configurable SteppIR tunable range — skip follow/inhibit off-band

A SteppIR only covers part of the spectrum (a standard one is 20 m–6 m) but
can't report its own range, so the follow loop's existing out-of-range guard
(FreqMin/FreqMax) never engaged for it. Tuning the rig to a band the antenna
can't reach (e.g. 30 m) made the loop keep trying — the immediate cause of the
stuck TX interlock addressed in the previous commit.

Add a Tunable range (MHz) setting to the Antenna panel, wired into the SteppIR
adapter's Status().FreqMin/FreqMax so the follow loop skips out-of-range
frequencies entirely: no tune command, no TX inhibit. Defaults to 13–54 MHz
(20 m–6 m, the common model) when unset; widen the low edge for a 40 m-equipped
SteppIR. Ultrabeam is unaffected (it reports its own per-band coverage).

Keeps the last-commanded-freq deadband from the previous commit as a universal
safety net for any flaky-status case within the valid range.
This commit is contained in:
2026-07-24 16:05:36 +02:00
parent b83d55cc32
commit 48345a22fc
5 changed files with 67 additions and 10 deletions
+41 -4
View File
@@ -180,6 +180,8 @@ const (
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
keyMotorBaud = "ultrabeam.baud" // serial baud (SteppIR default 9600)
keyMotorTXInhibit = "ultrabeam.tx_inhibit" // "1" → block Flex TX while the antenna is moving
keyMotorFreqMin = "ultrabeam.freq_min" // SteppIR tunable range low edge (MHz); out-of-range = don't follow/inhibit
keyMotorFreqMax = "ultrabeam.freq_max" // SteppIR tunable range high edge (MHz)
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
@@ -12214,7 +12216,10 @@ func (a ubAdapter) Status() motorStatus {
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: st.FreqMin, FreqMax: st.FreqMax}
}
type steppirAdapter struct{ c *steppir.Client }
type steppirAdapter struct {
c *steppir.Client
freqMin, freqMax int // MHz; tunable range (the SteppIR can't report its own)
}
func (a steppirAdapter) Start() error { return a.c.Start() }
func (a steppirAdapter) Stop() { a.c.Stop() }
@@ -12234,7 +12239,7 @@ func (a steppirAdapter) Status() motorStatus {
if err != nil || st == nil {
return motorStatus{}
}
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0}
return motorStatus{Connected: st.Connected, Direction: st.Direction, Frequency: st.Frequency, Band: st.Band, Moving: st.MotorsMoving != 0, FreqMin: a.freqMin, FreqMax: a.freqMax}
}
// UltrabeamSettings is the JSON shape for the Hardware → Antenna panel. The name
@@ -12250,6 +12255,12 @@ type UltrabeamSettings struct {
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)
TXInhibit bool `json:"tx_inhibit"` // block Flex transmission while the elements are moving
// SteppIR tunable range (MHz). The follow loop skips frequencies outside it —
// no tune command AND no TX inhibit — so a band the antenna can't cover (e.g.
// 30 m on a 20 m6 m SteppIR) never traps TX. Ignored for the Ultrabeam, which
// reports its own per-band coverage. 0 = defaults filled in on load for SteppIR.
FreqMinMHz int `json:"freq_min_mhz"`
FreqMaxMHz int `json:"freq_max_mhz"`
}
// GetUltrabeamSettings returns the persisted motorized-antenna config, defaulting
@@ -12261,7 +12272,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
return out, fmt.Errorf("db not initialized")
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit)
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax)
if err != nil {
return out, err
}
@@ -12285,6 +12296,20 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
if st, _ := strconv.Atoi(m[keyUltrabeamStep]); st == 25 || st == 50 || st == 100 {
out.StepKHz = st
}
out.FreqMinMHz, _ = strconv.Atoi(m[keyMotorFreqMin])
out.FreqMaxMHz, _ = strconv.Atoi(m[keyMotorFreqMax])
// A SteppIR doesn't report its coverage, so default to the standard 20 m6 m
// range (1354 MHz) when unset — the common model. Widen it (e.g. min 6 for a
// 40 m-equipped SteppIR) in Settings. This is what lets the follow loop leave
// TX alone on a band the antenna can't reach.
if out.Type == "steppir" {
if out.FreqMinMHz <= 0 {
out.FreqMinMHz = 13
}
if out.FreqMaxMHz <= 0 {
out.FreqMaxMHz = 54
}
}
return out, nil
}
@@ -12309,6 +12334,16 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
if s.Baud < 1200 || s.Baud > 115200 {
s.Baud = 9600
}
// Sanity: keep the range ordered; 0 = "no limit" (stored as-is).
if s.FreqMinMHz < 0 {
s.FreqMinMHz = 0
}
if s.FreqMaxMHz < 0 {
s.FreqMaxMHz = 0
}
if s.FreqMinMHz > 0 && s.FreqMaxMHz > 0 && s.FreqMinMHz > s.FreqMaxMHz {
s.FreqMinMHz, s.FreqMaxMHz = s.FreqMaxMHz, s.FreqMinMHz
}
for k, v := range map[string]string{
keyUltrabeamEnabled: boolStr(s.Enabled),
keyUltrabeamHost: strings.TrimSpace(s.Host),
@@ -12320,6 +12355,8 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyMotorCOM: strings.TrimSpace(s.COM),
keyMotorBaud: strconv.Itoa(s.Baud),
keyMotorTXInhibit: boolStr(s.TXInhibit),
keyMotorFreqMin: strconv.Itoa(s.FreqMinMHz),
keyMotorFreqMax: strconv.Itoa(s.FreqMaxMHz),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -12341,7 +12378,7 @@ func newMotorClient(s UltrabeamSettings) motorAntenna {
if tr.Mode != "serial" && tr.Host == "" {
return nil
}
return steppirAdapter{steppir.New(tr)}
return steppirAdapter{c: steppir.New(tr), freqMin: s.FreqMinMHz, freqMax: s.FreqMaxMHz}
}
// Ultrabeam is TCP only.
if strings.TrimSpace(s.Host) == "" {