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
+4 -2
View File
@@ -7,14 +7,16 @@
"Band openings: EME contacts are no longer mistaken for an opening. A 2 m opening was announced at 9650 km towards Japan on stations working moonbounce — real contacts, but the moon says nothing about the band, and an antenna pointed that way finds nothing. Each band now has the longest path the atmosphere can actually carry: 3500 km on 2 m, 4000 on 4 m, and no limit at all on 6 and 10 m where multi-hop really does go round the world.",
"Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency. A frequency set while transmitting was not recorded, so WSJT-X was told the radio was already back on the receive frequency and never restored it.",
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio. The meters were only ever drawn from the radio's stream, so a station on any other rig got an empty card while the amplifier was reporting all four over its own link.",
"Motorized antennas (Ultrabeam and SteppIR): tracking now offers the three modes the SteppIR controller software has — every frequency change, past a step of 25/50/100 kHz, or only when the band changes. Existing setups keep the step mode they already had."
"Motorized antennas (Ultrabeam and SteppIR): tracking now offers the three modes the SteppIR controller software has — every frequency change, past a step of 25/50/100 kHz, or only when the band changes. Existing setups keep the step mode they already had.",
"Motorized antennas: each covered band now has its own tune frequency, set in a box under the band in Settings, and that is where the band button in Station Control sends the antenna. Left empty a band keeps its default, and a frequency that is not in its band is refused rather than sent to the elements."
],
"fr": [
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.",
"Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Une ouverture 2 m était annoncée à 9650 km vers le Japon sur des stations en rebond lunaire — de vrais contacts, mais la Lune ne dit rien de la bande, et une antenne pointée par là ne trouve rien. Chaque bande a désormais la distance maximale que l atmosphère peut réellement porter : 3500 km en 2 m, 4000 en 4 m, et aucune limite en 6 et 10 m où les sauts multiples font vraiment le tour du monde.",
"Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission. Un changement de fréquence pendant l émission n était pas enregistré, WSJT-X croyait donc la radio déjà revenue sur la fréquence de réception et ne la remettait jamais en place.",
"PowerGenius XL : la carte du Contrôle station affiche désormais puissance, courant, ROS et température sans FlexRadio. Les mesures n étaient tirées que du flux de la radio, si bien qu une station sur une autre radio n avait qu une carte vide alors que l amplificateur remontait les quatre sur sa propre liaison.",
"Antennes motorisées (Ultrabeam et SteppIR) : le suivi propose désormais les trois modes du logiciel du contrôleur SteppIR — à chaque changement de fréquence, au-delà d un pas de 25/50/100 kHz, ou seulement au changement de bande. Les installations existantes conservent le mode par pas qu elles avaient déjà."
"Antennes motorisées (Ultrabeam et SteppIR) : le suivi propose désormais les trois modes du logiciel du contrôleur SteppIR — à chaque changement de fréquence, au-delà d un pas de 25/50/100 kHz, ou seulement au changement de bande. Les installations existantes conservent le mode par pas qu elles avaient déjà.",
"Antennes motorisées : chaque bande couverte a désormais sa propre fréquence d accord, saisie dans une case sous la bande dans les Réglages, et c est là que le bouton de bande du Contrôle station envoie l antenne. Laissée vide, une bande garde son défaut, et une fréquence hors de sa bande est refusée plutôt qu envoyée aux éléments."
]
},
{
+42 -5
View File
@@ -689,6 +689,13 @@ const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '1
// Bands a motorized HF/6 m antenna (Ultrabeam / SteppIR) can cover — the follow
// filter is a subset of these. Must match motorBands in app.go, low → high.
const MOTOR_BANDS = ['40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
// Shown as placeholders only — the backend owns these values (motorBands in
// app.go) and resolves what a band button actually commands. Duplicated here
// purely so an empty box can say what leaving it empty will do.
const MOTOR_BAND_DEFAULT_KHZ: Record<string, number> = {
'40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110,
'15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150,
};
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
@@ -1250,8 +1257,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
const [rotatorTest, setRotatorTest] = useState<{ ok: boolean; msg: string } | null>(null);
// Motorized antenna (Ultrabeam TCP or SteppIR TCP/serial) settings.
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
const [ultrabeam, setUltrabeam] = useState<{ enabled: boolean; type: string; transport: string; host: string; port: number; com: string; baud: number; follow: boolean; step_khz: number; track_mode: string; band_freqs: Record<string, number>; tx_inhibit: boolean; bands: string[]; freq_min_mhz: number; freq_max_mhz: number }>({
enabled: false, type: 'ultrabeam', transport: 'tcp', host: '', port: 23, com: '', baud: 9600, follow: false, step_khz: 50, track_mode: 'step', band_freqs: {}, tx_inhibit: false, bands: [], freq_min_mhz: 13, freq_max_mhz: 54,
});
const [ubTesting, setUbTesting] = useState(false);
const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null);
@@ -3181,25 +3188,55 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
<div className="border-t border-border/60 pt-3 space-y-2">
<Label className="text-sm">{t('hw.motorBands')}</Label>
<div className="flex items-center gap-1.5 flex-wrap">
{/* Band, and under it the frequency its button tunes to the
layout of the SteppIR controller's own Bands and Frequencies
table, which is where operators expect to find this. The box
only appears on a selected band: a tune frequency for a band the
antenna is not allowed on is a setting with no effect. Left
empty it shows the default as placeholder, so the field is
self-documenting and clearing it is how you go back. */}
<div className="flex items-start gap-1.5 flex-wrap">
{MOTOR_BANDS.map((b) => {
const on = ultrabeam.bands.includes(b);
return (
<button key={b} type="button"
<div key={b} className="flex flex-col gap-1">
<button type="button"
onClick={() => setUltrabeam((s) => ({
...s,
bands: on ? s.bands.filter((x) => x !== b) : [...s.bands, b],
}))}
className={`h-8 min-w-[3rem] rounded-md border px-2 text-sm font-medium transition-colors ${
className={`h-8 w-[4.5rem] rounded-md border px-2 text-sm font-medium transition-colors ${
on
? 'border-primary bg-primary/15 text-primary'
: 'border-input bg-background text-muted-foreground hover:bg-muted'
}`}>
{b}
</button>
{on && (
<input
type="text" inputMode="numeric"
value={ultrabeam.band_freqs?.[b] ? String(ultrabeam.band_freqs[b]) : ''}
placeholder={String(MOTOR_BAND_DEFAULT_KHZ[b] ?? '')}
title={t('hw.motorBandFreqHint')}
onChange={(e) => {
// Keep only digits, and store nothing for an empty box
// so it round-trips to "use the default" rather than
// to a zero the backend would have to interpret.
const digits = e.target.value.replace(/[^0-9]/g, '');
setUltrabeam((s) => {
const next = { ...(s.band_freqs || {}) };
if (digits === '') delete next[b]; else next[b] = parseInt(digits, 10);
return { ...s, band_freqs: next };
});
}}
className="h-7 w-[4.5rem] rounded-md border border-input bg-background px-1.5 text-center text-xs font-mono outline-none focus:border-primary"
/>
)}
</div>
);
})}
</div>
<p className="text-xs text-muted-foreground">{t('hw.motorBandFreqHint')}</p>
</div>
)}
<div className="border-t border-border/60 pt-3 space-y-1">
@@ -116,7 +116,7 @@ function RotatorWidget({ hd, refetch, centerLat, centerLon, bearing, t }: Rotato
);
}
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[] };
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; track_mode?: string; bands?: string[]; band_freqs?: Record<string, number> };
// Where each band button points the antenna.
//
@@ -241,7 +241,10 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-1">{t('station.bands')}</div>
<div className="grid grid-cols-5 gap-1">
{(ant.bands ?? []).map((b: string) => {
const khz = ANT_BAND_KHZ[b];
// Where this band tunes is resolved by the backend — the operator's
// per-band choice from Settings, or the default. ANT_BAND_KHZ is only
// the floor for a status poll that hasn't landed yet.
const khz = ant.band_freqs?.[b] || ANT_BAND_KHZ[b];
if (!khz) return null;
// "On this band" from the antenna's own frequency, not the rig's:
// the widget must show where the ANTENNA is, which is the whole
File diff suppressed because one or more lines are too long
+4
View File
@@ -3286,6 +3286,7 @@ export namespace main {
track_mode: string;
tx_inhibit: boolean;
bands: string[];
band_freqs: Record<string, number>;
freq_min_mhz: number;
freq_max_mhz: number;
@@ -3307,6 +3308,7 @@ export namespace main {
this.track_mode = source["track_mode"];
this.tx_inhibit = source["tx_inhibit"];
this.bands = source["bands"];
this.band_freqs = source["band_freqs"];
this.freq_min_mhz = source["freq_min_mhz"];
this.freq_max_mhz = source["freq_max_mhz"];
}
@@ -3324,6 +3326,7 @@ export namespace main {
step_khz: number;
track_mode: string;
bands: string[];
band_freqs: Record<string, number>;
static createFrom(source: any = {}) {
return new UltrabeamStatusInfo(source);
@@ -3343,6 +3346,7 @@ export namespace main {
this.step_khz = source["step_khz"];
this.track_mode = source["track_mode"];
this.bands = source["bands"];
this.band_freqs = source["band_freqs"];
}
}
export class UpdateInfo {
+78
View File
@@ -0,0 +1,78 @@
package main
import "testing"
// A per-band tune frequency goes straight to the antenna as a command, so a
// value that is not actually in that band has to be refused rather than obeyed.
// One wrong digit sends the elements travelling to a length that is wrong for
// the band the operator is on — and on a SteppIR that journey inhibits transmit
// the whole way.
func TestNormMotorBandFreqsRefusesOutOfBand(t *testing.T) {
in := map[string]int{
"20m": 14050, // fine, CW end
"40m": 7005, // fine
"6m": 50313, // fine, FT8
"15m": 1450, // a digit lost — lands in the broadcast band
"10m": 28400000, // Hz typed where kHz was asked
"17m": 14100, // right number, wrong band
"30m": 0, // not set
"80m": 3750, // not a band this antenna covers at all
"bogus": 14100, // not a band
}
got := normMotorBandFreqs(in)
want := map[string]int{"40m": 7005, "20m": 14050, "6m": 50313}
if len(got) != len(want) {
t.Fatalf("kept %v, want %v", got, want)
}
for k, v := range want {
if got[k] != v {
t.Errorf("%s = %d, want %d", k, got[k], v)
}
}
}
// The stored form round-trips, in canonical band order rather than map order so
// the settings row does not churn between saves.
func TestMotorBandFreqsRoundTrip(t *testing.T) {
m := map[string]int{"20m": 14050, "40m": 7005, "6m": 50313}
enc := encodeMotorBandFreqs(m)
if enc != "40m=7005,20m=14050,6m=50313" {
t.Errorf("encoded %q — want canonical low→high order", enc)
}
back := decodeMotorBandFreqs(enc)
for k, v := range m {
if back[k] != v {
t.Errorf("round trip lost %s: %d → %d", k, v, back[k])
}
}
// Garbage in one entry must cost only that entry.
part := decodeMotorBandFreqs("40m=7005,20m=oops,6m=50313")
if part["40m"] != 7005 || part["6m"] != 50313 {
t.Errorf("one bad entry took the others down: %v", part)
}
if _, ok := part["20m"]; ok {
t.Errorf("kept an unparseable entry: %v", part)
}
}
// An unset band falls back to its default, which is what makes the Settings box
// safe to leave empty.
func TestMotorTuneKHzForBandFallsBack(t *testing.T) {
m := map[string]int{"20m": 14050}
if got := motorTuneKHzForBand(m, "20m"); got != 14050 {
t.Errorf("chosen frequency ignored: %d", got)
}
if got := motorTuneKHzForBand(m, "15m"); got != 21150 {
t.Errorf("15m = %d, want the 21150 default", got)
}
if got := motorTuneKHzForBand(m, "80m"); got != 0 {
t.Errorf("80m = %d, want 0 — not a motor band", got)
}
// Every default must itself be in its band, or the fallback ships the very
// fault normMotorBandFreqs exists to catch.
for _, b := range motorBands {
if got := bandForHz(int64(b.defKHz) * 1000); got != b.name {
t.Errorf("default %d kHz for %s reads as %q", b.defKHz, b.name, got)
}
}
}