feat(station): the radio and both keyers on the Station Control tab
This tab began as the relay and rotator dashboard and stopped there: the three things an operator touches most — the radio, the CW keyer, the voice keyer — were the ones missing from the page that claims to show the station. The radio card carries the frequency and the mode large, because that is what gets glanced at, and the split pair only when there IS a split: a second frequency shown at all times is one more number to read past. When CAT is down it says which kind of down — switched off, or on and not answering. The CW keyer card carries the speed, which is the control an operator reaches for mid-QSO when a station comes back faster than expected, and Stop beside it because a message going to the wrong callsign has to end now. The voice keyer card carries the recorded messages themselves: a card that only said "idle" would be a light, not a control. Each polls its own binding and holds its own state, like the supply card above them, so they drop into the grid and reorder with everything else. The two keyers appear only when there is something behind them — a port configured, a message actually recorded — because an operator who works neither should not be handed two dead cards. That question is asked once on opening the tab: a keyer is bought and wired, not something that appears mid-session.
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, ChevronUp, ChevronDown } from 'lucide-react';
|
||||
import { Plus, Pencil, Trash2, Power, PlugZap, Loader2, Check, X, Compass, Square, Antenna as AntennaIcon, ArrowDownToLine, Minus, RefreshCw, GripVertical, ChevronUp, ChevronDown, Radio, Zap, Mic } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -25,6 +25,9 @@ import {
|
||||
GetAmpStatuses, GetFlexState,
|
||||
GetTunerGeniusStatus, GetTunerGeniusSettings,
|
||||
GetPSUStatus, GetPSUSettings, SetPSUOutput,
|
||||
GetCATState,
|
||||
GetWinkeyerStatus, WinkeyerSetSpeed, WinkeyerStop, WinkeyerConnect,
|
||||
GetDVKStatus, GetDVKMessages, DVKPlay, DVKStop,
|
||||
} from '../../wailsjs/go/main/App';
|
||||
|
||||
type RotatorProps = { centerLat?: number | null; centerLon?: number | null; bearing?: number | null };
|
||||
@@ -82,6 +85,181 @@ function PSUCard({ st, busy, onToggle, t }: {
|
||||
);
|
||||
}
|
||||
|
||||
// ── What commands the station, and not only what it switches ───────────────
|
||||
//
|
||||
// This tab began as the relay and rotator dashboard, and stopped there: the
|
||||
// three things an operator touches most — the radio, the CW keyer and the voice
|
||||
// keyer — were the ones missing from the page that claims to show the station.
|
||||
//
|
||||
// Each card polls its own binding and holds its own state, like PSUCard above.
|
||||
// That is deliberate: they can then be dropped into the grid, reordered and
|
||||
// hidden with everything else, and adding one costs nothing to the panel around
|
||||
// it. None of them tries to be the full console — a card says what the thing is
|
||||
// doing and offers the one or two controls worth reaching for from here.
|
||||
|
||||
const fmtMHz = (hz: number) => (hz > 0 ? (hz / 1e6).toFixed(6) : '—');
|
||||
|
||||
// The radio. The frequency and the mode large, because that is what an operator
|
||||
// glances at, and the split pair underneath only when there IS a split — a
|
||||
// second frequency shown at all times is one more number to read past.
|
||||
function RigCard({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [st, setSt] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetCATState().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 1000);
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const on = !!st?.connected;
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Radio className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{st?.rig || t('station.rig')}</div>
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-baseline gap-2">
|
||||
<span className="text-xl font-semibold tabular-nums leading-none">{fmtMHz(st?.freq_hz ?? 0)}</span>
|
||||
<span className="text-xs text-muted-foreground">MHz</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap text-[11px]">
|
||||
{!!st?.mode && <span className="rounded px-1.5 py-px font-semibold bg-primary/15 text-primary border border-primary/30">{st.mode}</span>}
|
||||
{!!st?.band && <span className="text-muted-foreground">{st.band}</span>}
|
||||
{!!st?.vfo && <span className="text-muted-foreground">VFO {st.vfo}</span>}
|
||||
{!!st?.backend && <span className="ml-auto text-muted-foreground/70 truncate">{st.backend}</span>}
|
||||
</div>
|
||||
{st?.split && (
|
||||
<div className="flex items-center gap-2 text-[11px] tabular-nums">
|
||||
<span className="rounded px-1.5 py-px font-semibold bg-warning-muted text-warning-muted-foreground border border-warning-border">SPLIT</span>
|
||||
<span className="text-muted-foreground">RX {fmtMHz(st?.freq_rx_hz ?? 0)}</span>
|
||||
</div>
|
||||
)}
|
||||
{!on && (
|
||||
<div className="text-[11px] text-muted-foreground truncate" title={st?.error || ''}>
|
||||
{st?.enabled ? (st?.error || t('station.rigDown')) : t('station.rigOff')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The CW keyer. Speed is the control an operator reaches for mid-QSO — a
|
||||
// station answers faster or slower than expected and the reply has to match —
|
||||
// so it is here rather than only in the docked panel, and Stop is beside it
|
||||
// because a message sent to the wrong callsign has to end NOW.
|
||||
function KeyerCard({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [st, setSt] = useState<any>(null);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetWinkeyerStatus().then((s: any) => { if (alive) setSt(s); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 1000);
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const on = !!st?.connected;
|
||||
const wpm = st?.wpm || 0;
|
||||
const step = (d: number) => {
|
||||
const w = Math.max(5, Math.min(50, wpm + d));
|
||||
setSt((cur: any) => ({ ...(cur ?? {}), wpm: w })); // shows at once; the poll confirms
|
||||
WinkeyerSetSpeed(w).catch(() => {});
|
||||
};
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Zap className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{t('station.keyer')}</div>
|
||||
{st?.busy && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
|
||||
<span className={cn('ml-auto size-2 rounded-full shrink-0', on ? 'bg-success' : 'bg-muted-foreground/40')}
|
||||
title={on ? t('station.online') : (st?.error || t('station.offline'))} />
|
||||
</div>
|
||||
<div className="p-3 space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(-1)}>
|
||||
<Minus className="size-3.5" />
|
||||
</Button>
|
||||
<div className="flex items-baseline gap-1">
|
||||
<span className="text-xl font-semibold tabular-nums leading-none">{wpm || '—'}</span>
|
||||
<span className="text-xs text-muted-foreground">WPM</span>
|
||||
</div>
|
||||
<Button variant="outline" size="icon" className="size-7" disabled={!on} onClick={() => step(1)}>
|
||||
<Plus className="size-3.5" />
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" className="ml-auto h-7 px-2" disabled={!on || !st?.busy}
|
||||
onClick={() => WinkeyerStop().catch(() => {})}>
|
||||
<Square className="size-3 mr-1" />{t('station.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-[11px] text-muted-foreground">
|
||||
<span className="truncate">{st?.port || t('station.noPort')}</span>
|
||||
{!!st?.version && <span className="ml-auto shrink-0">v{st.version}</span>}
|
||||
</div>
|
||||
{!on && (
|
||||
<Button variant="outline" size="sm" className="w-full h-7"
|
||||
onClick={() => WinkeyerConnect().catch(() => {})}>
|
||||
{t('station.connect')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// The voice keyer. The messages themselves, because a card that only said
|
||||
// "idle" would be a light and not a control — from here a CQ goes out without
|
||||
// leaving the tab.
|
||||
function VoiceKeyerCard({ t }: { t: (k: string, v?: any) => string }) {
|
||||
const [st, setSt] = useState<any>({ playing: false, recording: false });
|
||||
const [msgs, setMsgs] = useState<any[]>([]);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
const tick = () => GetDVKStatus().then((s: any) => { if (alive) setSt(s ?? {}); }).catch(() => {});
|
||||
tick();
|
||||
const h = window.setInterval(tick, 1000);
|
||||
// The recordings change when the operator records one, which is rare and
|
||||
// never from this tab — read once, and again only on a status change worth
|
||||
// it would be more machinery than it saves.
|
||||
GetDVKMessages().then((m: any[]) => { if (alive) setMsgs(m ?? []); }).catch(() => {});
|
||||
return () => { alive = false; window.clearInterval(h); };
|
||||
}, []);
|
||||
const recorded = msgs.filter((m) => m.has_audio);
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden h-full">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Mic className="size-4 text-primary" />
|
||||
<div className="text-sm font-semibold truncate">{t('station.voiceKeyer')}</div>
|
||||
{st?.playing && <span className="text-[10px] font-bold text-danger animate-pulse">TX</span>}
|
||||
{st?.recording && <span className="text-[10px] font-bold text-warning animate-pulse">REC</span>}
|
||||
<Button variant="ghost" size="sm" className="ml-auto h-6 px-2 text-[11px]"
|
||||
disabled={!st?.playing} onClick={() => DVKStop().catch(() => {})}>
|
||||
<Square className="size-3 mr-1" />{t('station.stop')}
|
||||
</Button>
|
||||
</div>
|
||||
<div className="p-3">
|
||||
{recorded.length === 0 ? (
|
||||
<div className="text-[11px] text-muted-foreground">{t('station.noVoiceMsg')}</div>
|
||||
) : (
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{recorded.map((m) => (
|
||||
<button key={m.slot} type="button"
|
||||
onClick={() => DVKPlay(m.slot).catch(() => {})}
|
||||
disabled={st?.playing}
|
||||
title={`${m.duration_sec?.toFixed?.(1) ?? ''}s`}
|
||||
className="rounded-md border border-border bg-muted/30 px-2 py-1 text-[11px] font-medium hover:bg-muted disabled:opacity-40">
|
||||
<span className="text-muted-foreground mr-1">F{m.slot}</span>
|
||||
{m.label || `#${m.slot}`}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type Device = {
|
||||
id: string; type: string; name: string; host: string;
|
||||
user?: string; pass?: string; channels?: number; labels: string[];
|
||||
@@ -317,6 +495,25 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
}, [poll, pollAnt, devices.length]);
|
||||
|
||||
const persistOrder = (next: string[]) => { setOrder(next); writeUiPref('opslog.stationOrder', JSON.stringify(next)); };
|
||||
|
||||
// Whether the two keyers exist at this station. Asked ONCE, on opening the
|
||||
// tab: a keyer is bought, wired and configured, not something that appears
|
||||
// mid-session, and polling for the answer would be a round trip a second for
|
||||
// a fact that does not change. A keyer counts as present when it is connected
|
||||
// or a port is configured for it, the voice keyer when at least one message
|
||||
// has actually been recorded — an empty set of slots is not a keyer.
|
||||
const [keyerShown, setKeyerShown] = useState(false);
|
||||
const [dvkShown, setDvkShown] = useState(false);
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
GetWinkeyerStatus().then((s: any) => {
|
||||
if (alive) setKeyerShown(!!s && (!!s.connected || !!String(s.port ?? '').trim()));
|
||||
}).catch(() => {});
|
||||
GetDVKMessages().then((m: any[]) => {
|
||||
if (alive) setDvkShown((m ?? []).some((x) => x?.has_audio));
|
||||
}).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, []);
|
||||
// Reorder so `dragged` lands just before `target`.
|
||||
const onDrop = (targetId: string) => {
|
||||
const src = dragId.current; dragId.current = null;
|
||||
@@ -419,6 +616,13 @@ export function StationControlPanel({ centerLat, centerLon, bearing }: RotatorPr
|
||||
// single ~430px column — they are the same cards the FlexRadio panel shows
|
||||
// full-width, and they need that room here too.
|
||||
const widgets: { id: string; node: React.ReactNode; wide?: boolean }[] = [];
|
||||
// The radio first: it is the station, and everything else on this page is
|
||||
// something attached to it. Then the two keyers, each only when there is
|
||||
// something behind it — an operator who works neither CW nor voice keyer
|
||||
// should not be given two dead cards to read past.
|
||||
widgets.push({ id: 'rig', node: <RigCard t={t} /> });
|
||||
if (keyerShown) widgets.push({ id: 'keyer', node: <KeyerCard t={t} /> });
|
||||
if (dvkShown) widgets.push({ id: 'dvk', node: <VoiceKeyerCard t={t} />, wide: true });
|
||||
if (rot.enabled) {
|
||||
widgets.push({ id: 'rotator', node: <RotatorWidget hd={rot} refetch={pokeRotorHeading} centerLat={centerLat} centerLon={centerLon} bearing={bearing} t={t} /> });
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user