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
+26 -14
View File
@@ -673,6 +673,9 @@ type AmpUI = { id: string; name: string; enabled: boolean; type: string; transpo
type RelayRuleUI = { device_id: string; relay: number; mode: string; freq_lo_khz: number; freq_hi_khz: number; bands: string[] };
type StationDevUI = { id: string; type: string; name: string; labels: string[] };
const RELAY_BANDS = ['160m', '80m', '60m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m', '4m', '2m', '70cm'];
// 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'];
const relayCountUI = (type: string) => (type === 'kmtronic' || type === 'denkovi' ? 8 : 5);
// Live status + OPERATE/STANDBY toggle for ONE configured amplifier (by config
@@ -1149,8 +1152,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; tx_inhibit: boolean; 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, tx_inhibit: false, 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; 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, 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);
@@ -3013,19 +3016,28 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
)}
</div>
{(isSteppir || ultrabeam.type === 'ultrabeam') && (
<div className="border-t border-border/60 pt-3 space-y-1">
<div className="flex items-center gap-2 flex-wrap">
<Label className="text-sm">{t('hw.motorRange')}</Label>
<input type="number" min={1} max={60} value={ultrabeam.freq_min_mhz}
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_min_mhz: parseInt(e.target.value, 10) || 0 }))}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
<span className="text-xs text-muted-foreground"></span>
<input type="number" min={1} max={60} value={ultrabeam.freq_max_mhz}
onChange={(e) => setUltrabeam((s) => ({ ...s, freq_max_mhz: parseInt(e.target.value, 10) || 0 }))}
className="h-8 w-16 rounded-md border border-input bg-background px-2 text-sm" />
<span className="text-xs text-muted-foreground">MHz</span>
<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">
{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>
<p className="text-xs text-muted-foreground">{t('hw.motorRangeHint')}</p>
<p className="text-xs text-muted-foreground">{t('hw.motorBandsHint')}</p>
</div>
)}
<div className="border-t border-border/60 pt-3 space-y-1">