feat: Yaesu control panel (meters, bands, DSP, TX), and drop a stale Flex hint
A console pane for the native Yaesu backend, in the same shape as the Icom and Flex ones: S/PO/SWR meters, band and mode rows, AF/RF/squelch, AGC, the IPO/AMP1/ AMP2 front-end selector, ATT, NB, DNR + level, narrow filter, power in watts, mic gain, VOX, split and ATU tune. Three decisions worth keeping: Panel reads are STAGGERED and live in their own file, away from ReadState. Meters poll every cycle; settings only change when someone turns a knob, so they refresh every 8th cycle and right after any set. Polling all of it every cycle would put twenty queries a second on the serial link the frequency display shares. Band buttons use the rig's own band memory (BS) rather than a frequency we pick, so 20 m lands where the operator last was on 20 m — what the radio's own band keys do. A set is followed by a read-back, so the panel shows what the RIG ended up with, not what we asked for; the two differ whenever a value is out of range or the mode forbids the control. And a control the model lacks keeps its previous value instead of dropping to zero, which reads as a setting that reset itself. The S9 point of the S-meter scale is a hypothesis (the manual does not state it) and is commented as such — one number to correct if reports come out an S unit off, rather than a fudge spread through the RST helper. Also removes the FlexRadio settings blurb, which explained the backend to someone who had already chosen it.
This commit is contained in:
@@ -156,6 +156,7 @@ interface Props {
|
||||
onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update
|
||||
flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane
|
||||
icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane
|
||||
yaesuAvailable?: boolean; // CAT backend is Yaesu → offer the Yaesu console as a Main pane
|
||||
}
|
||||
|
||||
// Pretty little card showing what OpsLog will stamp on each QSO based on
|
||||
@@ -817,7 +818,7 @@ function RelayAutoPanel() {
|
||||
// cluster grid or the worked-before grid. Per-profile (stored via SetUIPref,
|
||||
// which is profile-prefixed). Self-contained so it owns its async-loaded state.
|
||||
const MAIN_PANE_VALUES = ['map1', 'map2', 'cluster', 'worked', 'recent', 'netcontrol'];
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) {
|
||||
function MainViewPanes({ onChanged, flexAvailable, icomAvailable, yaesuAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean; yaesuAvailable?: boolean }) {
|
||||
const { t } = useI18n();
|
||||
const [left, setLeft] = useState('map1');
|
||||
const [right, setRight] = useState('map2');
|
||||
@@ -826,10 +827,11 @@ function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?
|
||||
...MAIN_PANE_VALUES,
|
||||
...(flexAvailable ? ['flex'] : []),
|
||||
...(icomAvailable ? ['icom'] : []),
|
||||
...(yaesuAvailable ? ['yaesu'] : []),
|
||||
].map((value) => ({ value, label: t(`settings.pane.${value}`) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
useEffect(() => {
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_VALUES.includes(v);
|
||||
const valid = (v: string) => v === 'flex' || v === 'icom' || v === 'yaesu' || MAIN_PANE_VALUES.includes(v);
|
||||
Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')])
|
||||
.then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); });
|
||||
}, []);
|
||||
@@ -1024,7 +1026,7 @@ const ICOM_MODELS: { name: string; addr: number }[] = [
|
||||
{ name: 'IC-9700', addr: 0xA2 },
|
||||
];
|
||||
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) {
|
||||
export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable, yaesuAvailable }: Props) {
|
||||
const { t } = useI18n();
|
||||
const [selected, setSelected] = useState<SectionId>((initialSection as SectionId) || 'station');
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -2664,11 +2666,6 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'flex' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t('cat.flexHint')}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
@@ -5133,7 +5130,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</label>
|
||||
<TelemetryToggle />
|
||||
|
||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
|
||||
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} yaesuAvailable={yaesuAvailable} />
|
||||
|
||||
<div className="border-t border-border/60 pt-4 space-y-2">
|
||||
<h4 className="text-sm font-semibold text-foreground">{t('gen.pwEnc')}</h4>
|
||||
|
||||
@@ -0,0 +1,367 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna } from 'lucide-react';
|
||||
import {
|
||||
GetYaesuState, RefreshYaesuPanel,
|
||||
SetYaesuPower, SetYaesuMicGain, SetYaesuAFGain, SetYaesuRFGain, SetYaesuSquelch,
|
||||
SetYaesuAGC, SetYaesuPreamp, SetYaesuAtt, SetYaesuNB, SetYaesuNR, SetYaesuNRLevel,
|
||||
SetYaesuNarrow, SetYaesuVOX, SetYaesuSplit, SetYaesuBand, TuneYaesuATU,
|
||||
GetCATState, SetCATMode,
|
||||
} 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;
|
||||
transmitting: boolean; split: boolean;
|
||||
s_meter: number; power_meter: number; swr_meter: number;
|
||||
rf_power: number; mic_gain: number; af_gain: number; rf_gain: number; squelch: number;
|
||||
agc?: string; preamp: number; att: number;
|
||||
nb: boolean; nr: boolean; nr_level: number; narrow: boolean; vox: boolean;
|
||||
};
|
||||
|
||||
const ZERO: YaesuState = {
|
||||
available: false, transmitting: false, split: false,
|
||||
s_meter: 0, power_meter: 0, swr_meter: 0,
|
||||
rf_power: 0, mic_gain: 0, af_gain: 0, rf_gain: 0, squelch: 0,
|
||||
preamp: 0, att: 0, nb: false, nr: false, nr_level: 0, narrow: false, vox: false,
|
||||
};
|
||||
|
||||
// Band buttons use the rig's OWN band memory (CAT "BS"), not a frequency we
|
||||
// choose: pressing 20 m lands where the operator last was on 20 m, which is what
|
||||
// the radio's own band keys do. That is why these are band names, not Hz.
|
||||
const BANDS = ['160m', '80m', '40m', '30m', '20m', '17m', '15m', '12m', '10m', '6m'];
|
||||
|
||||
const MODES = ['SSB', 'CW', 'RTTY', 'FT8', 'AM', 'FM'];
|
||||
|
||||
// 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' }];
|
||||
|
||||
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 '';
|
||||
}
|
||||
|
||||
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.
|
||||
//
|
||||
// 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.
|
||||
function sParts(v: number): { s: number; over: number; label: string } {
|
||||
if (v >= 50) {
|
||||
const over = Math.max(0, Math.round((v - 50) * 60 / 50));
|
||||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||||
}
|
||||
const s = Math.max(0, Math.min(9, Math.round(v / 5.5)));
|
||||
return { s, over: 0, label: `S${s}` };
|
||||
}
|
||||
|
||||
function Slider({ value, onChange, disabled, accent = '#2563eb' }: {
|
||||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
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;
|
||||
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)));
|
||||
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={0} max={100} 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 }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function Meter({ label, value, accent, onClick, title }: {
|
||||
label: string; value: number; accent: string; onClick?: () => void; title?: string;
|
||||
}) {
|
||||
const v = Math.max(0, Math.min(100, value));
|
||||
const body = (
|
||||
<>
|
||||
<span className="w-9 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
if (onClick) {
|
||||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full hover:opacity-80">{body}</button>;
|
||||
}
|
||||
return <div className="flex items-center gap-2">{body}</div>;
|
||||
}
|
||||
|
||||
export function YaesuPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
||||
const { t } = useI18n();
|
||||
const [st, setSt] = useState<YaesuState>(ZERO);
|
||||
const [freqHz, setFreqHz] = 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);
|
||||
setFreqHz((c as any)?.freq_hz ?? 0);
|
||||
// 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);
|
||||
|
||||
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 w-full overflow-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>
|
||||
</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" />
|
||||
<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 */}
|
||||
<Card icon={Activity} title={t('yaesu.meters')}>
|
||||
<Meter label="S" value={view.s_meter} accent="var(--chart-1, #2563eb)"
|
||||
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)" />
|
||||
<Meter label="SWR" value={view.swr_meter} accent="var(--warning, #d97706)" />
|
||||
</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) => (
|
||||
<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>
|
||||
))}
|
||||
</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)))} />
|
||||
<Chip on={view.att > 0} onClick={() => push('att', view.att > 0 ? 0 : 12, () => SetYaesuAtt(view.att > 0 ? 0 : 12))} label="ATT" />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
{/* Noise + filter */}
|
||||
<Card icon={SlidersHorizontal} title={t('yaesu.noiseFilter')}>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Chip on={view.nb} onClick={() => push('nb', !view.nb, () => SetYaesuNB(!view.nb))} label="NB" />
|
||||
<Chip on={view.nr} onClick={() => push('nr', !view.nr, () => SetYaesuNR(!view.nr))} label="DNR" />
|
||||
<Chip on={view.narrow} onClick={() => push('narrow', !view.narrow, () => SetYaesuNarrow(!view.narrow))} label="NAR" />
|
||||
</div>
|
||||
<Row label="DNR">
|
||||
{/* 1-15 on the rig, shown as-is rather than rescaled to a percentage:
|
||||
the radio's own display counts 1-15, and matching it is what makes
|
||||
the panel readable next to the front panel. */}
|
||||
<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" />
|
||||
<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. */}
|
||||
<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" />
|
||||
<span className="w-10 text-right text-xs font-mono tabular-nums text-muted-foreground">{view.rf_power}W</span>
|
||||
</Row>
|
||||
<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" />
|
||||
<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>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user