502 lines
26 KiB
TypeScript
502 lines
26 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
|
import {
|
|
GetYaesuState, RefreshYaesuPanel,
|
|
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
|
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
|
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
|
SetYaesuModeRaw, SetYaesuSplitOffset, SetYaesuKeySpeed, SetYaesuBreakIn, YaesuZeroIn, GetCATState,
|
|
} from '../../wailsjs/go/main/App';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { sMeterRST } from '@/lib/rst';
|
|
import { MeterBar } from '@/components/MeterBar';
|
|
|
|
type YaesuState = {
|
|
available: boolean; model?: string; mode?: string; raw_mode?: string;
|
|
transmitting: boolean; split: boolean;
|
|
s_meter: number; power_meter: number; swr_meter: number;
|
|
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
|
agc?: string; preamp: number; att: number;
|
|
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
|
split_tx_hz?: number; key_speed?: number; break_in?: boolean; swr?: number; power_w?: number;
|
|
};
|
|
|
|
const ZERO: YaesuState = {
|
|
available: false, transmitting: false, split: false,
|
|
s_meter: 0, power_meter: 0, swr_meter: 0,
|
|
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
|
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
|
};
|
|
|
|
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
|
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
|
// the radio's own band keys do. That is why these are band names, not Hz.
|
|
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
|
|
|
// Mode buttons. CW, RTTY, DIGI and PSK exist on BOTH sidebands on a Yaesu and
|
|
// the operator is the one who knows which they want, so each shows its sideband
|
|
// and CLICKING AN ACTIVE BUTTON AGAIN flips it: CW-U → CW-L → CW-U. One button,
|
|
// one finger, no hidden gesture. SSB takes its sideband from the frequency, as
|
|
// the band plan dictates, and AM/FM have none.
|
|
//
|
|
// PSK rides on the rig's DATA mode, like the other digital modes — the button
|
|
// exists because the operator thinks in modes, not in what the radio calls them.
|
|
type ModeBtn = { id: string; label: string; sideband: boolean; rig: (side: 'U' | 'L') => string };
|
|
const MODES: ModeBtn[] = [
|
|
{ id: 'SSB', label: 'SSB', sideband: false, rig: () => 'SSB' },
|
|
{ id: 'CW', label: 'CW', sideband: true, rig: (s) => 'CW-' + s },
|
|
{ id: 'RTTY', label: 'RTTY', sideband: true, rig: (s) => 'RTTY-' + s },
|
|
{ id: 'DIGI', label: 'DIGI', sideband: true, rig: (s) => 'DATA-' + s },
|
|
{ id: 'PSK', label: 'PSK', sideband: true, rig: (s) => 'DATA-' + s },
|
|
{ id: 'AM', label: 'AM', sideband: false, rig: () => 'AM' },
|
|
{ id: 'FM', label: 'FM', sideband: false, rig: () => 'FM' },
|
|
];
|
|
|
|
// Which button the rig's current raw mode belongs to, and on which sideband.
|
|
function activeMode(raw?: string): { id: string; side: 'U' | 'L' } | null {
|
|
switch ((raw || '').toUpperCase()) {
|
|
case 'USB': return { id: 'SSB', side: 'U' };
|
|
case 'LSB': return { id: 'SSB', side: 'L' };
|
|
case 'CW-U': return { id: 'CW', side: 'U' };
|
|
case 'CW-L': return { id: 'CW', side: 'L' };
|
|
case 'RTTY-U': return { id: 'RTTY', side: 'U' };
|
|
case 'RTTY-L': return { id: 'RTTY', side: 'L' };
|
|
case 'DATA-U': return { id: 'DIGI', side: 'U' };
|
|
case 'DATA-L': return { id: 'DIGI', side: 'L' };
|
|
case 'AM': return { id: 'AM', side: 'U' };
|
|
case 'FM': return { id: 'FM', side: 'U' };
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// The FTDX10/FTDX101 preamp is a three-way front-end selector, not an on/off:
|
|
// IPO bypasses the preamp entirely (best on a quiet, high-signal band), AMP1 and
|
|
// AMP2 add gain. Presenting it as a toggle would hide the middle position.
|
|
const PREAMPS = [{ v: '0', l: 'IPO' }, { v: '1', l: 'AMP1' }, { v: '2', l: 'AMP2' }];
|
|
const AGCS = [{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }, { v: 'AUTO', l: 'AUTO' }];
|
|
// The attenuator is a three-step pad on these rigs (6/12/18 dB), not a toggle.
|
|
const ATTS = [{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
|
|
|
function fmtVFO(hz?: number): string {
|
|
if (!hz || hz <= 0) return '––.–––.––';
|
|
const mhz = Math.floor(hz / 1_000_000);
|
|
const khz = Math.floor((hz % 1_000_000) / 1000);
|
|
const h2 = Math.floor((hz % 1000) / 10);
|
|
return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`;
|
|
}
|
|
|
|
function bandOfHz(hz?: number): string {
|
|
if (!hz || hz <= 0) return '';
|
|
const mhz = hz / 1_000_000;
|
|
const bands: [string, number, number][] = [
|
|
['160m', 1.8, 2.0], ['80m', 3.5, 4.0], ['60m', 5.25, 5.45], ['40m', 7.0, 7.3],
|
|
['30m', 10.1, 10.15], ['20m', 14.0, 14.35], ['17m', 18.068, 18.168],
|
|
['15m', 21.0, 21.45], ['12m', 24.89, 24.99], ['10m', 28.0, 29.7], ['6m', 50.0, 54.0],
|
|
];
|
|
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
|
return '';
|
|
}
|
|
|
|
|
|
// Split the 0-100 S-meter reading into S units + dB over S9.
|
|
//
|
|
// The FTDX10 answers SM0 on a 0-255 scale and its manual does not say where S9
|
|
// falls; the front panel puts it at roughly half travel, which is the 50 used
|
|
// here. That figure is a HYPOTHESIS — if reports come out consistently one S
|
|
// unit off on a radio, this is the number to correct, not the RST helper.
|
|
const S9_PCT = 50; // where S9 falls on the 0-100 reading (see above)
|
|
const DB_PER_PCT = 60 / 50; // above S9 the scale runs to roughly +60 dB
|
|
|
|
function sParts(v: number): { s: number; over: number; label: string } {
|
|
if (v >= S9_PCT) {
|
|
const over = Math.max(0, Math.round((v - S9_PCT) * DB_PER_PCT));
|
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
|
}
|
|
const s = Math.max(0, Math.min(9, Math.round(v / (S9_PCT / 9))));
|
|
return { s, over: 0, label: `S${s}` };
|
|
}
|
|
|
|
// Segment colour, the way a radio's own meter is printed: green up to S9, amber
|
|
// through the S9+ range, red once the signal is strong enough to be reported as
|
|
// 59+20 or more. Derived from the SAME S9 point as the label, so the colour
|
|
// change always lands exactly where the numbers say it should — if the S9 point
|
|
// is ever corrected, the colours follow on their own.
|
|
const RED_OVER_DB = 20;
|
|
function sSegColor(frac: number): string {
|
|
const pct = frac * 100;
|
|
if (pct < S9_PCT) return '#16a34a';
|
|
if ((pct - S9_PCT) * DB_PER_PCT < RED_OVER_DB) return '#f59e0b';
|
|
return '#dc2626';
|
|
}
|
|
|
|
function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
|
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; min?: number; max?: number;
|
|
}) {
|
|
const v = Math.max(min, Math.min(max, value));
|
|
const pct = max > min ? ((v - min) / (max - min)) * 100 : 0;
|
|
const ref = useRef<HTMLInputElement>(null);
|
|
// React's onWheel is passive, so preventDefault is ignored there — attach a
|
|
// native non-passive listener, and read live values through refs so the
|
|
// handler never closes over a stale value.
|
|
const valRef = useRef(value); valRef.current = value;
|
|
const cbRef = useRef(onChange); cbRef.current = onChange;
|
|
const disRef = useRef(disabled); disRef.current = disabled;
|
|
const minRef = useRef(min); minRef.current = min;
|
|
const maxRef = useRef(max); maxRef.current = max;
|
|
useEffect(() => {
|
|
const el = ref.current;
|
|
if (!el) return;
|
|
const onWheel = (e: WheelEvent) => {
|
|
if (disRef.current) return;
|
|
e.preventDefault();
|
|
const nv = Math.max(minRef.current, Math.min(maxRef.current, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
|
if (nv !== valRef.current) cbRef.current(nv);
|
|
};
|
|
el.addEventListener('wheel', onWheel, { passive: false });
|
|
return () => el.removeEventListener('wheel', onWheel);
|
|
}, []);
|
|
return (
|
|
<input
|
|
ref={ref}
|
|
type="range" min={min} max={max} value={v} disabled={disabled}
|
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
|
className={cn('flex-1 h-2 rounded-full appearance-none cursor-pointer disabled:opacity-40 disabled:cursor-default',
|
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
|
|
'[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
|
|
'[&::-webkit-slider-thumb]:cursor-grab [&::-webkit-slider-thumb]:active:cursor-grabbing')}
|
|
// The filled side was invisible against the dark theme: --muted is barely
|
|
// lighter than the card it sits on, so the whole track read as one bar.
|
|
// An explicit translucent track keeps both halves distinct in either theme.
|
|
style={{
|
|
background: `linear-gradient(to right, ${accent} 0%, ${accent} ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) ${pct}%, color-mix(in srgb, var(--foreground) 18%, transparent) 100%)`,
|
|
borderColor: accent,
|
|
}}
|
|
/>
|
|
);
|
|
}
|
|
|
|
function Segmented({ value, options, onChange }: {
|
|
value: string; options: { v: string; l: string }[]; onChange: (v: string) => void;
|
|
}) {
|
|
return (
|
|
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
|
{options.map((o) => (
|
|
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
|
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
|
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
|
{o.l}
|
|
</button>
|
|
))}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Chip({ on, onClick, label, title }: { on: boolean; onClick: () => void; label: string; title?: string }) {
|
|
return (
|
|
<button type="button" onClick={onClick} title={title}
|
|
className={cn('shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
|
on ? 'bg-success border-success text-success-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
|
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
|
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
|
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
|
</div>
|
|
<div className="p-3 space-y-3">{children}</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
|
return (
|
|
<div className="flex items-center gap-2">
|
|
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
|
{children}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function YaesuPanel({ onReportRST, onKeySpeed }: {
|
|
onReportRST?: (rst: string) => void;
|
|
// Told whenever the operator moves the CW speed here, so the app can keep the
|
|
// keyer that is ACTUALLY sending in step. With DTR/RTS line keying the PC does
|
|
// the timing and the rig's internal keyer speed changes nothing audible — the
|
|
// slider looked broken because it was driving the wrong keyer.
|
|
onKeySpeed?: (wpm: number) => void;
|
|
}) {
|
|
const { t } = useI18n();
|
|
const [st, setSt] = useState<YaesuState>(ZERO);
|
|
// The frequency being LISTENED to. RigState follows ADIF, where freq_hz is the
|
|
// TRANSMIT frequency — under split that is the other VFO, so taking it as the
|
|
// main display showed the operator the frequency they transmit on and an
|
|
// offset of 0 kHz against itself.
|
|
const [freqHz, setFreqHz] = useState(0);
|
|
const [txHz, setTxHz] = useState(0);
|
|
const [err, setErr] = useState('');
|
|
|
|
// Optimistic local values for the sliders. Without them a drag fights the
|
|
// poll: the rig's older reading arrives mid-gesture and yanks the thumb back.
|
|
const [local, setLocal] = useState<Partial<YaesuState>>({});
|
|
const localAtRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const tick = async () => {
|
|
try {
|
|
const s = (await GetYaesuState()) as YaesuState;
|
|
const c = await GetCATState();
|
|
if (!alive) return;
|
|
setSt(s);
|
|
const cs = c as any;
|
|
const tx = cs?.freq_hz ?? 0;
|
|
const rx = cs?.split && cs?.freq_rx_hz > 0 ? cs.freq_rx_hz : tx;
|
|
setFreqHz(rx);
|
|
setTxHz(tx);
|
|
// Drop the optimistic overlay once the rig has had time to answer with
|
|
// the new value — 1.2 s covers the slow-beat settings read.
|
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
|
setErr('');
|
|
} catch (e: any) {
|
|
if (alive) setErr(String(e?.message ?? e));
|
|
}
|
|
};
|
|
tick();
|
|
const id = window.setInterval(tick, 400);
|
|
return () => { alive = false; window.clearInterval(id); };
|
|
}, []);
|
|
|
|
const view = { ...st, ...local };
|
|
|
|
// Every setter follows the same shape: show the value at once, remember when,
|
|
// and let the poll take over. A rejected command surfaces as an error rather
|
|
// than as a control that silently springs back.
|
|
function push<K extends keyof YaesuState>(key: K, value: YaesuState[K], fn: () => Promise<void>) {
|
|
setLocal((l) => ({ ...l, [key]: value }));
|
|
localAtRef.current = Date.now();
|
|
fn().catch((e) => setErr(String(e?.message ?? e)));
|
|
}
|
|
|
|
const band = bandOfHz(freqHz);
|
|
// CW changes what belongs on the panel: no microphone, no VOX, but a keyer
|
|
// speed, break-in and ZIN. Driven by the RIG's mode, not the logged one.
|
|
const isCW = (view.raw_mode || '').toUpperCase().startsWith('CW');
|
|
|
|
if (!st.available) {
|
|
return (
|
|
<div className="h-full w-full flex items-center justify-center p-6 text-center">
|
|
<div className="space-y-1">
|
|
<Radio className="size-8 mx-auto text-muted-foreground/50" />
|
|
<p className="text-sm text-muted-foreground">{t('yaesu.notConnected')}</p>
|
|
{err && <p className="text-xs text-destructive">{err}</p>}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
|
{/* Same wrapper as the Icom and Flex panels: capped width, CENTRED. Capping
|
|
it without mx-auto left the console pinned to the left edge with a
|
|
window of empty space beside it. */}
|
|
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
|
{/* VFO + status */}
|
|
<div className="rounded-xl border border-border bg-card shadow-sm px-4 py-3 flex items-center justify-between gap-3 flex-wrap">
|
|
<div>
|
|
<div className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{st.model || 'Yaesu'}</div>
|
|
<div className="text-2xl font-mono tabular-nums font-bold">{fmtVFO(freqHz)}</div>
|
|
{view.split && txHz > 0 && (
|
|
<div className="text-[11px] font-mono tabular-nums text-warning">
|
|
{t('yaesu.txOn')} {fmtVFO(txHz)}
|
|
{freqHz > 0 ? ' (' + (txHz > freqHz ? '+' : '') + Math.round((txHz - freqHz) / 100) / 10 + ' kHz)' : ''}
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{st.transmitting && (
|
|
<span className="px-2 py-1 rounded-md text-[11px] font-bold bg-destructive text-destructive-foreground">TX</span>
|
|
)}
|
|
<Chip on={view.split} onClick={() => push('split', !view.split, () => SetYaesuSplit(!view.split))} label="SPLIT" />
|
|
{/* The usual pile-up offsets. Which one is idiomatic depends on the
|
|
mode — up 5 on phone, up 1 on CW — so both are offered rather than
|
|
guessed, and each turns split on in the same action. */}
|
|
<button type="button" onClick={() => SetYaesuSplitOffset(1000).catch((e) => setErr(String(e?.message ?? e)))}
|
|
title={t('yaesu.splitUpHint')}
|
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+1k</button>
|
|
<button type="button" onClick={() => SetYaesuSplitOffset(5000).catch((e) => setErr(String(e?.message ?? e)))}
|
|
title={t('yaesu.splitUpHint')}
|
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">+5k</button>
|
|
<button type="button" onClick={() => TuneYaesuATU().catch((e) => setErr(String(e?.message ?? e)))}
|
|
title={t('yaesu.tuneHint')}
|
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
|
TUNE
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{err && <p className="text-xs text-destructive px-1">{err}</p>}
|
|
|
|
{/* Meters — the SHARED MeterBar the Flex and Icom panels use, so the three
|
|
consoles read alike instead of each having its own instrument style. */}
|
|
<Card icon={Activity} title={t('yaesu.meters')}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<MeterBar label="S-METER" value={view.s_meter} lo={0} hi={100} accent="#16a34a" segColor={sSegColor}
|
|
display={sParts(view.s_meter).label}
|
|
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
|
title={onReportRST ? t('yaesu.sToRst') : undefined} />
|
|
{/* Watts as MEASURED, not the power setting scaled by a percentage:
|
|
the setting says what was asked for, the meter says what left. */}
|
|
<MeterBar label="PWR" value={view.transmitting ? (view.power_w ?? 0) : 0} unit="W" lo={0} hi={Math.max(100, view.rf_power || 100)} accent="#0ea5e9"
|
|
display={view.transmitting ? Math.round(view.power_w ?? 0) + 'W' : '—'} />
|
|
{/* The RATIO, as the rig shows it — a percentage of meter travel is
|
|
not something an operator can act on. The bar keeps the travel. */}
|
|
<MeterBar label="SWR" value={view.transmitting ? view.swr_meter : 0} lo={0} hi={100} accent="#f59e0b"
|
|
display={view.transmitting ? (view.swr && view.swr >= 1 ? view.swr.toFixed(1) : '1.0') : '—'} />
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Bands + modes */}
|
|
<Card icon={Antenna} title={t('yaesu.bandMode')}>
|
|
<div className="flex flex-wrap gap-1">
|
|
{BANDS.map((b) => (
|
|
<button key={b} type="button"
|
|
onClick={() => SetYaesuBand(b).catch((e) => setErr(String(e?.message ?? e)))}
|
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
|
band === b ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
|
{b.replace('m', '')}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<div className="flex flex-wrap gap-1">
|
|
{MODES.map((m) => {
|
|
const act = activeMode(view.raw_mode);
|
|
const on = act?.id === m.id;
|
|
// The sideband shown is the rig's when this mode is active, else the
|
|
// one the band plan implies — so a button says what pressing it will
|
|
// actually do rather than a stale letter.
|
|
const side: 'U' | 'L' = on && act ? act.side : (freqHz > 0 && freqHz < 10_000_000 ? 'L' : 'U');
|
|
const flip: 'U' | 'L' = side === 'U' ? 'L' : 'U';
|
|
return (
|
|
<button key={m.id} type="button"
|
|
onClick={() => {
|
|
// Already on this mode → the click means "the other sideband".
|
|
const target = m.id === 'SSB'
|
|
? (freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB')
|
|
: m.rig(on && m.sideband ? flip : side);
|
|
SetYaesuModeRaw(target).catch((e) => setErr(String(e?.message ?? e)));
|
|
}}
|
|
title={m.sideband ? t('yaesu.sidebandHint') : undefined}
|
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
|
on ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
|
{m.sideband ? m.label + '-' + side : m.label}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Receive */}
|
|
<Card icon={AudioLines} title={t('yaesu.receive')}>
|
|
<Row label="AF">
|
|
<Slider value={view.af_gain} onChange={(v) => push('af_gain', v, () => SetYaesuAFGain(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.af_gain}</span>
|
|
</Row>
|
|
<Row label="RF">
|
|
<Slider value={view.rf_gain} onChange={(v) => push('rf_gain', v, () => SetYaesuRFGain(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_gain}</span>
|
|
</Row>
|
|
<Row label="SQL">
|
|
<Slider value={view.squelch} onChange={(v) => push('squelch', v, () => SetYaesuSquelch(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.squelch}</span>
|
|
</Row>
|
|
<Row label="AGC">
|
|
<Segmented value={view.agc ?? 'AUTO'} options={AGCS} onChange={(v) => push('agc', v, () => SetYaesuAGC(v))} />
|
|
</Row>
|
|
<Row label="FRONT">
|
|
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
|
</Row>
|
|
<Row label="ATT">
|
|
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* Noise + filter */}
|
|
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
|
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
|
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
|
</div>
|
|
<Row label="DNR">
|
|
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
|
the radio's own display counts 1-15, and matching it is what makes
|
|
the panel readable next to the front panel. */}
|
|
<Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
|
|
onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* Transmit */}
|
|
<Card icon={Mic} title={t('yaesu.transmit')}>
|
|
<Row label="PWR">
|
|
{/* Watts, not a percentage: the rig reports and takes watts, and a
|
|
percentage would be a second unit to reconcile every time. */}
|
|
<Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
|
|
onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
|
|
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
|
</Row>
|
|
{/* Microphone gain and VOX are meaningless in CW — the rig ignores both
|
|
— so they are hidden rather than shown as dead controls. */}
|
|
{!isCW && (
|
|
<>
|
|
<Row label="MIC">
|
|
<Slider value={view.mic_gain} onChange={(v) => push('mic_gain', v, () => SetYaesuMicGain(v))} />
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.mic_gain}</span>
|
|
</Row>
|
|
<Row label="VOX">
|
|
<Chip on={view.vox} onClick={() => push('vox', !view.vox, () => SetYaesuVOX(!view.vox))} label="VOX" />
|
|
</Row>
|
|
</>
|
|
)}
|
|
<Row label="">
|
|
<button type="button" onClick={() => RefreshYaesuPanel().catch((e) => setErr(String(e?.message ?? e)))}
|
|
className="ml-auto px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
|
{t('yaesu.refresh')}
|
|
</button>
|
|
</Row>
|
|
</Card>
|
|
|
|
{/* CW — only in CW, where these replace the phone controls above. */}
|
|
{isCW && (
|
|
<Card icon={Radio} title={t('yaesu.cw')}>
|
|
<Row label="SPEED">
|
|
<Slider value={view.key_speed || 20} min={4} max={60} accent="var(--primary)"
|
|
onChange={(v) => { push('key_speed', v, () => SetYaesuKeySpeed(v)); onKeySpeed?.(v); }} />
|
|
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.key_speed || 20} wpm</span>
|
|
</Row>
|
|
<div className="flex items-center gap-2 flex-wrap">
|
|
<Chip on={!!view.break_in} onClick={() => push('break_in', !view.break_in, () => SetYaesuBreakIn(!view.break_in))}
|
|
label="BK-IN" title={t('yaesu.breakInHint')} />
|
|
{/* ZIN is a one-shot: the rig retunes so the station being received
|
|
lands on the operator's own CW pitch. Not a toggle, so it is a
|
|
plain button rather than a chip that would look latched. */}
|
|
<button type="button" onClick={() => YaesuZeroIn().catch((e) => setErr(String(e?.message ?? e)))}
|
|
title={t('yaesu.zinHint')}
|
|
className="px-2 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
|
ZIN
|
|
</button>
|
|
</div>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|