feat(antenna): per-band tune frequency for Ultrabeam and SteppIR

The band buttons in Station Control tuned to a fixed mid-band frequency baked
into the frontend. An operator who lives in the CW segment, or in the FT8
window, got the antenna resonant somewhere he never operates and had to nudge
it every time. The SteppIR's own controller has a Frequency (KHz) column per
band for exactly this; this is that column.

Stored sparsely: a band with no entry uses its default, so nothing migrates
and an operator sets only the bands he cares about. Left empty the Settings
box shows the default as its placeholder, which makes clearing it the obvious
way back.

The value is checked against the band plan before it is kept, because it goes
to the antenna as a tune command — a lost digit (1450 for 20 m) or kHz typed
as MHz would send the elements travelling to a length wrong for the band the
operator is on, and on a SteppIR that journey inhibits transmit the whole way.
A rejected entry is logged and the band falls back to its default.

Resolution happens in the backend and rides on the existing status poll, so
the widget no longer decides where a band button goes and cannot drift from
what Settings shows. Its own table stays only as a floor for the first poll.
This commit is contained in:
2026-08-12 08:58:01 +02:00
parent 65bbaa85f3
commit 1b32b1ddec
7 changed files with 245 additions and 24 deletions
+100 -3
View File
@@ -234,6 +234,7 @@ const (
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"
keyMotorBandFreqs = "motor.band_freqs" // per-band tune frequency: "40m=7100,20m=14150"
keyMotorType = "ultrabeam.type" // "ultrabeam" | "steppir" (default ultrabeam)
keyMotorTransport = "ultrabeam.transport" // "tcp" | "serial" (default tcp)
keyMotorCOM = "ultrabeam.com" // serial device name (COM3, /dev/ttyUSB0)
@@ -14684,6 +14685,11 @@ type UltrabeamSettings struct {
// 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"`
// Per-band tune frequency (kHz) — where a band button in Station Control
// sends the antenna. Sparse: a band with no entry uses its default, so an
// operator sets only the bands he cares about and an existing config needs no
// migration.
BandFreqs map[string]int `json:"band_freqs"`
// 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.
@@ -14723,7 +14729,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
}
m, err := a.settings.GetMany(a.ctx, keyUltrabeamEnabled, keyUltrabeamHost, keyUltrabeamPort, keyUltrabeamFollow, keyUltrabeamStep,
keyMotorType, keyMotorTransport, keyMotorCOM, keyMotorBaud, keyMotorTXInhibit, keyMotorFreqMin, keyMotorFreqMax, keyMotorBands,
keyMotorTrackMode)
keyMotorTrackMode, keyMotorBandFreqs)
if err != nil {
return out, err
}
@@ -14748,6 +14754,7 @@ func (a *App) GetUltrabeamSettings() (UltrabeamSettings, error) {
out.StepKHz = st
}
out.TrackMode = normMotorTrackMode(m[keyMotorTrackMode])
out.BandFreqs = decodeMotorBandFreqs(m[keyMotorBandFreqs])
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
@@ -14814,6 +14821,7 @@ func (a *App) SaveUltrabeamSettings(s UltrabeamSettings) error {
keyUltrabeamFollow: boolStr(s.Follow),
keyUltrabeamStep: strconv.Itoa(s.StepKHz),
keyMotorTrackMode: normMotorTrackMode(s.TrackMode),
keyMotorBandFreqs: encodeMotorBandFreqs(normMotorBandFreqs(s.BandFreqs)),
keyMotorType: s.Type,
keyMotorTransport: s.Transport,
keyMotorCOM: strings.TrimSpace(s.COM),
@@ -14899,12 +14907,91 @@ func (a *App) startUltrabeam() {
// 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.
// defKHz is where a band button tunes the antenna when the operator has not
// chosen a frequency for that band — roughly mid-band, where a beam's pattern is
// usable across the whole allocation. It is only a default: an operator who
// lives in the CW segment sets his own, exactly as the SteppIR controller's own
// "Frequency (KHz)" column does.
var motorBands = []struct {
name string
nomMHz int
defKHz int
}{
{"40m", 7}, {"30m", 10}, {"20m", 14}, {"17m", 18},
{"15m", 21}, {"12m", 24}, {"10m", 28}, {"6m", 50},
{"40m", 7, 7100}, {"30m", 10, 10125}, {"20m", 14, 14150}, {"17m", 18, 18110},
{"15m", 21, 21150}, {"12m", 24, 24930}, {"10m", 28, 28400}, {"6m", 50, 50150},
}
// motorBandDefaultKHz is the fallback tune frequency for a band, 0 if unknown.
func motorBandDefaultKHz(band string) int {
band = strings.ToLower(strings.TrimSpace(band))
for _, b := range motorBands {
if b.name == band {
return b.defKHz
}
}
return 0
}
// normMotorBandFreqs keeps only entries that name a real motor band AND whose
// frequency actually falls in that band.
//
// The check matters: this value is fed straight to the antenna as a tune
// command. A slip of one digit — 1450 for 20 m, or kHz typed as MHz — would send
// the elements travelling to a length that is wrong for the band the operator is
// on, and on a SteppIR that is a long, transmit-inhibited journey to a position
// nobody asked for. An entry that fails the check is dropped, so the band falls
// back to its default rather than to nonsense.
func normMotorBandFreqs(in map[string]int) map[string]int {
out := map[string]int{}
for _, b := range motorBands {
khz, ok := in[b.name]
if !ok || khz <= 0 {
continue
}
if bandForHz(int64(khz)*1000) != b.name {
applog.Printf("motor-antenna: ignoring %d kHz for %s — not in that band", khz, b.name)
continue
}
out[b.name] = khz
}
return out
}
// encodeMotorBandFreqs / decodeMotorBandFreqs store the map as "40m=7100,20m=14150".
// A flat string rather than JSON so the settings row stays readable, and so a
// value corrupted by hand degrades one band instead of the whole set.
func encodeMotorBandFreqs(m map[string]int) string {
parts := []string{}
for _, b := range motorBands { // canonical order, not map order
if khz := m[b.name]; khz > 0 {
parts = append(parts, fmt.Sprintf("%s=%d", b.name, khz))
}
}
return strings.Join(parts, ",")
}
func decodeMotorBandFreqs(s string) map[string]int {
out := map[string]int{}
for _, kv := range strings.Split(s, ",") {
name, val, ok := strings.Cut(strings.TrimSpace(kv), "=")
if !ok {
continue
}
if khz, err := strconv.Atoi(strings.TrimSpace(val)); err == nil && khz > 0 {
out[strings.ToLower(strings.TrimSpace(name))] = khz
}
}
return normMotorBandFreqs(out)
}
// motorTuneKHzForBand is the frequency a band button commands: the operator's
// choice when set, the default otherwise.
func motorTuneKHzForBand(m map[string]int, band string) int {
band = strings.ToLower(strings.TrimSpace(band))
if khz := m[band]; khz > 0 {
return khz
}
return motorBandDefaultKHz(band)
}
// motorBandNames is the full ordered set (all bands enabled).
@@ -15180,6 +15267,10 @@ type UltrabeamStatusInfo struct {
// as buttons rather than inventing its own list, so a band dropped in Settings
// cannot be clicked here.
Bands []string `json:"bands"`
// Where each band button tunes. Resolved here — operator's choice or the
// default — so the widget never has to carry its own copy of the band table
// and cannot drift from what Settings shows.
BandFreqs map[string]int `json:"band_freqs"`
}
// GetUltrabeamStatus returns the antenna's current state for the UI poll.
@@ -15191,6 +15282,12 @@ func (a *App) GetUltrabeamStatus() UltrabeamStatusInfo {
out.Follow = s.Follow
out.StepKHz = s.StepKHz
out.TrackMode = normMotorTrackMode(s.TrackMode)
out.BandFreqs = map[string]int{}
for _, b := range s.Bands {
if khz := motorTuneKHzForBand(s.BandFreqs, b); khz > 0 {
out.BandFreqs[b] = khz
}
}
out.Bands = append(out.Bands, s.Bands...)
if a.motorAnt == nil {
return out