fix: Yaesu panel — S units, sidebands, three-step ATT, readable sliders, own tab

First look on the FTDX10 turned up six things:

The S meter printed a raw percentage — "57" tells an operator nothing, and it is
the S number that goes into a report. It now reads S1-S9/S9+dB, the same value
the click-to-fill RST already used.

CW, RTTY and the data modes exist on BOTH sidebands and the operator is the one
who knows which they want. The buttons now carry the rig's actual sideband and a
double-click flips it; PSK is added, riding the rig's DATA mode as it does on the
radio itself. This also means the mode row drives the rig directly (MD0 with the
exact mode) instead of going through the ADIF path, which could only pick a
sideband by convention.

The attenuator is a 6/12/18 dB pad on these rigs, not the single step I assumed —
two thirds of the control was unreachable.

Sliders had no visible filled side: --muted is barely lighter than the card it
sits on, so the whole track read as one bar. They also came in three kinds (two
bare range inputs among them). One component now, explicit track colour, and it
takes a min/max so power in watts and DNR 1-15 look like the rest.

The panel stretched across the whole window; it is a column of controls, so it is
now capped and every row stays readable.

And it gets its own Yaesu tab, like FlexRadio and Icom, rather than only being
available as a Main-view pane.
This commit is contained in:
2026-07-29 11:37:18 +02:00
parent 842d4708a7
commit 9bc5a14c69
10 changed files with 214 additions and 52 deletions
+8
View File
@@ -5373,6 +5373,7 @@ export default function App() {
)}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu</TabsTrigger>}
{statsTabOpen && (
<TabsTrigger value="stats" className="gap-1.5">
{t('stats.tab')}
@@ -5788,6 +5789,13 @@ export default function App() {
{/* Icom CI-V receive-DSP control panel only when the CAT backend
is an Icom. */}
{/* Yaesu CAT control panel — only when the CAT backend is a Yaesu. */}
{catState.backend === 'yaesu' && (
<TabsContent value="yaesu" className="flex-1 min-h-0 p-0">
<YaesuPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</TabsContent>
)}
{catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
+92 -39
View File
@@ -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>
);
}
+2 -2
View File
@@ -106,7 +106,7 @@ const en: Dict = {
'settings.pane.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control',
'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console', 'settings.pane.yaesu': 'Yaesu console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent',
'settings.pane.flex': 'FlexRadio controls', 'settings.pane.icom': 'Icom console', 'settings.pane.yaesu': 'Yaesu console', 'yaesu.notConnected': 'Yaesu CAT not connected', 'yaesu.meters': 'Meters', 'yaesu.bandMode': 'Band & mode', 'yaesu.receive': 'Receive', 'yaesu.noiseFilter': 'Noise & filter', 'yaesu.transmit': 'Transmit', 'yaesu.refresh': 'Refresh', 'yaesu.tuneHint': 'Start an antenna-tuner cycle', 'yaesu.sToRst': 'Click to fill the RST sent', 'yaesu.sidebandHint': 'Click to select this mode, double-click to switch sideband (U/L)',
'theme.auto': 'Auto (system)', 'theme.light-warm': 'Warm light', 'theme.light-cool': 'Cool light',
'theme.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast',
@@ -518,7 +518,7 @@ const fr: Dict = {
'settings.pane.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés',
'settings.pane.recent': 'QSO récents', 'settings.pane.netcontrol': 'Gestion de net',
'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom', 'settings.pane.yaesu': 'Console Yaesu', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé',
'settings.pane.flex': 'Contrôles FlexRadio', 'settings.pane.icom': 'Console Icom', 'settings.pane.yaesu': 'Console Yaesu', 'yaesu.notConnected': 'CAT Yaesu non connecté', 'yaesu.meters': 'Mesures', 'yaesu.bandMode': 'Bande et mode', 'yaesu.receive': 'Réception', 'yaesu.noiseFilter': 'Bruit et filtre', 'yaesu.transmit': 'Émission', 'yaesu.refresh': 'Actualiser', 'yaesu.tuneHint': "Lancer un cycle d'accord d'antenne", 'yaesu.sToRst': 'Cliquer pour remplir le RST envoyé', 'yaesu.sidebandHint': 'Cliquer pour choisir ce mode, double-cliquer pour changer de bande latérale (U/L)',
'theme.auto': 'Auto (système)', 'theme.light-warm': 'Clair chaud', 'theme.light-cool': 'Clair froid',
'theme.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé',
+2
View File
@@ -925,6 +925,8 @@ export function SetYaesuBand(arg1:string):Promise<void>;
export function SetYaesuMicGain(arg1:number):Promise<void>;
export function SetYaesuModeRaw(arg1:string):Promise<void>;
export function SetYaesuNB(arg1:boolean):Promise<void>;
export function SetYaesuNR(arg1:boolean):Promise<void>;
+4
View File
@@ -1798,6 +1798,10 @@ export function SetYaesuMicGain(arg1) {
return window['go']['main']['App']['SetYaesuMicGain'](arg1);
}
export function SetYaesuModeRaw(arg1) {
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
}
export function SetYaesuNB(arg1) {
return window['go']['main']['App']['SetYaesuNB'](arg1);
}
+2
View File
@@ -1078,6 +1078,7 @@ export namespace cat {
available: boolean;
model?: string;
mode?: string;
raw_mode?: string;
transmitting: boolean;
split: boolean;
s_meter: number;
@@ -1106,6 +1107,7 @@ export namespace cat {
this.available = source["available"];
this.model = source["model"];
this.mode = source["mode"];
this.raw_mode = source["raw_mode"];
this.transmitting = source["transmitting"];
this.split = source["split"];
this.s_meter = source["s_meter"];