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
+52 -15
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"
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 ${
on
? 'border-primary bg-primary/15 text-primary'
: 'border-input bg-background text-muted-foreground hover:bg-muted'
}`}>
{b}
</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 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