The operator measured the whole table: 10 W showed 83, 12 W showed 100, 13 W showed 8 — the bargraph is relative to a meter RANGE that flips at 12 W (0-12 QRP, 0-120 above). A bar percentage was never watts; with the PC setting choosing the range, it converts, and the console prints the watts beside the bar.
377 lines
20 KiB
TypeScript
377 lines
20 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
|
import { Radio, Power, Activity, AudioLines, SlidersHorizontal } from 'lucide-react';
|
|
import {
|
|
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
|
|
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
|
|
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
|
|
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRITOffset, SetKenwoodPanelMode,
|
|
GetCATState,
|
|
} from '../../wailsjs/go/main/App';
|
|
import { cn } from '@/lib/utils';
|
|
import { useI18n } from '@/lib/i18n';
|
|
import { sMeterRST } from '@/lib/rst';
|
|
import { ShiftRow } from '@/components/ShiftRow';
|
|
import { MeterBar } from '@/components/MeterBar';
|
|
import { WheelRange } from '@/components/WheelRange';
|
|
|
|
type KenwoodState = {
|
|
available: boolean; model?: string; elecraft: boolean; mode?: string; data_sub?: string;
|
|
transmitting: boolean; split: boolean; split_tx_hz?: number;
|
|
s_meter: number; s_meter_raw: number;
|
|
power_meter: number; power_w?: number; swr: number; swr_raw: number;
|
|
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number;
|
|
preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string;
|
|
filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number;
|
|
meters_provisional: boolean;
|
|
};
|
|
|
|
const ZERO: KenwoodState = {
|
|
available: false, elecraft: false, transmitting: false, split: false,
|
|
s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0,
|
|
rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
|
|
preamp: false, att: false, nb: false, nr: false,
|
|
filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0,
|
|
meters_provisional: true,
|
|
};
|
|
|
|
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8
|
|
// kHz; the rest of the range is reachable from the radio's own knob, and a
|
|
// panel offering thirty widths is slower to use than the knob it replaces.
|
|
// 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the
|
|
// top of the FT8 sub-band, and the decodes that go missing are the ones
|
|
// nobody notices are missing.
|
|
const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000];
|
|
|
|
// Raw S-meter → S units, CALIBRATED against a real K3 beside its own display
|
|
// (2026-08): raw 5 reads S7 on the radio, raw 9 reads S9+20. So S9 sits near
|
|
// raw 6.5 — not 9, which showed everything two S-units low — and each raw step
|
|
// above it is worth ~8 dB, shown in the 10 dB steps the K3's own meter uses.
|
|
// A Kenwood answers 0-30 on the same command and keeps the simple 1-per-raw
|
|
// scale until someone calibrates one against a real radio too.
|
|
const K3_S9_RAW = 6.5;
|
|
const K3_DB_PER_RAW = 8;
|
|
function sParts(rawV: number, elecraft: boolean): { s: number; over: number; label: string } {
|
|
const s9raw = elecraft ? K3_S9_RAW : 9;
|
|
if (rawV >= s9raw) {
|
|
let over = Math.max(0, Math.round((rawV - s9raw) * (elecraft ? K3_DB_PER_RAW : 6)));
|
|
if (elecraft) over = Math.round(over / 10) * 10;
|
|
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
|
}
|
|
const s = Math.max(0, Math.min(9, Math.round(rawV * (elecraft ? 9 / K3_S9_RAW : 1))));
|
|
return { s, over: 0, label: `S${s}` };
|
|
}
|
|
|
|
// Segment colour, printed the way a radio's own meter is: green up to S9, amber
|
|
// through the S9+ range, red once the signal would be reported as 59+20 or more.
|
|
function sSegColor(frac: number) {
|
|
if (frac > 0.78) return '#dc2626';
|
|
if (frac > 0.55) return '#f59e0b';
|
|
return '#16a34a';
|
|
}
|
|
|
|
// The card shell the Yaesu and Icom consoles use, so the three read alike.
|
|
function Card({ icon: Icon, title, children }: { icon: any; title: 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 text-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>
|
|
);
|
|
}
|
|
|
|
// One button shape for every on/off control, so the panel reads as one
|
|
// instrument instead of a collection of differently-styled switches.
|
|
function Toggle({ label, on, off, onClick }: { label: string; on: boolean; off: boolean; onClick: () => void }) {
|
|
return (
|
|
<button type="button" disabled={off} onClick={onClick}
|
|
className={cn('px-2 py-1 rounded-md text-[11px] font-bold tracking-wide border transition-all disabled:opacity-30',
|
|
on ? 'bg-primary text-primary-foreground border-primary' : 'bg-card text-muted-foreground border-border hover:border-primary/60')}>
|
|
{label}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
// isDataMode: what the rig reports for MD6 varies — "DATA", or the configured
|
|
// digital default (FT8…) — so the DATA family is "not one of the native modes".
|
|
function isDataMode(m?: string): boolean {
|
|
switch ((m ?? '').toUpperCase()) {
|
|
case '': case 'CW': case 'USB': case 'LSB': case 'SSB': case 'FM': case 'AM': case 'RTTY':
|
|
return false;
|
|
}
|
|
return true;
|
|
}
|
|
|
|
export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) => void }) {
|
|
const { t } = useI18n();
|
|
const [st, setSt] = useState<KenwoodState>(ZERO);
|
|
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is on — the same
|
|
// keyboard clarifier the Icom and TCI consoles have, for zero-beating a
|
|
// caller without touching the mouse.
|
|
const stRef = useRef(st); stRef.current = st;
|
|
useEffect(() => {
|
|
const onKey = (e: KeyboardEvent) => {
|
|
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
|
const v = stRef.current;
|
|
if (!v.available || !v.rit) return;
|
|
e.preventDefault();
|
|
SetKenwoodRITOffset((v.rit_offset || 0) + (e.key === 'ArrowRight' ? 10 : -10)).catch(() => {});
|
|
};
|
|
window.addEventListener('keydown', onKey);
|
|
return () => window.removeEventListener('keydown', onKey);
|
|
}, []);
|
|
const [freqHz, setFreqHz] = useState(0);
|
|
const [err, setErr] = useState('');
|
|
// Optimistic overlay: a slider must follow the finger, not the poll. Dropped
|
|
// once the radio has had time to answer with the value it actually took.
|
|
const [local, setLocal] = useState<{ rf_power?: number; af_gain?: number; rf_gain?: number; mic_gain?: number; squelch?: number; key_speed?: number }>({});
|
|
const localAtRef = useRef(0);
|
|
|
|
useEffect(() => {
|
|
let alive = true;
|
|
const tick = async () => {
|
|
try {
|
|
const s = (await GetKenwoodState()) as KenwoodState;
|
|
const c = (await GetCATState()) as any;
|
|
if (!alive) return;
|
|
setSt(s);
|
|
setFreqHz(c?.split && c?.freq_rx_hz > 0 ? c.freq_rx_hz : (c?.freq_hz ?? 0));
|
|
if (Date.now() - localAtRef.current > 1200) setLocal({});
|
|
setErr('');
|
|
} catch (e: any) {
|
|
if (alive) setErr(String(e?.message ?? e));
|
|
}
|
|
};
|
|
tick();
|
|
const id = window.setInterval(tick, 500);
|
|
return () => { alive = false; window.clearInterval(id); };
|
|
}, []);
|
|
|
|
const view = { ...st, ...local };
|
|
const off = !st.available;
|
|
|
|
const setErrMsg = (e: any) => setErr(String(e?.message ?? e));
|
|
|
|
const put = (patch: typeof local, run: () => Promise<any>) => {
|
|
setLocal((p) => ({ ...p, ...patch }));
|
|
localAtRef.current = Date.now();
|
|
run().catch((e) => setErr(String(e?.message ?? e)));
|
|
};
|
|
|
|
return (
|
|
<div className="h-full min-h-0 overflow-auto bg-background">
|
|
{/* Same wrapper as the Yaesu, Icom and Flex consoles: capped width and
|
|
CENTRED. A console stretched across a 2000 px window puts a slider a
|
|
hand's width from its own label, and the panel stops reading as one
|
|
instrument. */}
|
|
<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 flex items-center gap-1.5">
|
|
<Radio className="size-3.5" />
|
|
{st.elecraft ? 'Elecraft' : 'Kenwood'} {st.model}
|
|
<span className={cn('size-2 rounded-full', off ? 'bg-muted-foreground/40' : 'bg-success')} />
|
|
</div>
|
|
<div className="text-2xl font-mono tabular-nums font-bold">
|
|
{freqHz > 0 ? (freqHz / 1e6).toFixed(6) : '—'}
|
|
</div>
|
|
</div>
|
|
{/* Mode row. The two DATA buttons are why it exists: MD6 alone keeps
|
|
whatever submode the last session left, and a K3 "in DATA" with FSK D
|
|
still armed keys FT8 with no audio. DATA sends MD6+DT0 (DATA A, the
|
|
soundcard path), RTTY sends MD6+DT2 (FSK D). The active DATA button
|
|
follows the rig's own DT answer. */}
|
|
<div className="inline-flex rounded-md border border-border overflow-hidden">
|
|
{([
|
|
['CW', 'CW', view.mode === 'CW'],
|
|
['USB', 'USB', view.mode === 'USB'],
|
|
['LSB', 'LSB', view.mode === 'LSB'],
|
|
['DATA', t('k3.modeData'), isDataMode(view.mode) && view.data_sub !== 'FSK D' && view.data_sub !== 'PSK D'],
|
|
['RTTY', t('k3.modeRtty'), (isDataMode(view.mode) && (view.data_sub === 'FSK D' || view.data_sub === 'PSK D')) || view.mode === 'RTTY'],
|
|
] as [string, string, boolean][]).map(([cmd, label, on]) => (
|
|
<button key={cmd} type="button" disabled={off}
|
|
title={cmd === 'DATA' ? t('k3.modeDataHint') : cmd === 'RTTY' ? t('k3.modeRttyHint') : label}
|
|
onClick={() => SetKenwoodPanelMode(cmd).catch((e: any) => setErr(String(e?.message ?? e)))}
|
|
className={cn('px-2.5 py-1.5 text-xs font-bold border-l border-border first:border-l-0',
|
|
on ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
|
{label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<button type="button" className="text-[11px] text-muted-foreground hover:text-foreground flex items-center gap-1"
|
|
onClick={() => RefreshKenwood().catch(() => {})} title={t('k3.refreshHint')}>
|
|
<SlidersHorizontal className="size-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
{off && <div className="text-xs text-muted-foreground px-1">{t('k3.waiting')}</div>}
|
|
{!!err && <div className="text-[11px] text-danger px-1">{err}</div>}
|
|
|
|
{/* Meters */}
|
|
<Card icon={Activity} title={t('k3.meters')}>
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-2">
|
|
<MeterBar label="S-METER" value={view.transmitting ? 0 : view.s_meter} lo={0} hi={100}
|
|
accent="#16a34a" segColor={sSegColor}
|
|
display={view.transmitting ? '—' : sParts(view.s_meter_raw, view.elecraft).label}
|
|
onClick={() => {
|
|
if (view.transmitting || !onReportRST) return;
|
|
const sp = sParts(view.s_meter_raw, view.elecraft);
|
|
onReportRST(sMeterRST(sp.s, sp.over, view.mode));
|
|
}}
|
|
title={t('k3.sMeterHint', { raw: String(view.s_meter_raw) })} />
|
|
<MeterBar label="PWR" value={view.transmitting ? view.power_meter : 0} lo={0} hi={100} accent="#0ea5e9"
|
|
display={view.transmitting && view.elecraft ? `${view.power_w ?? 0} W` : undefined} />
|
|
{/* 0 means "not measured", and it must not render as a perfect 1.0:
|
|
a match that looks ideal on an antenna nobody has measured is the one
|
|
reading that can cost a radio. */}
|
|
<MeterBar label="SWR" value={view.transmitting && view.swr > 0 ? view.swr : 1} lo={1} hi={4}
|
|
accent="#f59e0b"
|
|
display={view.transmitting && view.swr > 0 ? view.swr.toFixed(1) : '—'} />
|
|
</div>
|
|
{view.meters_provisional && (
|
|
<p className="text-[10px] text-muted-foreground">{t('k3.provisional')}</p>
|
|
)}
|
|
</Card>
|
|
|
|
{/* MOX + TUNE */}
|
|
<div className="flex items-center gap-2">
|
|
<button type="button" disabled={off}
|
|
onClick={() => SetKenwoodTX(!view.transmitting).catch((e) => setErr(String(e?.message ?? e)))}
|
|
className={cn('flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 transition-all disabled:opacity-30',
|
|
view.transmitting
|
|
? 'bg-danger text-danger-foreground border-danger shadow-[0_0_14px] shadow-danger/50'
|
|
: 'bg-card text-danger border-danger hover:bg-danger-muted')}>
|
|
<Power className="size-4 inline mr-1 -mt-0.5" /> MOX
|
|
</button>
|
|
<button type="button" disabled={off}
|
|
onClick={() => TuneKenwoodATU().catch((e) => setErr(String(e?.message ?? e)))}
|
|
title={t('k3.tuneHint')}
|
|
className="flex-1 px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-warning text-warning bg-card hover:bg-warning-muted transition-all disabled:opacity-30">
|
|
<Activity className="size-4 inline mr-1 -mt-0.5" /> TUNE
|
|
</button>
|
|
{/* The HOLD of the same switch: tuner in line or bypassed. Two buttons
|
|
because they are two things on the radio — tuning is a cycle you
|
|
start, bypassing is a state you leave it in. */}
|
|
<button type="button" disabled={off}
|
|
onClick={() => ToggleKenwoodATU().catch(setErrMsg)}
|
|
title={t('k3.atuHint')}
|
|
className="px-3 py-2.5 rounded-lg text-sm font-extrabold tracking-wide border-2 border-border text-muted-foreground bg-card hover:bg-muted transition-all disabled:opacity-30">
|
|
ATU
|
|
</button>
|
|
</div>
|
|
|
|
{/* Levels — two columns, so a slider stays beside the label it belongs to
|
|
instead of running the width of the window. */}
|
|
<Card icon={SlidersHorizontal} title={t('k3.levels')}>
|
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-x-6 gap-y-2">
|
|
<label className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.power')}</span>
|
|
<WheelRange min={0} max={110} step={1} disabled={off}
|
|
value={view.rf_power ?? 0}
|
|
onChange={(n) => put({ rf_power: n }, () => SetKenwoodPower(n))} />
|
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_power ?? 0} W</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 shrink-0 text-muted-foreground flex items-center gap-1">
|
|
<AudioLines className="size-3.5" /> {t('k3.volume')}
|
|
</span>
|
|
<WheelRange min={0} max={100} disabled={off}
|
|
value={view.af_gain ?? 0}
|
|
onChange={(n) => put({ af_gain: n }, () => SetKenwoodAFGain(n))} />
|
|
<span className="w-12 text-right font-mono tabular-nums">{view.af_gain ?? 0}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.rfGain')}</span>
|
|
<WheelRange min={0} max={100} disabled={off}
|
|
value={view.rf_gain ?? 0}
|
|
onChange={(n) => put({ rf_gain: n }, () => SetKenwoodRFGain(n))} />
|
|
<span className="w-12 text-right font-mono tabular-nums">{view.rf_gain ?? 0}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.micGain')}</span>
|
|
<WheelRange min={0} max={100} disabled={off}
|
|
value={view.mic_gain ?? 0}
|
|
onChange={(n) => put({ mic_gain: n }, () => SetKenwoodMicGain(n))} />
|
|
<span className="w-12 text-right font-mono tabular-nums">{view.mic_gain ?? 0}</span>
|
|
</label>
|
|
<label className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.squelch')}</span>
|
|
<WheelRange min={0} max={100} disabled={off}
|
|
value={view.squelch ?? 0}
|
|
onChange={(n) => put({ squelch: n }, () => SetKenwoodSquelch(n))} />
|
|
<span className="w-12 text-right font-mono tabular-nums">{view.squelch ?? 0}</span>
|
|
</label>
|
|
{/* CW keyer speed — the radio's own keyer, the one the K3 sends with. */}
|
|
<label className="flex items-center gap-2 text-xs">
|
|
<span className="w-16 shrink-0 text-muted-foreground">{t('k3.keySpeed')}</span>
|
|
<WheelRange min={8} max={50} disabled={off}
|
|
value={view.key_speed || 20}
|
|
onChange={(n) => put({ key_speed: n }, () => SetKenwoodKeySpeed(n))} />
|
|
<span className="w-12 text-right font-mono tabular-nums">{view.key_speed || 0} wpm</span>
|
|
</label>
|
|
</div>
|
|
</Card>
|
|
|
|
{/* Receive chain. Toggles, because that is what they are on the radio:
|
|
one press each, and the state comes back from the rig rather than from
|
|
what the button was asked to do. */}
|
|
<Card icon={AudioLines} title={t('k3.receive')}>
|
|
<div className="space-y-2">
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<Toggle label="PRE" on={view.preamp} off={off} onClick={() => SetKenwoodPreamp(!view.preamp).catch(setErrMsg)} />
|
|
<Toggle label="ATT" on={view.att} off={off} onClick={() => SetKenwoodAtt(!view.att).catch(setErrMsg)} />
|
|
<Toggle label="NB" on={view.nb} off={off} onClick={() => SetKenwoodNB(!view.nb).catch(setErrMsg)} />
|
|
<Toggle label="NR" on={view.nr} off={off} onClick={() => SetKenwoodNR(!view.nr).catch(setErrMsg)} />
|
|
<span className="w-2" />
|
|
{['OFF', 'SLOW', 'FAST'].map((a) => (
|
|
<Toggle key={a} label={a} on={(view.agc || '').toUpperCase() === a} off={off}
|
|
onClick={() => SetKenwoodAGC(a).catch(setErrMsg)} />
|
|
))}
|
|
</div>
|
|
|
|
{/* RIT / XIT — the shared ShiftRow, exactly as the Icom and TCI consoles
|
|
drive theirs: ± / wheel / type, Ctrl+←/→ while RIT is on. The K3 has
|
|
ONE offset for both, so both rows show it, like the Icom's. */}
|
|
<div className="space-y-1.5">
|
|
<ShiftRow label="RIT" accent="#8b5cf6" on={view.rit} hz={view.rit_offset || 0} disabled={off}
|
|
onToggle={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)}
|
|
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
|
|
<ShiftRow label="XIT" accent="#f59e0b" on={view.xit} hz={view.rit_offset || 0} disabled={off}
|
|
onToggle={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)}
|
|
onSet={(hz) => SetKenwoodRITOffset(hz).catch(setErrMsg)} />
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
{/* Antenna only when the radio answered AN — a K3 without the internal
|
|
ATU has one socket and no switch to offer. */}
|
|
{view.antenna > 0 && (<>
|
|
<span className="w-2" />
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.antenna')}</span>
|
|
{[1, 2].map((n) => (
|
|
<Toggle key={n} label={'ANT' + n} on={view.antenna === n} off={off}
|
|
onClick={() => SetKenwoodAntenna(n).catch(setErrMsg)} />
|
|
))}
|
|
</>)}
|
|
</div>
|
|
|
|
{/* Filter width */}
|
|
<div className="flex flex-wrap items-center gap-1.5">
|
|
<span className="text-[10px] uppercase tracking-wider text-muted-foreground">{t('k3.filter')}</span>
|
|
{FILTERS.map((hz) => (
|
|
<Toggle key={hz} label={hz >= 1000 ? (hz / 1000).toFixed(1) + 'k' : String(hz)}
|
|
on={view.filter_hz === hz} off={off}
|
|
onClick={() => SetKenwoodFilter(hz).catch(setErrMsg)} />
|
|
))}
|
|
{view.filter_hz > 0 && (
|
|
<span className="text-[10px] font-mono text-muted-foreground">{view.filter_hz} Hz</span>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</Card>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|