|
|
|
@@ -5,14 +5,14 @@ import {
|
|
|
|
|
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
|
|
|
|
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
|
|
|
|
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
|
|
|
|
GetCATState, SetCATMode,
|
|
|
|
|
SetYaesuModeRaw, GetCATState,
|
|
|
|
|
} from '../../wailsjs/go/main/App';
|
|
|
|
|
import { cn } from '@/lib/utils';
|
|
|
|
|
import { useI18n } from '@/lib/i18n';
|
|
|
|
|
import { sMeterRST } from '@/lib/rst';
|
|
|
|
|
|
|
|
|
|
type YaesuState = {
|
|
|
|
|
available: boolean; model?: string; mode?: string;
|
|
|
|
|
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;
|
|
|
|
@@ -32,13 +32,48 @@ const ZERO: YaesuState = {
|
|
|
|
|
// 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'];
|
|
|
|
|
|
|
|
|
|
const MODES = ['SSB', 'CW', 'RTTY', 'FT8', 'AM', 'FM'];
|
|
|
|
|
// 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 carries its
|
|
|
|
|
// current sideband and DOUBLE-CLICK flips it. SSB picks 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 '––.–––.––';
|
|
|
|
@@ -60,13 +95,6 @@ function bandOfHz(hz?: number): string {
|
|
|
|
|
return '';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function modeMatches(btn: string, cur?: string): boolean {
|
|
|
|
|
if (!cur) return false;
|
|
|
|
|
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
|
|
|
|
if (btn === 'FT8') return cur === 'FT8' || cur === 'FT4' || cur === 'DATA';
|
|
|
|
|
return btn === cur;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// Split the 0-100 S-meter reading into S units + dB over S9.
|
|
|
|
|
//
|
|
|
|
@@ -83,10 +111,11 @@ function sParts(v: number): { s: number; over: number; label: string } {
|
|
|
|
|
return { s, over: 0, label: `S${s}` };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
|
|
|
|
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
|
|
|
|
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(0, Math.min(100, value));
|
|
|
|
|
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
|
|
|
|
@@ -94,13 +123,15 @@ function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
|
|
|
|
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(0, Math.min(100, valRef.current + (e.deltaY < 0 ? 1 : -1)));
|
|
|
|
|
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 });
|
|
|
|
@@ -109,12 +140,19 @@ function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
|
|
|
|
return (
|
|
|
|
|
<input
|
|
|
|
|
ref={ref}
|
|
|
|
|
type="range" min={0} max={100} value={v} disabled={disabled}
|
|
|
|
|
type="range" min={min} max={max} value={v} disabled={disabled}
|
|
|
|
|
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
|
|
|
|
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
|
|
|
|
'[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full',
|
|
|
|
|
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')}
|
|
|
|
|
style={{ background: `linear-gradient(to right, ${accent} ${v}%, var(--muted))`, borderColor: accent }}
|
|
|
|
|
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,
|
|
|
|
|
}}
|
|
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
@@ -166,8 +204,8 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function Meter({ label, value, accent, onClick, title }: {
|
|
|
|
|
label: string; value: number; accent: string; onClick?: () => void; title?: string;
|
|
|
|
|
function Meter({ label, value, accent, text, onClick, title }: {
|
|
|
|
|
label: string; value: number; accent: string; text?: string; onClick?: () => void; title?: string;
|
|
|
|
|
}) {
|
|
|
|
|
const v = Math.max(0, Math.min(100, value));
|
|
|
|
|
const body = (
|
|
|
|
@@ -176,7 +214,9 @@ function Meter({ label, value, accent, onClick, title }: {
|
|
|
|
|
<div className="flex-1 h-2.5 rounded-full bg-muted/60 overflow-hidden">
|
|
|
|
|
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
|
|
|
|
|
</div>
|
|
|
|
|
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{v}</span>
|
|
|
|
|
{/* The S meter reads in S UNITS, not a percentage — "57" told the operator
|
|
|
|
|
nothing, and it is the S number that goes into a report. */}
|
|
|
|
|
<span className="w-14 text-right text-xs font-mono tabular-nums text-muted-foreground">{text ?? v}</span>
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
if (onClick) {
|
|
|
|
@@ -244,7 +284,8 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
<div className="h-full w-full overflow-auto p-3 space-y-3">
|
|
|
|
|
<div className="h-full w-full overflow-auto p-3">
|
|
|
|
|
<div className="max-w-[560px] 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>
|
|
|
|
@@ -268,7 +309,7 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
|
|
|
|
|
{/* Meters */}
|
|
|
|
|
<Card icon={Activity} title={t('yaesu.meters')}>
|
|
|
|
|
<Meter label="S" value={view.s_meter} accent="var(--chart-1, #2563eb)"
|
|
|
|
|
<Meter label="S" value={view.s_meter} accent="var(--chart-1, #2563eb)" text={sParts(view.s_meter).label}
|
|
|
|
|
onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
|
|
|
|
|
title={t('yaesu.sToRst')} />
|
|
|
|
|
<Meter label="PO" value={view.power_meter} accent="var(--success, #16a34a)" />
|
|
|
|
@@ -288,14 +329,25 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
))}
|
|
|
|
|
</div>
|
|
|
|
|
<div className="flex flex-wrap gap-1">
|
|
|
|
|
{MODES.map((m) => (
|
|
|
|
|
<button key={m} type="button"
|
|
|
|
|
onClick={() => SetCATMode(m).catch((e) => setErr(String(e?.message ?? e)))}
|
|
|
|
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
|
|
|
|
modeMatches(m, view.mode) ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
|
|
|
|
{m}
|
|
|
|
|
</button>
|
|
|
|
|
))}
|
|
|
|
|
{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={() => { if (m.id === 'SSB') { SetYaesuModeRaw(freqHz > 0 && freqHz < 10_000_000 ? 'LSB' : 'USB').catch((e) => setErr(String(e?.message ?? e))); } else { SetYaesuModeRaw(m.rig(side)).catch((e) => setErr(String(e?.message ?? e))); } }}
|
|
|
|
|
onDoubleClick={() => { if (m.sideband) SetYaesuModeRaw(m.rig(flip)).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>
|
|
|
|
|
|
|
|
|
@@ -318,7 +370,9 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
</Row>
|
|
|
|
|
<Row label="FRONT">
|
|
|
|
|
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} />
|
|
|
|
|
<Chip on={view.att > 0} onClick={() => push('att', view.att > 0 ? 0 : 12, () => SetYaesuAtt(view.att > 0 ? 0 : 12))} label="ATT" />
|
|
|
|
|
</Row>
|
|
|
|
|
<Row label="ATT">
|
|
|
|
|
<Segmented value={String(view.att)} options={ATTS} onChange={(v) => push('att', parseInt(v, 10), () => SetYaesuAtt(parseInt(v, 10)))} />
|
|
|
|
|
</Row>
|
|
|
|
|
</Card>
|
|
|
|
|
|
|
|
|
@@ -333,9 +387,8 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
{/* 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. */}
|
|
|
|
|
<input type="range" min={1} max={15} value={view.nr_level || 1} disabled={!view.nr}
|
|
|
|
|
onChange={(e) => { const v = parseInt(e.target.value, 10); push('nr_level', v, () => SetYaesuNRLevel(v)); }}
|
|
|
|
|
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 accent-primary" />
|
|
|
|
|
<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>
|
|
|
|
@@ -345,9 +398,8 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
<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. */}
|
|
|
|
|
<input type="range" min={5} max={100} value={view.rf_power || 5}
|
|
|
|
|
onChange={(e) => { const v = parseInt(e.target.value, 10); push('rf_power', v, () => SetYaesuPower(v)); }}
|
|
|
|
|
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer accent-primary" />
|
|
|
|
|
<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>
|
|
|
|
|
<Row label="MIC">
|
|
|
|
@@ -362,6 +414,7 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
|
|
|
|
|
</button>
|
|
|
|
|
</Row>
|
|
|
|
|
</Card>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|