299 lines
17 KiB
TypeScript
299 lines
17 KiB
TypeScript
// 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<string, number> };
|
|
|
|
// 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<string, number> = {
|
|
'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<number[]>([]); // current element lengths (mm), from ReadElements
|
|
const [reading, setReading] = useState(false);
|
|
const [busyEl, setBusyEl] = useState<number | null>(null);
|
|
const [editingEl, setEditingEl] = useState<number | null>(null); // element whose mm is being typed
|
|
const [editVal, setEditVal] = useState('');
|
|
const run = (p: Promise<any>) => 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 (
|
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full flex flex-col min-h-0">
|
|
<div className="flex items-center gap-2 px-2 py-1.5 border-b border-border/60 bg-muted/30 shrink-0">
|
|
<AntennaIcon className="size-4 text-primary" />
|
|
<div className="min-w-0">
|
|
<div className="text-sm font-semibold truncate">{isUB ? 'Ultrabeam' : 'SteppIR'}</div>
|
|
{ant.frequency > 0 && <div className="text-[10px] text-muted-foreground font-mono">{(ant.frequency / 1000).toFixed(3)} MHz</div>}
|
|
</div>
|
|
{ant.moving && <span className="ml-auto text-[10px] font-semibold text-warning animate-pulse">{t('station.moving')}</span>}
|
|
<span className={cn('size-2 rounded-full shrink-0', ant.moving ? '' : 'ml-auto', ant.connected ? 'bg-success' : 'bg-muted-foreground/40')}
|
|
title={ant.connected ? t('station.online') : t('station.offline')} />
|
|
{onClose && (
|
|
<button type="button" onClick={onClose} title={t('station.hideWidget')}
|
|
className="text-muted-foreground hover:text-foreground shrink-0">
|
|
<X className="size-3.5" />
|
|
</button>
|
|
)}
|
|
</div>
|
|
<div className="p-2 space-y-2 flex-1 min-h-0 overflow-auto">
|
|
<div>
|
|
{/* No heading: N / 180° / Bi say what they are, and in a docked widget
|
|
a line of height costs more than the word explains. */}
|
|
<div className="flex gap-1">
|
|
{dirs.map(([d, lbl]) => (
|
|
<button key={d} type="button" disabled={!ant.connected}
|
|
onClick={() => run(SetUltrabeamDirection(d))}
|
|
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors disabled:opacity-40',
|
|
ant.direction === d ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
|
{lbl}
|
|
</button>
|
|
))}
|
|
</div>
|
|
</div>
|
|
{/* 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. */}
|
|
<div>
|
|
<div className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground mb-0.5">{t('station.bands')}</div>
|
|
<div className="grid grid-cols-5 gap-1">
|
|
{(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 (
|
|
<button key={b} type="button" disabled={!ant.connected}
|
|
onClick={() => run(MotorTuneKHz(khz))}
|
|
title={`${(khz / 1000).toFixed(3)} MHz`}
|
|
className={cn('rounded-md border py-0.5 text-[11px] font-mono font-semibold transition-colors disabled:opacity-40',
|
|
here ? 'bg-primary text-primary-foreground border-primary' : 'border-border hover:bg-muted')}>
|
|
{b}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-1">
|
|
<button type="button" disabled={!ant.connected}
|
|
onClick={() => run(MotorNudgeKHz(-NUDGE_KHZ))}
|
|
title={t('station.nudgeDown', { n: NUDGE_KHZ })}
|
|
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
|
<ChevronDown className="size-3.5" /> {NUDGE_KHZ}
|
|
</button>
|
|
<button type="button" disabled={!ant.connected}
|
|
onClick={() => run(MotorNudgeKHz(NUDGE_KHZ))}
|
|
title={t('station.nudgeUp', { n: NUDGE_KHZ })}
|
|
className="flex-1 flex items-center justify-center gap-1 rounded-md border border-border py-1 text-xs font-semibold hover:bg-muted disabled:opacity-40">
|
|
<ChevronUp className="size-3.5" /> {NUDGE_KHZ}
|
|
</button>
|
|
</div>
|
|
|
|
{/* 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 && (
|
|
<div className="space-y-1.5">
|
|
<div className="flex items-center gap-2">
|
|
<button type="button"
|
|
onClick={() => run(SetMotorFollow(!ant.follow, 0, ''))}
|
|
className={cn('flex-1 rounded-md border py-1 text-xs font-semibold transition-colors',
|
|
ant.follow ? 'bg-success-muted text-success-muted-foreground border-success-border' : 'border-border hover:bg-muted')}>
|
|
{ant.follow ? t('station.trackOn') : t('station.trackOff')}
|
|
</button>
|
|
{ant.follow && (ant.track_mode || 'step') === 'step' && (
|
|
<select
|
|
value={String(ant.step_khz || 50)}
|
|
onChange={(e) => run(SetMotorFollow(true, parseInt(e.target.value, 10), ''))}
|
|
className="h-[30px] rounded-md border border-border bg-background px-1 text-xs font-mono"
|
|
title={t('station.trackStepTip')}
|
|
>
|
|
{[25, 50, 100].map((s) => <option key={s} value={s}>{s} kHz</option>)}
|
|
</select>
|
|
)}
|
|
</div>
|
|
{ant.follow && (
|
|
<select
|
|
value={ant.track_mode || 'step'}
|
|
onChange={(e) => run(SetMotorFollow(true, 0, e.target.value))}
|
|
className="w-full h-[30px] rounded-md border border-border bg-background px-1.5 text-xs"
|
|
title={t('station.trackModeTip')}
|
|
>
|
|
<option value="always" title={t('station.trackAlwaysTip')}>{t('station.trackAlways')}</option>
|
|
<option value="step" title={t('station.trackStepTipMode')}>{t('station.trackStep')}</option>
|
|
<option value="band" title={t('station.trackBandTip')}>{t('station.trackBand')}</option>
|
|
</select>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
<button type="button" disabled={!ant.connected}
|
|
onClick={() => run(UltrabeamRetract())}
|
|
className="w-full flex items-center justify-center gap-1.5 rounded-md border border-warning-border bg-warning-muted text-warning-muted-foreground py-1 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
|
<ArrowDownToLine className="size-3.5" /> {t('station.retract')}
|
|
</button>
|
|
|
|
{isUB && !essentialsOnly && (
|
|
<div>
|
|
<div className="flex items-center gap-2 mb-1.5">
|
|
<span className="text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">{t('station.elements')}</span>
|
|
<button type="button" onClick={readLengths} disabled={!ant.connected || reading}
|
|
className="text-[10px] text-primary hover:underline inline-flex items-center gap-1 disabled:opacity-40" title={t('station.readLengths')}>
|
|
<RefreshCw className={cn('size-3', reading && 'animate-spin')} /> {t('station.read')}
|
|
</button>
|
|
</div>
|
|
{lengths.length === 0 ? (
|
|
<p className="text-[11px] text-muted-foreground">{t('station.noLengths')}</p>
|
|
) : (
|
|
<div className="space-y-1.5">
|
|
{/* 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 }) => (
|
|
<div key={i} className="flex items-center gap-2">
|
|
<span className="text-xs w-20 shrink-0 text-muted-foreground truncate">{elementName(i, t)}</span>
|
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
|
onClick={() => nudge(i, -ELEMENT_STEP)}
|
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
|
<Minus className="size-3.5" />
|
|
</button>
|
|
{editingEl === i ? (
|
|
<input autoFocus type="number" value={editVal}
|
|
onChange={(e) => 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" />
|
|
) : (
|
|
<span className="text-sm font-mono font-bold flex-1 min-w-0 text-center tabular-nums cursor-pointer hover:underline"
|
|
title={t('station.setExactLen')}
|
|
onClick={() => { setEditingEl(i); setEditVal(String(mm)); }}>
|
|
{busyEl === i ? <Loader2 className="size-3.5 animate-spin inline" /> : `${mm} mm`}
|
|
</span>
|
|
)}
|
|
<button type="button" disabled={!ant.connected || busyEl !== null}
|
|
onClick={() => nudge(i, ELEMENT_STEP)}
|
|
className="flex items-center justify-center size-7 rounded-md border border-border hover:bg-muted disabled:opacity-40 shrink-0">
|
|
<Plus className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
<p className="text-[10px] text-muted-foreground mt-1">{t('station.elementsHint')}</p>
|
|
</div>
|
|
)}
|
|
{err && <div className="text-[11px] text-destructive break-words">{err}</div>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|