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
+7
View File
@@ -14978,3 +14978,10 @@ func (a *App) yaesuDo(fn func(cat.YaesuController) error) error {
} }
return a.cat.YaesuDo(fn) return a.cat.YaesuDo(fn)
} }
// SetYaesuModeRaw picks an exact rig mode, sideband included ("CW-L", "DATA-U").
// The panel's mode buttons use this rather than SetCATMode, which takes an ADIF
// mode and can only choose a sideband by convention.
func (a *App) SetYaesuModeRaw(mode string) error {
return a.yaesuDo(func(y cat.YaesuController) error { return y.SetYaesuModeRaw(mode) })
}
+2 -2
View File
@@ -3,7 +3,7 @@
"version": "0.21.9", "version": "0.21.9",
"date": "2026-07-28", "date": "2026-07-28",
"en": [ "en": [
"Yaesu control panel: with the native Yaesu backend, a console pane gives S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, ATT, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. Enable it in Settings → Main view panes.", "Yaesu control panel: with the native Yaesu backend, a console tab and Main-view pane give S/PO/SWR meters, band and mode buttons, AF/RF/squelch, AGC, IPO-AMP1-AMP2, a 6/12/18 dB attenuator, NB, DNR, narrow filter, power, mic gain, VOX, split and ATU tune. CW, RTTY, DIGI and PSK show their sideband — double-click a button to switch it.",
"Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.", "Native Xiegu CAT (G90, X6100, X6200, X5105): a new backend talks CI-V straight to the radio — frequency, mode, split and PTT. Pick \"Xiegu (native CI-V)\" in Settings → CAT. Untested on a radio so far, feedback welcome.",
"CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).", "CAT sharing: OpsLog can now serve its rig connection to other programs (Settings → CAT → Share CAT). WSJT-X, JTDX, MSHV or Log4OM connect with rig model \"Hamlib NET rigctl\" at 127.0.0.1:4532 — needed because a native CAT backend holds the radio serial port on its own. Works with every backend, and with both Hamlib command dialects (JTDX names the VFO, MSHV does not).",
"Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.", "Native Yaesu CAT: a new backend talks to an FTDX10/FTDX101 directly over its serial port, without OmniRig — frequency, mode, VFO A/B, split and PTT. Pick \"Yaesu (native CAT)\" in Settings → CAT. First release, feedback welcome.",
@@ -16,7 +16,7 @@
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes." "CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
], ],
"fr": [ "fr": [
"Panneau de contrôle Yaesu : avec le backend Yaesu natif, un volet console offre les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, ATT, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. À activer dans Réglages → Volets de la vue principale.", "Panneau de contrôle Yaesu : avec le backend Yaesu natif, un onglet console et un volet de la vue principale offrent les mesures S/PO/ROS, les boutons de bande et de mode, AF/RF/squelch, AGC, IPO-AMP1-AMP2, un atténuateur 6/12/18 dB, NB, DNR, filtre étroit, puissance, gain micro, VOX, split et accord d'antenne. CW, RTTY, DIGI et PSK affichent leur bande latérale — double-cliquez pour en changer.",
"CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.", "CAT Xiegu natif (G90, X6100, X6200, X5105) : un nouveau backend dialogue en CI-V directement avec la radio — fréquence, mode, split et PTT. À choisir dans Réglages → CAT sous « Xiegu (CI-V natif) ». Pas encore testé sur une radio, retours bienvenus.",
"Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).", "Partage du CAT : OpsLog peut désormais servir sa liaison radio aux autres logiciels (Réglages → CAT → Partager le CAT). WSJT-X, JTDX, MSHV ou Log4OM s'y connectent avec le modèle « Hamlib NET rigctl » sur 127.0.0.1:4532 — nécessaire car un backend CAT natif occupe seul le port série. Fonctionne avec tous les backends, et avec les deux dialectes Hamlib (JTDX nomme le VFO, MSHV non).",
"CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.", "CAT Yaesu natif : un nouveau backend dialogue directement avec un FTDX10/FTDX101 par son port série, sans OmniRig — fréquence, mode, VFO A/B, split et PTT. À choisir dans Réglages → CAT sous « Yaesu (CAT natif) ». Première version, retours bienvenus.",
+8
View File
@@ -5373,6 +5373,7 @@ export default function App() {
)} )}
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>} {catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>} {catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
{catState.backend === 'yaesu' && <TabsTrigger value="yaesu">Yaesu</TabsTrigger>}
{statsTabOpen && ( {statsTabOpen && (
<TabsTrigger value="stats" className="gap-1.5"> <TabsTrigger value="stats" className="gap-1.5">
{t('stats.tab')} {t('stats.tab')}
@@ -5788,6 +5789,13 @@ export default function App() {
{/* Icom CI-V receive-DSP control panel only when the CAT backend {/* Icom CI-V receive-DSP control panel only when the CAT backend
is an Icom. */} 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' && ( {catState.backend === 'icom' && (
<TabsContent value="icom" className="flex-1 min-h-0 p-0"> <TabsContent value="icom" className="flex-1 min-h-0 p-0">
<IcomPanel isNetwork={catBackend === 'icom-net'} onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> <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, SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel, SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU, SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
GetCATState, SetCATMode, SetYaesuModeRaw, GetCATState,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n'; import { useI18n } from '@/lib/i18n';
import { sMeterRST } from '@/lib/rst'; import { sMeterRST } from '@/lib/rst';
type YaesuState = { type YaesuState = {
available: boolean; model?: string; mode?: string; available: boolean; model?: string; mode?: string; raw_mode?: string;
transmitting: boolean; split: boolean; transmitting: boolean; split: boolean;
s_meter: number; power_meter: number; swr_meter: number; s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: 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. // 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 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: // 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 // 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. // 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 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' }]; 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 { function fmtVFO(hz?: number): string {
if (!hz || hz <= 0) return '––.–––.––'; if (!hz || hz <= 0) return '––.–––.––';
@@ -60,13 +95,6 @@ function bandOfHz(hz?: number): string {
return ''; 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. // 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}` }; return { s, over: 0, label: `S${s}` };
} }
function Slider({ value, onChange, disabled, accent = '#2563eb' }: { function Slider({ value, onChange, disabled, accent = 'var(--primary)', min = 0, max = 100 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; 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); const ref = useRef<HTMLInputElement>(null);
// React's onWheel is passive, so preventDefault is ignored there — attach a // React's onWheel is passive, so preventDefault is ignored there — attach a
// native non-passive listener, and read live values through refs so the // 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 valRef = useRef(value); valRef.current = value;
const cbRef = useRef(onChange); cbRef.current = onChange; const cbRef = useRef(onChange); cbRef.current = onChange;
const disRef = useRef(disabled); disRef.current = disabled; const disRef = useRef(disabled); disRef.current = disabled;
const minRef = useRef(min); minRef.current = min;
const maxRef = useRef(max); maxRef.current = max;
useEffect(() => { useEffect(() => {
const el = ref.current; const el = ref.current;
if (!el) return; if (!el) return;
const onWheel = (e: WheelEvent) => { const onWheel = (e: WheelEvent) => {
if (disRef.current) return; if (disRef.current) return;
e.preventDefault(); 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); if (nv !== valRef.current) cbRef.current(nv);
}; };
el.addEventListener('wheel', onWheel, { passive: false }); el.addEventListener('wheel', onWheel, { passive: false });
@@ -109,12 +140,19 @@ function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
return ( return (
<input <input
ref={ref} 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))} 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', 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-3.5 [&::-webkit-slider-thumb]:rounded-full', '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-4 [&::-webkit-slider-thumb]:rounded-full',
'[&::-webkit-slider-thumb]:bg-card [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')} '[&::-webkit-slider-thumb]:bg-background [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow',
style={{ background: `linear-gradient(to right, ${accent} ${v}%, var(--muted))`, borderColor: accent }} '[&::-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 }: { function Meter({ label, value, accent, text, onClick, title }: {
label: string; value: number; accent: string; onClick?: () => void; title?: string; label: string; value: number; accent: string; text?: string; onClick?: () => void; title?: string;
}) { }) {
const v = Math.max(0, Math.min(100, value)); const v = Math.max(0, Math.min(100, value));
const body = ( 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="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 className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div> </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) { if (onClick) {
@@ -244,7 +284,8 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
} }
return ( 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 */} {/* 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 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>
@@ -268,7 +309,7 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
{/* Meters */} {/* Meters */}
<Card icon={Activity} title={t('yaesu.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} onClick={onReportRST ? () => { const sp = sParts(view.s_meter); onReportRST(sMeterRST(sp.s, sp.over, view.mode)); } : undefined}
title={t('yaesu.sToRst')} /> title={t('yaesu.sToRst')} />
<Meter label="PO" value={view.power_meter} accent="var(--success, #16a34a)" /> <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>
<div className="flex flex-wrap gap-1"> <div className="flex flex-wrap gap-1">
{MODES.map((m) => ( {MODES.map((m) => {
<button key={m} type="button" const act = activeMode(view.raw_mode);
onClick={() => SetCATMode(m).catch((e) => setErr(String(e?.message ?? e)))} const on = act?.id === m.id;
className={cn('px-2 py-1 rounded-md text-[11px] font-bold border transition-colors', // The sideband shown is the rig's when this mode is active, else the
modeMatches(m, view.mode) ? 'bg-primary border-primary text-primary-foreground' : 'bg-card text-muted-foreground border-border hover:bg-muted')}> // one the band plan implies — so a button says what pressing it will
{m} // actually do rather than a stale letter.
</button> 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> </div>
</Card> </Card>
@@ -318,7 +370,9 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
</Row> </Row>
<Row label="FRONT"> <Row label="FRONT">
<Segmented value={String(view.preamp)} options={PREAMPS} onChange={(v) => push('preamp', parseInt(v, 10), () => SetYaesuPreamp(parseInt(v, 10)))} /> <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> </Row>
</Card> </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: {/* 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 radio's own display counts 1-15, and matching it is what makes
the panel readable next to the front panel. */} the panel readable next to the front panel. */}
<input type="range" min={1} max={15} value={view.nr_level || 1} disabled={!view.nr} <Slider value={view.nr_level || 1} min={1} max={15} disabled={!view.nr}
onChange={(e) => { const v = parseInt(e.target.value, 10); push('nr_level', v, () => SetYaesuNRLevel(v)); }} onChange={(v) => push('nr_level', v, () => SetYaesuNRLevel(v))} />
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 accent-primary" />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span> <span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.nr_level || 1}</span>
</Row> </Row>
</Card> </Card>
@@ -345,9 +398,8 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
<Row label="PWR"> <Row label="PWR">
{/* Watts, not a percentage: the rig reports and takes watts, and a {/* Watts, not a percentage: the rig reports and takes watts, and a
percentage would be a second unit to reconcile every time. */} percentage would be a second unit to reconcile every time. */}
<input type="range" min={5} max={100} value={view.rf_power || 5} <Slider value={view.rf_power || 5} min={5} max={100} accent="var(--destructive)"
onChange={(e) => { const v = parseInt(e.target.value, 10); push('rf_power', v, () => SetYaesuPower(v)); }} onChange={(v) => push('rf_power', v, () => SetYaesuPower(v))} />
className="flex-1 h-1.5 rounded-full appearance-none cursor-pointer accent-primary" />
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span> <span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
</Row> </Row>
<Row label="MIC"> <Row label="MIC">
@@ -362,6 +414,7 @@ export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => voi
</button> </button>
</Row> </Row>
</Card> </Card>
</div>
</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.map1': 'Map — great-circle + beam', 'settings.pane.map2': 'Map — locator (street)',
'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before', 'settings.pane.cluster': 'Cluster spots', 'settings.pane.worked': 'Worked before',
'settings.pane.recent': 'Recent QSOs', 'settings.pane.netcontrol': 'Net control', '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.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.light-sage': 'Sage light', 'theme.dim-slate': 'Dim slate', 'theme.dark-warm': 'Warm dark',
'theme.dark-graphite': 'Graphite dark', 'theme.high-contrast': 'High contrast', '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.map1': 'Carte — orthodromie + faisceau', 'settings.pane.map2': 'Carte — locator (rue)',
'settings.pane.cluster': 'Spots cluster', 'settings.pane.worked': 'Déjà contactés', '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.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.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.light-sage': 'Clair sauge', 'theme.dim-slate': 'Ardoise tamisé', 'theme.dark-warm': 'Sombre chaud',
'theme.dark-graphite': 'Sombre graphite', 'theme.high-contrast': 'Contraste élevé', '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 SetYaesuMicGain(arg1:number):Promise<void>;
export function SetYaesuModeRaw(arg1:string):Promise<void>;
export function SetYaesuNB(arg1:boolean):Promise<void>; export function SetYaesuNB(arg1:boolean):Promise<void>;
export function SetYaesuNR(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); return window['go']['main']['App']['SetYaesuMicGain'](arg1);
} }
export function SetYaesuModeRaw(arg1) {
return window['go']['main']['App']['SetYaesuModeRaw'](arg1);
}
export function SetYaesuNB(arg1) { export function SetYaesuNB(arg1) {
return window['go']['main']['App']['SetYaesuNB'](arg1); return window['go']['main']['App']['SetYaesuNB'](arg1);
} }
+2
View File
@@ -1078,6 +1078,7 @@ export namespace cat {
available: boolean; available: boolean;
model?: string; model?: string;
mode?: string; mode?: string;
raw_mode?: string;
transmitting: boolean; transmitting: boolean;
split: boolean; split: boolean;
s_meter: number; s_meter: number;
@@ -1106,6 +1107,7 @@ export namespace cat {
this.available = source["available"]; this.available = source["available"];
this.model = source["model"]; this.model = source["model"];
this.mode = source["mode"]; this.mode = source["mode"];
this.raw_mode = source["raw_mode"];
this.transmitting = source["transmitting"]; this.transmitting = source["transmitting"];
this.split = source["split"]; this.split = source["split"];
this.s_meter = source["s_meter"]; this.s_meter = source["s_meter"];
+3
View File
@@ -212,6 +212,9 @@ func (y *Yaesu) ReadState() (RigState, error) {
y.curFreq = s.FreqHz y.curFreq = s.FreqHz
if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 { if r, err := y.ask("MD0;"); err == nil && len(r) >= 4 {
// Keep the RAW mode too: ADIF folds CW-U/CW-L and DATA-U/DATA-L together,
// but the panel has to show which sideband the rig is actually on.
y.panel.RawMode = yaesuRawModeName(r[3])
if m, ok := yaesuModeToADIF[r[3]]; ok { if m, ok := yaesuModeToADIF[r[3]]; ok {
if m == "DATA" { if m == "DATA" {
m = y.digital m = y.digital
+92 -9
View File
@@ -28,6 +28,9 @@ type YaesuTXState struct {
Available bool `json:"available"` Available bool `json:"available"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
// RawMode is the rig mode with its sideband (CW-U, DATA-L…), which ADIF
// deliberately does not record but the panel has to show.
RawMode string `json:"raw_mode,omitempty"`
Transmitting bool `json:"transmitting"` Transmitting bool `json:"transmitting"`
Split bool `json:"split"` Split bool `json:"split"`
@@ -70,6 +73,7 @@ type YaesuController interface {
SetYaesuVOX(bool) error SetYaesuVOX(bool) error
SetYaesuSplit(bool) error SetYaesuSplit(bool) error
SetYaesuBand(string) error SetYaesuBand(string) error
SetYaesuModeRaw(string) error
TuneYaesuATU() error TuneYaesuATU() error
} }
@@ -276,6 +280,46 @@ func (y *Yaesu) SetYaesuSplit(on bool) error {
// SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used // SetYaesuBand switches band with BS, which lands the rig on ITS OWN last-used
// frequency for that band — the radio's band memory, not a frequency we choose. // frequency for that band — the radio's band memory, not a frequency we choose.
// SetYaesuModeRaw selects an exact rig mode, sideband included — "CW-U",
// "DATA-L", "RTTY-U"… The plain SetMode path takes an ADIF mode and can only
// choose a sideband by convention, but CW, RTTY and the data modes are routinely
// run on EITHER sideband and the operator is the one who knows which. This is
// what the panel's mode buttons use.
func (y *Yaesu) SetYaesuModeRaw(name string) error {
d, ok := yaesuRawModeDigit(name)
if !ok {
return fmt.Errorf("yaesu: unknown rig mode %q", name)
}
return y.setAndRefresh(fmt.Sprintf("MD0%c;", d))
}
// yaesuRawModeDigit maps a rig-mode name to its MD digit.
func yaesuRawModeDigit(name string) (byte, bool) {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "LSB":
return '1', true
case "USB":
return '2', true
case "CW-U", "CWU":
return '3', true
case "CW-L", "CWL":
return '7', true
case "FM":
return '4', true
case "AM":
return '5', true
case "RTTY-L", "RTTYL":
return '6', true
case "RTTY-U", "RTTYU":
return '9', true
case "DATA-L", "DATAL", "DIGI-L":
return '8', true
case "DATA-U", "DATAU", "DIGI-U":
return 'C', true
}
return 0, false
}
func (y *Yaesu) SetYaesuBand(band string) error { func (y *Yaesu) SetYaesuBand(band string) error {
code, ok := yaesuBandCode(band) code, ok := yaesuBandCode(band)
if !ok { if !ok {
@@ -357,21 +401,32 @@ func yaesuAGCCode(name string) int {
return 4 // AUTO — the safe default for anything unrecognised return 4 // AUTO — the safe default for anything unrecognised
} }
// The FTDX10's attenuator is a single 12 dB step (RA00/RA01). Reporting the dB // The FTDX10/FTDX101 attenuator is a THREE-step pad (RA00..RA03 = off, 6, 12,
// rather than the raw code lets the panel label the button honestly, and leaves // 18 dB), not the single step this first assumed — a panel offering only one
// room for models with more steps. // step hides two thirds of the control. Reporting the dB rather than the raw
// code lets the buttons label themselves honestly.
func yaesuAttDB(code int) int { func yaesuAttDB(code int) int {
if code == 0 { switch code {
return 0 case 1:
return 6
case 2:
return 12
case 3:
return 18
} }
return 12 return 0
} }
func yaesuAttCode(db int) int { func yaesuAttCode(db int) int {
if db <= 0 { switch {
return 0 case db >= 18:
return 3
case db >= 12:
return 2
case db >= 6:
return 1
} }
return 1 return 0
} }
// yaesuBandCode maps an ADIF band to the BS command's band number. // yaesuBandCode maps an ADIF band to the BS command's band number.
@@ -402,3 +457,31 @@ func yaesuBandCode(band string) (int, bool) {
} }
return 0, false return 0, false
} }
// yaesuRawModeName is the inverse of yaesuRawModeDigit: what the rig is on,
// sideband included, for the panel's mode buttons to highlight.
func yaesuRawModeName(d byte) string {
switch d {
case '1':
return "LSB"
case '2':
return "USB"
case '3':
return "CW-U"
case '4':
return "FM"
case '5':
return "AM"
case '6':
return "RTTY-L"
case '7':
return "CW-L"
case '8':
return "DATA-L"
case '9':
return "RTTY-U"
case 'C':
return "DATA-U"
}
return ""
}