TUNE (1C 01 02) started a cycle; nothing sent the 0 that puts the tuner back through. An ATU chip beside SPLIT now toggles it, and the tuner state is read on the slow front-panel beat so the radio's own TUNER button stays in sync.
700 lines
37 KiB
TypeScript
700 lines
37 KiB
TypeScript
import { useEffect, useRef, useState } from 'react';
|
||
import { Radio, AudioLines, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power, Volume2, VolumeX } from 'lucide-react';
|
||
import {
|
||
GetIcomState, IcomRefresh,
|
||
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
|
||
IcomSetANF, IcomSetAPF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
|
||
AudioMonitorActive, AudioStartMonitor, AudioStopMonitor,
|
||
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetATU, IcomConsolePTT,
|
||
GetCATState, SetCATFrequency, SetCATMode,
|
||
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
|
||
IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos,
|
||
IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel,
|
||
IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower,
|
||
} 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';
|
||
import { ShiftRow } from '@/components/ShiftRow';
|
||
|
||
type IcomState = {
|
||
available: boolean; model?: string; mode?: string;
|
||
transmitting: boolean; split: boolean; sub_hz?: number; atu_on?: boolean;
|
||
s_meter: number; power_meter: number; swr_meter: number;
|
||
rf_power: number; mic_gain: number;
|
||
af_gain: number; rf_gain: number;
|
||
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; apf: boolean;
|
||
agc?: string; preamp: number; att: number; filter: number;
|
||
rit_hz: number; rit_on: boolean; xit_on: boolean;
|
||
antenna: number;
|
||
pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number;
|
||
squelch: number; comp: boolean; comp_level: number;
|
||
monitor: boolean; mon_level: number;
|
||
vox: boolean; vox_gain: number; anti_vox: number;
|
||
};
|
||
|
||
const ZERO: IcomState = {
|
||
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,
|
||
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, apf: false,
|
||
preamp: 0, att: 0, filter: 1,
|
||
rit_hz: 0, rit_on: false, xit_on: false,
|
||
antenna: 1,
|
||
pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50,
|
||
squelch: 0, comp: false, comp_level: 0,
|
||
monitor: false, mon_level: 0,
|
||
vox: false, vox_gain: 0, anti_vox: 0,
|
||
};
|
||
|
||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||
// the plain SetFrequency command — no band-stacking codes needed. Hz values.
|
||
type Band = { l: string; hz: number };
|
||
|
||
const HF_BANDS: Band[] = [
|
||
{ l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 },
|
||
{ l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 },
|
||
{ l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 },
|
||
];
|
||
const B6 = { l: '6', hz: 50_150_000 };
|
||
const B2 = { l: '2', hz: 144_300_000 }; // SSB calling
|
||
const B70 = { l: '70cm', hz: 432_200_000 }; // SSB calling
|
||
const B23 = { l: '23cm', hz: 1_296_200_000 }; // SSB calling
|
||
|
||
// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using
|
||
// the plain SetFrequency command — no band-stacking codes needed.
|
||
//
|
||
// Which buttons to OFFER depends on the radio, exactly as the attenuator steps
|
||
// do below. An IC-9700 has no HF at all, yet the console was showing it 160
|
||
// through 6 and none of the bands it actually covers: ten dead buttons, and no
|
||
// way to change band from here on the only rig where you would want to.
|
||
function bandsFor(model?: string): Band[] {
|
||
const m = (model ?? '').toUpperCase();
|
||
if (m.includes('9700')) return [B2, B70, B23]; // VHF/UHF/SHF only
|
||
if (m.includes('705')) return [...HF_BANDS, B6, B2, B70];
|
||
if (m.includes('9100')) return [...HF_BANDS, B6, B2, B70, B23];
|
||
// 7300 / 7610 / 7100 / unknown: HF + 6 m, the historical list.
|
||
return [...HF_BANDS, B6];
|
||
}
|
||
|
||
// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for
|
||
// SSB by frequency and the rig's data variant for digital modes.
|
||
const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM', 'DATA'];
|
||
|
||
// Attenuator steps are MODEL-dependent even though the CI-V command (0x11) is the
|
||
// same: the value byte is the dB. The IC-7610 (and 7700/7800/7851) have a 6/12/18
|
||
// dB stepped attenuator, and the IC-7760 the same (confirmed on a real one); the
|
||
// IC-7300/705/7100 have a single 20 dB attenuator; the
|
||
// IC-9700 a single 10 dB. Offering the wrong steps = a dead button (the rig NAKs
|
||
// e.g. 6 dB on a 7300). Default to the common single 20 dB for unknown models.
|
||
function attOptions(model?: string): { v: string; l: string }[] {
|
||
const m = (model ?? '').toUpperCase();
|
||
const OFF = { v: '0', l: 'OFF' };
|
||
if (/(7610|7700|7760|7800|7850|7851)/.test(m)) {
|
||
return [OFF, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }];
|
||
}
|
||
if (m.includes('9700')) return [OFF, { v: '10', l: '10dB' }];
|
||
return [OFF, { v: '20', l: '20dB' }]; // IC-7300 / IC-705 / IC-7100 / default
|
||
}
|
||
|
||
// bandOfHz names the amateur band a frequency falls in, so the band row can show
|
||
// where the rig actually is. The buttons only ever SENT a frequency and carried
|
||
// no active state at all, so nothing was highlighted whatever the rig reported.
|
||
// Edges are the ITU/IARU band limits, wide enough to cover regional differences —
|
||
// out-of-band (transverter IF, general coverage RX) matches nothing, as it should.
|
||
function bandOfHz(hz?: number): string {
|
||
if (!hz || hz <= 0) return '';
|
||
const mhz = hz / 1_000_000;
|
||
const bands: [string, number, number][] = [
|
||
['160', 1.8, 2.0], ['80', 3.5, 4.0], ['60', 5.25, 5.45], ['40', 7.0, 7.3],
|
||
['30', 10.1, 10.15], ['20', 14.0, 14.35], ['17', 18.068, 18.168],
|
||
['15', 21.0, 21.45], ['12', 24.89, 24.99], ['10', 28.0, 29.7],
|
||
// Labels must match the band buttons' exactly — this is only used to light
|
||
// the button for the band the rig is on, and '70' never matched '70cm'.
|
||
['6', 50.0, 54.0], ['4', 70.0, 70.5], ['2', 144.0, 148.0],
|
||
['70cm', 430.0, 450.0], ['23cm', 1240.0, 1300.0],
|
||
];
|
||
for (const [name, lo, hi] of bands) if (mhz >= lo && mhz <= hi) return name;
|
||
return '';
|
||
}
|
||
|
||
// fmtVFO renders a Hz frequency the way an Icom front panel does:
|
||
// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00".
|
||
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')}`;
|
||
}
|
||
|
||
// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB.
|
||
// icomWatts turns the backend's 0-100 meter percentage back into watts on the
|
||
// IC-7760's own meter face. The backend value is linear in the RAW meter byte
|
||
// (0-255 → 0-100), but Icom's calibration is not: raw 143 is half deflection
|
||
// and raw 213 is full scale. On a real 7760 a measured 100 W sits at half
|
||
// deflection of the 250 W face — the linear ×2.5 first tried showed 140 W for
|
||
// it. Below half scale watts run 0→100, above it 100→250.
|
||
// The anchors are MEASURED on the real radio, not derived: a known 50 W read
|
||
// raw ≈89 and a known 100 W read raw 143 (Icom's documented half-deflection),
|
||
// with raw 213 = full scale = 250 W. The face is not linear in watts at the
|
||
// bottom — a two-segment guess showed 50 W as 62 — so watts interpolate
|
||
// between the measured anchors, and a new measurement just adds a row.
|
||
const ICOM_7760_PO: [number, number][] = [[0, 0], [89, 50], [143, 100], [213, 250]];
|
||
function icomWatts(pct: number): { w: number; defl: number } {
|
||
const raw = Math.max(0, pct * 2.55);
|
||
const defl = raw <= 143 ? (raw / 143) * 50 : Math.min(100, 50 + ((raw - 143) / 70) * 50);
|
||
let w = 250;
|
||
for (let i = 1; i < ICOM_7760_PO.length; i++) {
|
||
const [r0, w0] = ICOM_7760_PO[i - 1], [r1, w1] = ICOM_7760_PO[i];
|
||
if (raw <= r1) { w = w0 + ((raw - r0) / (r1 - r0)) * (w1 - w0); break; }
|
||
}
|
||
return { w: Math.round(w), defl };
|
||
}
|
||
|
||
function modeMatches(btn: string, cur?: string): boolean {
|
||
if (!cur) return false;
|
||
if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB';
|
||
// The backend surfaces USB-D as the operator's digital default (FT8…), or as
|
||
// plain DATA — either way it is the DATA button that should light.
|
||
if (btn === 'DATA') return ['DATA', 'FT8', 'FT4', 'JS8', 'JT65', 'JT9', 'MFSK', 'OLIVIA'].includes(cur);
|
||
if (btn === 'PSK') return cur === 'PSK' || cur === 'PSK31';
|
||
return btn === cur;
|
||
}
|
||
|
||
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
|
||
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
|
||
}) {
|
||
const v = Math.max(0, Math.min(100, value));
|
||
const ref = useRef<HTMLInputElement>(null);
|
||
// Mouse-wheel adjusts the slider. React's onWheel is passive (preventDefault
|
||
// is ignored), so attach a non-passive native listener; read live values via
|
||
// refs to avoid stale closures.
|
||
const valRef = useRef(value); valRef.current = value;
|
||
const cbRef = useRef(onChange); cbRef.current = onChange;
|
||
const disRef = useRef(disabled); disRef.current = disabled;
|
||
const stepRef = useRef(step); stepRef.current = step;
|
||
useEffect(() => {
|
||
const el = ref.current;
|
||
if (!el) return;
|
||
const onWheel = (e: WheelEvent) => {
|
||
if (disRef.current) return;
|
||
e.preventDefault();
|
||
const d = e.deltaY < 0 ? stepRef.current : -stepRef.current;
|
||
const nv = Math.max(0, Math.min(100, valRef.current + d));
|
||
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}%, #d8cfb8 ${v}%)`, 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 }: { on: boolean; onClick: () => void; label: string }) {
|
||
return (
|
||
<button type="button" onClick={onClick}
|
||
className={cn('w-14 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 LevelRow({ label, on, onToggle, value, onLevel }: {
|
||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||
}) {
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Chip on={on} onClick={onToggle} label={label} />
|
||
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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>
|
||
);
|
||
}
|
||
|
||
// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
|
||
// Optional onClick makes the row a button (used to send the S reading to RST tx).
|
||
function Meter({ label, value, accent, scale, onClick, title }: { label: string; value: number; accent: string; scale?: 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-10 text-right text-[11px] font-mono tabular-nums text-muted-foreground">{scale ?? v}</span>
|
||
</>
|
||
);
|
||
if (onClick) {
|
||
return <button type="button" onClick={onClick} title={title} className="flex items-center gap-2 w-full rounded cursor-pointer hover:bg-muted/50 -mx-1 px-1 py-0.5">{body}</button>;
|
||
}
|
||
return <div className="flex items-center gap-2">{body}</div>;
|
||
}
|
||
|
||
// sParts turns the raw 0-100 S-meter into S-unit + dB-over-S9 (S9 ≈ 47% on the
|
||
// CI-V 0-255 scale, +60 dB near full scale). Used for both the display label and
|
||
// the RST-tx value on click.
|
||
function sParts(v: number): { s: number; over: number; label: string } {
|
||
if (v >= 47) {
|
||
const over = Math.max(0, Math.round((v - 47) * 60 / 47));
|
||
return { s: 9, over, label: over > 0 ? `S9+${over}` : 'S9' };
|
||
}
|
||
const s = Math.max(0, Math.min(9, Math.round(v / 5.2)));
|
||
return { s, over: 0, label: `S${s}` };
|
||
}
|
||
|
||
// wfColor maps a 0-1 amplitude to a classic waterfall colour ramp
|
||
// (near-black → blue → cyan → green → amber → red).
|
||
const WF_STOPS: [number, [number, number, number]][] = [
|
||
[0.0, [8, 12, 28]], [0.22, [26, 58, 138]], [0.42, [0, 150, 190]],
|
||
[0.62, [46, 200, 120]], [0.80, [240, 210, 70]], [1.0, [244, 63, 60]],
|
||
];
|
||
function wfColor(v: number): [number, number, number] {
|
||
v = Math.max(0, Math.min(1, Math.pow(v, 0.7))); // gamma-lift so the noise floor still has hue
|
||
for (let i = 1; i < WF_STOPS.length; i++) {
|
||
if (v <= WF_STOPS[i][0]) {
|
||
const [a, ca] = WF_STOPS[i - 1], [b, cb] = WF_STOPS[i];
|
||
const f = (v - a) / (b - a || 1);
|
||
return [Math.round(ca[0] + (cb[0] - ca[0]) * f), Math.round(ca[1] + (cb[1] - ca[1]) * f), Math.round(ca[2] + (cb[2] - ca[2]) * f)];
|
||
}
|
||
}
|
||
return WF_STOPS[WF_STOPS.length - 1][1];
|
||
}
|
||
|
||
// The spectrum scope is GONE, deliberately. Every Icom streams its waveform
|
||
// differently — the IC-7851 controls a scope it never streams, and a real
|
||
// IC-7760 stops answering CI-V altogether a few frames in, taking CAT and
|
||
// audio down with it — and chasing a per-model frame layout for a decoration
|
||
// is not worth a console that drops the link. The radio has a better scope
|
||
// on its own front panel.
|
||
|
||
export function IcomPanel({ onReportRST, isNetwork = false }: { onReportRST?: (rst: string) => void; isNetwork?: boolean } = {}) {
|
||
// The speaker toggle lives HERE, next to ON/OFF, because that is where the
|
||
// operator is looking — burying "stop listening" behind Settings → Audio
|
||
// meant a trip through two panels to mute a radio sitting in the same room.
|
||
const [listening, setListening] = useState(false);
|
||
useEffect(() => {
|
||
if (!isNetwork) return;
|
||
let alive = true;
|
||
const ask = () => AudioMonitorActive().then((v) => { if (alive) setListening(!!v); }).catch(() => {});
|
||
ask();
|
||
const id = window.setInterval(ask, 2000);
|
||
return () => { alive = false; window.clearInterval(id); };
|
||
}, [isNetwork]);
|
||
const toggleListening = () => {
|
||
const next = !listening;
|
||
setListening(next);
|
||
(next ? AudioStartMonitor() : Promise.resolve(AudioStopMonitor())).catch(() => setListening(!next));
|
||
};
|
||
const { t } = useI18n();
|
||
const [st, setSt] = useState<IcomState>(ZERO);
|
||
const [cat, setCat] = useState<any>(null); // RigState (freq/mode/split) for the VFO display
|
||
const [tuning, setTuning] = useState(false);
|
||
const txRef = useRef(false);
|
||
const stRef = useRef<IcomState>(ZERO); stRef.current = st;
|
||
|
||
const load = () => {
|
||
GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
|
||
GetCATState().then((c) => setCat(c ?? null)).catch(() => {});
|
||
};
|
||
const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); };
|
||
// Initial one-shot read of the rig's DSP snapshot on mount (the 500ms poll only
|
||
// re-reads the cache; the backend also loads DSP on the first responsive read).
|
||
const refresh = async () => {
|
||
try { await IcomRefresh(); } catch {}
|
||
await load();
|
||
};
|
||
|
||
useEffect(() => {
|
||
refresh();
|
||
const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
|
||
return () => window.clearInterval(id);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
// Optimistic local update + fire the command; the cache poll reconciles.
|
||
const set = (patch: Partial<IcomState>, fn: () => Promise<void>) => {
|
||
setSt((s) => ({ ...s, ...patch }));
|
||
fn().catch(() => {});
|
||
};
|
||
|
||
const toggleMox = () => {
|
||
const next = !txRef.current;
|
||
txRef.current = next;
|
||
// Through the console binding: on a network station the PC microphone
|
||
// rides with the PTT (silence otherwise); on USB it keys and nothing more.
|
||
set({ transmitting: next }, () => IcomConsolePTT(next));
|
||
};
|
||
|
||
const tune = async () => {
|
||
setTuning(true);
|
||
try { await IcomTune(); } catch {}
|
||
window.setTimeout(() => setTuning(false), 4000);
|
||
};
|
||
|
||
// RIT/ΔTX offset (signed Hz, clamped ±9999). Optimistic like the DSP controls.
|
||
const setRit = (hz: number) => {
|
||
const v = Math.max(-9999, Math.min(9999, hz));
|
||
set({ rit_hz: v }, () => IcomSetRIT(v));
|
||
};
|
||
|
||
// Ctrl+Left/Right shifts the RIT by ±10 Hz while RIT is active — a keyboard
|
||
// clarifier for zero-beating a caller without touching the mouse.
|
||
useEffect(() => {
|
||
const onKey = (e: KeyboardEvent) => {
|
||
if (!e.ctrlKey || (e.key !== 'ArrowLeft' && e.key !== 'ArrowRight')) return;
|
||
const s = stRef.current;
|
||
if (!s.available || !s.rit_on) return;
|
||
e.preventDefault();
|
||
setRit(s.rit_hz + (e.key === 'ArrowRight' ? 10 : -10));
|
||
};
|
||
window.addEventListener('keydown', onKey);
|
||
return () => window.removeEventListener('keydown', onKey);
|
||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||
}, []);
|
||
|
||
if (!st.available) {
|
||
return (
|
||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||
{t('icmp.notConnected')}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
const tx = st.transmitting;
|
||
// VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the
|
||
// other is TX (freq_hz); otherwise there's a single VFO (freq_hz).
|
||
const split = !!cat?.split;
|
||
const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0);
|
||
// The sub receiver's dial is worth seeing whether or not split is on —
|
||
// in split the CAT state's TX freq is the authority, otherwise the panel's
|
||
// own sub_hz read.
|
||
const subHz: number = split ? (cat?.freq_hz || 0) : (st.sub_hz || 0);
|
||
const curMode: string = cat?.mode || st.mode || '';
|
||
// Mode-dependent controls: VOX / speech-comp / mic are voice-only (hidden on
|
||
// CW and data); APF (audio peak filter) is CW-only. Fold USB/LSB into phone.
|
||
const um = curMode.toUpperCase();
|
||
const isCW = um === 'CW' || um === 'CWR';
|
||
const isPhone = um === 'SSB' || um === 'USB' || um === 'LSB' || um === 'AM' || um === 'FM';
|
||
|
||
return (
|
||
<div className="h-full min-h-0 overflow-auto bg-background">
|
||
<div className="max-w-5xl mx-auto p-3 space-y-3">
|
||
{/* Header strip: model + mode + live RX/TX indicator + split badge. */}
|
||
<div className="flex items-center justify-between rounded-xl border border-border bg-card px-3 py-2 shadow-sm">
|
||
<div className="flex items-center gap-2">
|
||
<span className={cn('inline-flex items-center gap-1.5 rounded-md px-2 py-1 text-[11px] font-bold uppercase tracking-wider',
|
||
tx ? 'bg-destructive text-destructive-foreground' : 'bg-success text-success-foreground')}>
|
||
<span className={cn('size-2 rounded-full', tx ? 'bg-card animate-pulse' : 'bg-card/90')} />
|
||
{tx ? 'TX' : 'RX'}
|
||
</span>
|
||
<span className="text-sm font-bold">{st.model || 'Icom'}</span>
|
||
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
|
||
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
{/* Radio power ON / OFF — NETWORK only. Over USB the CI-V interface is
|
||
unpowered while the rig is off, so power-ON can't reach it (OFF works
|
||
but ON doesn't); hiding both avoids a dead button. On the network the
|
||
rig's LAN server stays alive in standby, so both work. */}
|
||
{isNetwork && (
|
||
<>
|
||
<button type="button" onClick={toggleListening}
|
||
title={listening ? t('icmp.speakerOffHint') : t('icmp.speakerOnHint')}
|
||
className={cn('inline-flex items-center gap-1 rounded-md border px-2 py-1 text-xs font-bold',
|
||
listening
|
||
? 'border-primary/60 bg-primary/10 text-primary hover:bg-primary/20'
|
||
: 'border-border bg-card text-muted-foreground hover:bg-muted')}>
|
||
{listening ? <Volume2 className="size-3.5" /> : <VolumeX className="size-3.5" />}
|
||
</button>
|
||
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
|
||
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
|
||
<Power className="size-3.5" /> ON
|
||
</button>
|
||
<button type="button" onClick={() => IcomSetPower(false).catch(() => {})} title={t('icmp.powerOffHint')}
|
||
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
|
||
<Power className="size-3.5" /> OFF
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
|
||
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
|
||
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
|
||
<div className="grid grid-cols-2 divide-x divide-border/60">
|
||
{/* MAIN VFO */}
|
||
<div className="px-4 py-3">
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
|
||
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
|
||
</div>
|
||
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
|
||
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
|
||
<span>{cat?.band || (mainHz ? '' : '—')}</span>
|
||
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)}</span> : null}
|
||
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
|
||
</div>
|
||
</div>
|
||
{/* SUB VFO (populated in split; dimmed otherwise) */}
|
||
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
|
||
<div className="flex items-center justify-between mb-1.5">
|
||
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
|
||
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
|
||
</div>
|
||
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
|
||
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
|
||
</div>
|
||
</div>
|
||
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
|
||
<div className="grid grid-cols-7 border-t border-border/60 divide-x divide-border/60">
|
||
{MODES.map((m) => {
|
||
const on = modeMatches(m, curMode);
|
||
return (
|
||
<button key={m} type="button" onClick={() => setMode(m)}
|
||
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
|
||
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
|
||
{m}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
|
||
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
|
||
{(() => { const sp = sParts(st.s_meter); return (
|
||
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
|
||
title={onReportRST ? t('rst.clickToFill') : undefined}
|
||
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
|
||
); })()}
|
||
{(() => {
|
||
if ((st.model ?? '').includes('7760')) {
|
||
const { w, defl } = icomWatts(st.power_meter);
|
||
return <Meter label="Po" value={defl} accent="#ef4444" scale={`${w} W`} />;
|
||
}
|
||
return <Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />;
|
||
})()}
|
||
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
|
||
{/* Band buttons + antenna selection. */}
|
||
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
|
||
<div className="grid grid-cols-5 gap-1.5">
|
||
{bandsFor(st.model).map((b) => {
|
||
const here = bandOfHz(mainHz) === b.l;
|
||
return (
|
||
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
|
||
title={here ? t('icmp.bandCurrent', { b: b.l }) : undefined}
|
||
className={cn('px-1 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||
here
|
||
? 'border-primary bg-primary text-primary-foreground shadow-[0_0_8px] shadow-primary/40'
|
||
: 'border-border bg-card text-foreground hover:bg-muted')}>
|
||
{b.l}
|
||
</button>
|
||
);
|
||
})}
|
||
</div>
|
||
<Row label={t('icmp.antenna')}>
|
||
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
|
||
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
|
||
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
|
||
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
|
||
onToggle={() => set({ rit_on: !st.rit_on }, () => IcomSetRITOn(!st.rit_on))}
|
||
onSet={setRit} />
|
||
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
|
||
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
|
||
onSet={setRit} />
|
||
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
|
||
</Card>
|
||
|
||
{/* Transmit controls. */}
|
||
<Card icon={Mic} title={t('icmp.transmit')} accent="#ef4444">
|
||
<Row label={t('icmp.power')}>
|
||
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
|
||
{/* PC is a percentage of the rig's rated power; on a 200 W rig the
|
||
operator thinks in watts, so say it in watts there. */}
|
||
<span className="w-12 text-right text-xs font-mono tabular-nums text-muted-foreground">
|
||
{(st.model ?? '').includes('7760') ? `${st.rf_power * 2} W` : st.rf_power}
|
||
</span>
|
||
</Row>
|
||
{isPhone && (
|
||
<Row label={t('icmp.mic')}>
|
||
<Slider value={st.mic_gain} accent="#ef4444" onChange={(v) => set({ mic_gain: v }, () => IcomSetMicGain(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.mic_gain}</span>
|
||
</Row>
|
||
)}
|
||
<div className="flex items-center gap-2 pt-1">
|
||
<button type="button" onClick={toggleMox}
|
||
className={cn('flex-1 px-3 py-1.5 rounded-md text-xs font-bold border transition-colors',
|
||
tx ? 'bg-destructive border-destructive text-destructive-foreground' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||
{tx ? 'TX ON' : 'MOX'}
|
||
</button>
|
||
<Chip label="SPLIT" on={st.split} onClick={() => set({ split: !st.split }, () => IcomSetSplit(!st.split))} />
|
||
{/* Tuner IN/OUT — TUNE below starts a cycle but could never take the
|
||
tuner back out of line. */}
|
||
<Chip label="ATU" on={!!st.atu_on} onClick={() => set({ atu_on: !st.atu_on } as any, () => IcomSetATU(!st.atu_on))} />
|
||
<button type="button" onClick={tune} disabled={tuning}
|
||
className={cn('w-14 shrink-0 px-2 py-1.5 rounded-md text-[11px] font-bold border transition-colors',
|
||
tuning ? 'bg-warning border-warning text-warning-foreground animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
|
||
TUNE
|
||
</button>
|
||
</div>
|
||
{/* Monitor (all modes) + speech processor / VOX (voice modes only —
|
||
they don't exist on CW or data). */}
|
||
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
|
||
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
|
||
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
|
||
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
|
||
{isPhone && (
|
||
<>
|
||
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
|
||
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
|
||
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
|
||
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
|
||
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
|
||
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
|
||
<Row label="Anti-VOX">
|
||
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
|
||
</Row>
|
||
</>
|
||
)}
|
||
</div>
|
||
</Card>
|
||
|
||
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
|
||
<Row label="AF">
|
||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||
</Row>
|
||
<Row label="RF">
|
||
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
|
||
</Row>
|
||
<Row label={t('icmp.squelch')}>
|
||
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
|
||
</Row>
|
||
<Row label="AGC">
|
||
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
|
||
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
|
||
</Row>
|
||
<Row label={t('icmp.preamp')}>
|
||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||
</Row>
|
||
<Row label="Att">
|
||
<Segmented value={String(st.att)} options={attOptions(cat?.rig)}
|
||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||
</Row>
|
||
<Row label={t('icmp.filter')}>
|
||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||
</Row>
|
||
</Card>
|
||
|
||
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
|
||
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
|
||
<Row label="PBT-IN">
|
||
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
|
||
</Row>
|
||
<Row label="PBT-OUT">
|
||
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
|
||
</Row>
|
||
<button type="button"
|
||
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
|
||
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
|
||
{t('icmp.pbtCenter')}
|
||
</button>
|
||
<div className="pt-1 border-t border-border/60 space-y-3">
|
||
<div className="flex items-center gap-2">
|
||
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
|
||
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
|
||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
|
||
</div>
|
||
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
|
||
</div>
|
||
</Card>
|
||
|
||
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
|
||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||
<div className="flex items-center gap-2">
|
||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||
<span className="text-xs text-muted-foreground">{t('icmp.autoNotch')}</span>
|
||
</div>
|
||
{/* APF (audio peak filter) — CW only: peaks the CW tone. */}
|
||
{isCW && (
|
||
<div className="flex items-center gap-2">
|
||
<Chip label="APF" on={st.apf} onClick={() => set({ apf: !st.apf }, () => IcomSetAPF(!st.apf))} />
|
||
<span className="text-xs text-muted-foreground">{t('icmp.apf')}</span>
|
||
</div>
|
||
)}
|
||
</Card>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|