feat(motor-antenna): band selector for the follow filter (replaces min/max range)

A contiguous FreqMin/FreqMax range can't drop a single band while keeping its
neighbours — so it couldn't express "40 m and 20 m yes, 30 m no" for an antenna
whose 30 m extension isn't fitted. Replace it with a Covered bands selector
(40 m–6 m) that applies to both the Ultrabeam and the SteppIR.

The follow loop and immediate re-tune now gate on band membership
(motorBandAllowed) instead of a MHz range: only bands in the operator's set are
followed, and 80 m/160 m are never followed (outside a beam's reach) regardless.
The legacy FreqMin/FreqMax is migrated to a band set on load and still
round-trips, so existing configs are unchanged. UI swaps the two number inputs
for a row of toggle chips.
This commit is contained in:
2026-08-06 16:07:42 +02:00
parent df56391eb8
commit bd8ca2e9de
5 changed files with 168 additions and 46 deletions
+136 -28
View File
@@ -237,6 +237,7 @@ const (
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)
keyMotorBands = "ultrabeam.bands" // CSV of bands the antenna covers (e.g. "40m,20m,17m,…"); the follow filter
keyStationDevices = "station.devices" // JSON list of relay boards for the Station Control tab
// Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP
@@ -11988,11 +11989,8 @@ func (a *App) ultrabeamFollowNow(freqHz int64) {
if !st.Connected {
return
}
if st.FreqMin > 0 && st.FreqMax > 0 {
mhz := freqHz / 1_000_000
if mhz < int64(st.FreqMin) || mhz > int64(st.FreqMax) {
return // outside the antenna's tunable range
}
if !motorBandAllowed(s.Bands, freqHz) {
return // a band the antenna doesn't cover — leave it where it is
}
khz := int(freqHz / 1000)
ref := st.Frequency
@@ -14268,10 +14266,15 @@ 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.
// 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
// the antenna is left where it is. Expressed as a set rather than a min/max
// range so a single band can be dropped (e.g. 30 m without its extension) while
// its neighbours stay. Applies to BOTH the Ultrabeam and the SteppIR.
Bands []string `json:"bands"`
// Legacy tunable range (MHz). Superseded by Bands; kept so an older config
// migrates cleanly (the range is converted to a band set on load) and so the
// value round-trips. Not used by the follow filter once Bands is set.
FreqMinMHz int `json:"freq_min_mhz"`
FreqMaxMHz int `json:"freq_max_mhz"`
}
@@ -14285,7 +14288,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, keyMotorFreqMin, keyMotorFreqMax)
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands)
if err != nil {
return out, err
}
@@ -14311,17 +14314,20 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
}
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
// Bands is the follow filter. If it was saved, use it verbatim. Otherwise this
// is a config from before the band selector: migrate the legacy FreqMin/FreqMax
// range into a band set so the effective coverage is unchanged (an unset range
// on a SteppIR is treated as the standard 20 m6 m; an unset range on the
// Ultrabeam becomes the whole 40 m6 m universe, which still excludes 80 m).
if raw := strings.TrimSpace(m[keyMotorBands]); raw != "" {
out.Bands = normMotorBands(strings.Split(raw, ","))
}
if len(out.Bands) == 0 {
minMHz, maxMHz := out.FreqMinMHz, out.FreqMaxMHz
if out.Type == "steppir" && minMHz <= 0 && maxMHz <= 0 {
minMHz, maxMHz = 13, 54
}
out.Bands = motorBandsFromRange(minMHz, maxMHz)
}
return out, nil
}
@@ -14357,6 +14363,14 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
if s.FreqMinMHz > 0 && s.FreqMaxMHz > 0 && s.FreqMinMHz > s.FreqMaxMHz {
s.FreqMinMHz, s.FreqMaxMHz = s.FreqMaxMHz, s.FreqMinMHz
}
// Bands is the follow filter. Normalise to known bands in canonical order; an
// empty selection would strand the antenna on every band, so fall back to the
// full universe (which still excludes 80 m/160 m — outside a motor antenna's
// reach) rather than persist "nothing".
s.Bands = normMotorBands(s.Bands)
if len(s.Bands) == 0 {
s.Bands = motorBandNames()
}
for k, v := range map[string]string{
keyUltrabeamEnabled: boolStr(s.Enabled),
keyUltrabeamHost: strings.TrimSpace(s.Host),
@@ -14370,6 +14384,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyMotorTXInhibit: boolStr(s.TXInhibit),
keyMotorFreqMin: strconv.Itoa(s.FreqMinMHz),
keyMotorFreqMax: strconv.Itoa(s.FreqMaxMHz),
keyMotorBands: strings.Join(s.Bands, ","),
} {
if err := a.settings.Set(a.ctx, k, v); err != nil {
return err
@@ -14433,7 +14448,7 @@ func (a *App) startUltrabeam() {
if s.Follow {
stop := make(chan struct{})
a.ubFollowStop = stop
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, stop)
go a.ultrabeamFollowLoop(a.motorAnt, s.StepKHz, s.Bands, stop)
}
if s.TXInhibit {
stop := make(chan struct{})
@@ -14442,6 +14457,100 @@ func (a *App) startUltrabeam() {
}
}
// motorBands is the band universe a motorized HF/6 m antenna (Ultrabeam / SteppIR)
// can cover, low to high. The follow filter is expressed as a SUBSET of these, so
// an operator can drop a single band (e.g. 30 m, when the 30 m extension isn't
// fitted) while keeping its neighbours — something a contiguous min/max range
// can't express. nomMHz is a representative in-band frequency, used only to
// migrate a legacy FreqMin/FreqMax range into a band set.
var motorBands = []struct {
name string
nomMHz int
}{
{"40m", 7}, {"30m", 10}, {"20m", 14}, {"17m", 18},
{"15m", 21}, {"12m", 24}, {"10m", 28}, {"6m", 50},
}
// motorBandNames is the full ordered set (all bands enabled).
func motorBandNames() []string {
out := make([]string, len(motorBands))
for i, b := range motorBands {
out[i] = b.name
}
return out
}
// normMotorBands keeps only recognised motor bands, canonical low→high order,
// de-duplicated.
func normMotorBands(in []string) []string {
want := map[string]bool{}
for _, s := range in {
want[strings.TrimSpace(strings.ToLower(s))] = true
}
out := []string{}
for _, b := range motorBands {
if want[b.name] {
out = append(out, b.name)
}
}
return out
}
// motorBandsFromRange derives the enabled-band set from a legacy FreqMin/FreqMax
// range (MHz) — a one-time migration to the band selector. An empty/zero range
// means "everything the antenna universe covers".
func motorBandsFromRange(minMHz, maxMHz int) []string {
if minMHz <= 0 && maxMHz <= 0 {
return motorBandNames()
}
hi := maxMHz
if hi <= 0 {
hi = 9999
}
out := []string{}
for _, b := range motorBands {
if b.nomMHz >= minMHz && b.nomMHz <= hi {
out = append(out, b.name)
}
}
if len(out) == 0 { // a nonsensical range shouldn't strand the antenna on every band
return motorBandNames()
}
return out
}
// motorBandAllowed reports whether the follow logic may retune the antenna for a
// rig sitting at hz, given the operator's enabled-band list. A frequency outside
// the motor band universe (e.g. 80 m / 160 m) is NEVER allowed, so OpsLog won't
// push the antenna onto a band it physically can't reach. An empty list is
// treated as "every band in the universe" (a safety fallback — the settings
// loader normally fills the list).
func motorBandAllowed(bands []string, hz int64) bool {
b := bandForHz(hz)
if b == "" {
return false
}
inUniverse := false
for _, m := range motorBands {
if m.name == b {
inUniverse = true
break
}
}
if !inUniverse {
return false
}
if len(bands) == 0 {
return true
}
for _, x := range bands {
if x == b {
return true
}
}
return false
}
// ultrabeamFollowLoop re-tunes the antenna to the rig's current frequency
// whenever it drifts at least stepKHz from what the antenna is set to — so the
// elements track the band without the motors chasing every small QSY. Runs
@@ -14497,7 +14606,7 @@ func (a *App) motorTXInhibitLoop(c motorAntenna, stop <-chan struct{}) {
}
}
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struct{}) {
func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, bands []string, stop <-chan struct{}) {
if stepKHz <= 0 {
stepKHz = 50
}
@@ -14531,12 +14640,11 @@ func (a *App) ultrabeamFollowLoop(c motorAntenna, stepKHz int, stop <-chan struc
float64(rs.FreqHz)/1e6, rs.Mode, st.Frequency, stepKHz)
lastRigKHz = rigKHz
}
// Skip frequencies outside the antenna's tunable range (other band).
if st.FreqMin > 0 && st.FreqMax > 0 {
rigMHz := rs.FreqHz / 1_000_000
if rigMHz < int64(st.FreqMin) || rigMHz > int64(st.FreqMax) {
continue
}
// Skip bands the antenna doesn't cover (per the operator's band list) —
// no tune command, so the elements stay where they are on 80 m, an
// un-fitted 30 m, etc.
if !motorBandAllowed(bands, rs.FreqHz) {
continue
}
// Deadband reference = the rig freq we LAST commanded a move for, not the
// antenna's reported freq. A SteppIR reports a flaky/stale status