// MotorAntennaWidget — the Ultrabeam / SteppIR control, in one place. // // Lives in its own file because it is shown in TWO: the Station Control tab and // the dock beside the entry strip. Two copies of a control that keys hardware is // how one of them quietly stops matching the other — a pattern button that sets // a different direction, a band list that tunes somewhere else. import { useCallback, useEffect, useState } from 'react'; import { ArrowDownToLine, ChevronDown, ChevronUp, Loader2, Minus, Plus, RefreshCw, Antenna as AntennaIcon, X } from 'lucide-react'; import { cn } from '@/lib/utils'; import { SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements, MotorTuneKHz, MotorNudgeKHz, SetMotorFollow, } from '../../wailsjs/go/main/App'; export 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 }; // Where each band button points the antenna. // // Not the arithmetic centre of the band. An antenna is tuned for where people // actually work: 20 m centres on 14175 but nobody lives there, and 10 m spans // 1.7 MHz of which the top half is empty. These are the points that leave the // elements closest to right for the whole band, and one nudge away from the rest. const ANT_BAND_KHZ: Record = { '40m': 7100, '30m': 10125, '20m': 14150, '17m': 18110, '15m': 21150, '12m': 24930, '10m': 28400, '6m': 50150, }; // NUDGE_KHZ is the up/down step. 25 kHz because that is also the finest tracking // threshold: a nudge smaller than the tracking step would be undone by the next // poll while tracking is on. const NUDGE_KHZ = 25; // bandOfKHz names the band a frequency sits in, so a band button can light up // when the ANTENNA is already there. Deliberately generous at the edges: the // antenna's reported frequency is where it was commanded, which can sit slightly // outside the allocation. function bandOfKHz(khz: number): string { const edges: [number, number, string][] = [ [6900, 7300, '40m'], [10050, 10200, '30m'], [13900, 14400, '20m'], [18000, 18200, '17m'], [20900, 21500, '15m'], [24800, 25000, '12m'], [27900, 29800, '10m'], [49900, 50600, '6m'], ]; for (const [lo, hi, b] of edges) if (khz >= lo && khz <= hi) return b; return ''; } // MotorAntennaWidget controls a motorized antenna (Ultrabeam / SteppIR) from the // Station Control tab: pattern (Normal / 180° / Bi), Retract, and — Ultrabeam // only — per-element length adjustment. Heading/state is polled by the panel. const ELEMENT_STEP = 2; // the physical console adjusts 2 mm per press // elementName maps an element index to a ham-radio name: 0 = reflector, // 1 = driven element, then Director 1, 2, 3… function elementName(i: number, t: (k: string, v?: any) => string): string { if (i === 0) return t('station.reflector'); if (i === 1) return t('station.driven'); return `${t('station.director')} ${i - 1}`; } export function MotorAntennaWidget({ ant, refetch, t, onClose, essentialsOnly }: { ant: AntStatus; refetch: () => void; t: (k: string, v?: any) => string; // onClose adds the dock's close button. The Station Control tab passes none: // there the widget is a tile in a grid, and a tile that closes itself would // leave a hole the operator cannot fill back in. onClose?: () => void; // essentialsOnly drops what an operator sets once and then leaves alone: // tracking with its mode and step, and the per-element lengths. They belong in // Station Control, where there is room to think; in the dock they are height // spent on controls nobody touches between contacts, pushing the ones they do // touch — pattern, bands, nudge — off the bottom. essentialsOnly?: boolean; }) { const [err, setErr] = useState(''); const [lengths, setLengths] = useState([]); // current element lengths (mm), from ReadElements const [reading, setReading] = useState(false); const [busyEl, setBusyEl] = useState(null); const [editingEl, setEditingEl] = useState(null); // element whose mm is being typed const [editVal, setEditVal] = useState(''); const run = (p: Promise) => p.then(refetch).catch((e) => setErr(String(e?.message ?? e))); const isUB = ant.type !== 'steppir'; const readLengths = useCallback(async () => { setReading(true); setErr(''); try { setLengths(((await MotorReadElements()) ?? []) as number[]); } catch (e: any) { setErr(String(e?.message ?? e)); } finally { setReading(false); } }, []); // Read the current lengths once when the Ultrabeam widget mounts/connects, so // +/- starts from the real values rather than blind. useEffect(() => { if (isUB && ant.connected) readLengths(); }, [isUB, ant.connected, readLengths]); // Send an absolute length to one element and remember it as the new baseline. const setLen = async (i: number, mm: number) => { const next = Math.max(0, Math.round(mm)); const prev = lengths[i] ?? 0; setBusyEl(i); setErr(''); setLengths((ls) => { const c = [...ls]; c[i] = next; return c; }); // optimistic try { await MotorSetElement(i, next); refetch(); } catch (e: any) { // Rejected by the controller — most often the element is at its travel // limit for this band (extending on a low band). Revert the optimistic // value so the display stays truthful, and explain the likely cause when // the failed move was an extension. setLengths((ls) => { const c = [...ls]; c[i] = prev; return c; }); setErr(next > prev ? t('station.atMax') : String(e?.message ?? e)); } finally { setBusyEl(null); } }; // Nudge one element by ±2 mm from its current known length. const nudge = (i: number, delta: number) => setLen(i, (lengths[i] ?? 0) + delta); // Commit a typed exact length (click on the mm value). Lets the operator fix // the baseline when the auto-read is off, so +/- then work reliably. const commitEdit = (i: number) => { const v = parseInt(editVal, 10); setEditingEl(null); if (!isNaN(v) && v >= 0 && v !== lengths[i]) setLen(i, v); }; const dirs: [number, string][] = [[0, 'N'], [1, '180°'], [2, t('station.bi')]]; return (
{isUB ? 'Ultrabeam' : 'SteppIR'}
{ant.frequency > 0 &&
{(ant.frequency / 1000).toFixed(3)} MHz
}
{ant.moving && {t('station.moving')}} {onClose && ( )}
{/* No heading: N / 180° / Bi say what they are, and in a docked widget a line of height costs more than the word explains. */}
{dirs.map(([d, lbl]) => ( ))}
{/* Bands, then a nudge, then tracking — in the order an operator uses them: get to the band, fine-tune inside it, decide whether the antenna should follow the rig from here. */}
{t('station.bands')}
{(ant.bands ?? []).map((b: string) => { // 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 // reason for tuning it by hand. const here = ant.frequency > 0 && bandOfKHz(ant.frequency) === b; return ( ); })}
{/* Tracking. Here rather than only in Settings because it is an operating decision — off to park the antenna, on to resume — not something set up once. Mode and step only show when tracking is on: settings for something switched off are questions the operator cannot act on. And the step only shows in step mode, where it is the one thing it means. */} {!essentialsOnly && (
{ant.follow && (ant.track_mode || 'step') === 'step' && ( )}
{ant.follow && ( )}
)} {isUB && !essentialsOnly && (
{t('station.elements')}
{lengths.length === 0 ? (

{t('station.noLengths')}

) : (
{/* The READ_BANDS reply is undocumented and its 16-bit parse picks up structural bytes past the real data (varying by band), which made 6+ bogus "elements" appear. Ultrabeam beams are 3-element, and ModifyElement addresses elements 0..2 — so show just those. */} {lengths.map((mm, i) => ({ mm, i })).slice(0, 3).map(({ mm, i }) => (
{elementName(i, t)} {editingEl === i ? ( setEditVal(e.target.value)} onBlur={() => commitEdit(i)} onKeyDown={(e) => { if (e.key === 'Enter') commitEdit(i); if (e.key === 'Escape') setEditingEl(null); }} className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums bg-transparent border border-primary rounded px-1" /> ) : ( { setEditingEl(i); setEditVal(String(mm)); }}> {busyEl === i ? : `${mm} mm`} )}
))}
)}

{t('station.elementsHint')}

)} {err &&
{err}
}
); }