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:
@@ -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 m–6 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 m–6 m
|
||||
// range (13–54 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 m–6 m; an unset range on the
|
||||
// Ultrabeam becomes the whole 40 m–6 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
|
||||
|
||||
Reference in New Issue
Block a user