Files
OpsLog/frontend/src/components/YaesuPanel.tsx
T
rouggy 0848c275d2 fix(yaesu): mic gain scale, 200 W models, and a NAR button that did nothing
Reported from a real FTDX101, and each one is a different kind of wrong.

MIC GAIN was read and written through the 0-255 scale the audio gains
use, but the CAT reference gives MG000-100. A rig set to 80 therefore
showed 38, and moving the slider sent 204 — outside the range the radio
accepts, so it refused the command and the slider sprang back. That
snap-back was the symptom; the scale was the cause.

POWER was capped at 100 W by the slider, not by the radio. There is no
CAT command for 'how much power can you make', so the ceiling comes from
the model name, with the rig believed if it ever reports more than the
table expects — it has just proved what it can do.

NAR is the narrow IF filter, and on a rig that does not implement NA the
button showed a state the radio never gave and did nothing when pressed,
which reads as a fault in the radio. Now shown only when the rig answers,
with a tooltip saying what it is.

Also: the frequency readout steps the Hz digits under the wheel, not just
the kHz ones. Zero-beating a CW signal is a few tens of Hz and it was the
one move the display would not make. And the split chaser is CW-only —
its marker comes from a CW skimmer.
2026-08-24 17:59:13 +02:00

523 lines
27 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, SetYaesuAntenna, 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; antenna: number;
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; narrow_supported?: boolean; vox: boolean;
max_power?: number;
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, antenna: 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' }];
// Three jacks is the most any of these rigs has (FTDX101); an FTDX10 answers
// with 1 or 2 and the third button simply never gets used. A rig with a single
// socket answers nothing at all and reports 0, and the row is not drawn.
const ANTENNAS = [{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }, { v: '3', l: 'ANT3' }];
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>
{/* Only on a rig that HAS a choice: 0 means the AN command went
unanswered, which is what a single-socket FT-891 does. And the
choice is remembered for the band it was made on — no table to
fill in anywhere, the backend learns it here. */}
{view.antenna > 0 && (
<Row label="ANT">
<Segmented value={String(view.antenna)} options={ANTENNAS}
onChange={(v) => push('antenna', parseInt(v, 10), () => SetYaesuAntenna(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" />
{/* NAR is the narrow IF filter. Shown only when the rig ANSWERED NA:
a button that does nothing when pressed reads as a fault in the
radio, which is how it was reported from an FTDX101. */}
{view.narrow_supported && (
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))}
label="NAR" title={t('yaesu.narrowHint')} />
)}
</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={Math.max(100, view.max_power || 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>
);
}