feat(station): drive the motorized antenna from its widget
It showed pattern, elements and Retract — everything except where the antenna is pointed, which is the thing an operator changes most. Tuning it meant opening Settings or moving the rig. A button per band, taken from the bands configured in Settings rather than a list invented here: a band dropped there cannot be clicked here. They point at where people actually work, not the arithmetic centre — 20 m centres on 14175 and nobody lives there, 10 m spans 1.7 MHz of which the top half is empty. Up and down move 25 kHz, which is also the finest tracking threshold: a smaller nudge would be undone by the next poll while tracking is on. They work from the ANTENNA's frequency, not the rig's, or walking the antenna across a band while the rig stays put would be undone on the first press. Tracking moved within reach because it is an operating decision — off to park the antenna, on to resume — not something set up once. Its step only appears when it is on: a threshold for something switched off is a question the operator cannot act on. MotorTuneKHz carries the CURRENT direction rather than resetting it. Both controllers set frequency and pattern in one command, so a bare frequency would silently drop a 180° or bidirectional pattern — a beam turning round is not an acceptable side effect of clicking a band.
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -15,6 +15,7 @@ import {
|
||||
GetStationDevices, SaveStationDevices, GetStationStatus, StationSetRelay,
|
||||
GetRotatorHeading, RotatorGoTo, RotatorStop, SetActiveRotor,
|
||||
GetUltrabeamStatus, SetUltrabeamDirection, UltrabeamRetract, MotorSetElement, MotorReadElements,
|
||||
MotorTuneKHz, MotorNudgeKHz, SetMotorFollow,
|
||||
ListDenkoviDevices, ListSerialPorts, TestStationDevice,
|
||||
GetAmpStatuses, GetFlexState,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
@@ -115,7 +116,37 @@ 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[] };
|
||||
type AntStatus = { enabled: boolean; type: string; connected: boolean; direction: number; frequency: number; moving: boolean; elements: number[]; follow?: boolean; step_khz?: number; bands?: string[] };
|
||||
|
||||
// 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
|
||||
@@ -203,6 +234,70 @@ function MotorAntennaWidget({ ant, refetch, t }: { ant: AntStatus; refetch: () =
|
||||
))}
|
||||
</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-1">{t('station.bands')}</div>
|
||||
<div className="grid grid-cols-5 gap-1">
|
||||
{(ant.bands ?? []).map((b: string) => {
|
||||
const khz = 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-1 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.5 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.5 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. The step only shows when tracking is on: a threshold for
|
||||
something switched off is a question the operator cannot act on. */}
|
||||
<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.5 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 && (
|
||||
<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>
|
||||
|
||||
<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.5 text-xs font-semibold hover:brightness-95 disabled:opacity-40">
|
||||
|
||||
Reference in New Issue
Block a user