feat: added RIT and XIT for Icom

This commit is contained in:
2026-07-05 13:21:11 +02:00
parent e112de5967
commit 8cf53a0aa2
10 changed files with 430 additions and 64 deletions
+241 -58
View File
@@ -1,11 +1,12 @@
import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react';
import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal } from 'lucide-react';
import {
GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
IcomSetRIT, IcomSetRITOn, IcomSetXITOn,
} from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
@@ -19,6 +20,7 @@ type IcomState = {
af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number;
rit_hz: number; rit_on: boolean; xit_on: boolean;
};
const ZERO: IcomState = {
@@ -27,6 +29,7 @@ const ZERO: IcomState = {
af_gain: 0, rf_gain: 0,
nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1,
rit_hz: 0, rit_on: false, xit_on: false,
};
function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
@@ -145,6 +148,58 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string;
return <div className="flex items-center gap-2">{body}</div>;
}
// HdrMeter — a compact live meter for the model header band (S when receiving,
// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx.
function HdrMeter({ 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-5 shrink-0 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<div className="w-24 sm:w-32 h-2 rounded-full bg-muted/70 overflow-hidden">
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div>
<span className="w-12 text-right text-[11px] font-mono font-bold tabular-nums" style={{ color: accent }}>{scale}</span>
</>
);
if (onClick) return <button type="button" onClick={onClick} title={title} className="flex items-center gap-1.5 rounded-md hover:bg-muted/60 px-1.5 py-0.5 -my-0.5">{body}</button>;
return <div className="flex items-center gap-1.5 px-1.5">{body}</div>;
}
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
label: string; on: boolean; hz: number; accent: string;
onToggle: () => void; onDelta: (d: number) => void; onClear: () => void;
}) {
const ref = useRef<HTMLDivElement>(null);
const cb = useRef(onDelta); cb.current = onDelta;
useEffect(() => {
const el = ref.current;
if (!el) return;
const onWheel = (e: WheelEvent) => { e.preventDefault(); cb.current(e.deltaY < 0 ? 10 : -10); };
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, []);
return (
<div className="flex items-center gap-2">
<Chip on={on} onClick={onToggle} label={label} />
<div ref={ref} title="Wheel / ± to shift"
className={cn('flex-1 flex items-center justify-between rounded-md border px-1 py-0.5 select-none cursor-ns-resize',
on ? 'border-border bg-muted/40' : 'border-border/60 bg-muted/20 opacity-60')}>
<button type="button" onClick={() => onDelta(-10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground"></button>
<span className="text-sm font-mono font-bold tabular-nums" style={{ color: on ? accent : undefined }}>
{hz > 0 ? '+' : hz < 0 ? '' : ''}{Math.abs(hz)} Hz
</span>
<button type="button" onClick={() => onDelta(10)} className="px-2 text-sm font-bold text-muted-foreground hover:text-foreground">+</button>
</div>
<button type="button" onClick={onClear}
className="w-8 shrink-0 py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">0</button>
</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.
@@ -157,15 +212,36 @@ function sParts(v: number): { s: number; over: number; label: string } {
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];
}
// ScopePanadapter — enables the rig's spectrum-scope stream and draws the
// reassembled sweep on a canvas. The amplitude array is raw rig scale (~0-160);
// we normalise to the tallest recent peak so the trace fills the height.
// reassembled sweep as a modern SDR panadapter: a glowing filled spectrum trace
// on top and a scrolling colour waterfall below. Amplitudes are raw rig scale
// (~0-160), normalised to the tallest recent peak so the trace fills the height.
function ScopePanadapter() {
const { t } = useI18n();
const [on, setOn] = useState(false);
const [fixed, setFixed] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null);
const wfRef = useRef<HTMLCanvasElement>(null); // waterfall
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const holdRef = useRef<number[]>([]); // per-bin peak-hold line
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
@@ -198,7 +274,7 @@ function ScopePanadapter() {
vfo = cs?.split && cs.freq_rx_hz ? cs.freq_rx_hz : (cs?.freq_hz || 0);
} catch {}
if (vfo > 0) vfoRef.current = vfo;
draw(sw.amp, sw.low_hz, sw.high_hz, vfo);
draw(sw.amp, sw.low_hz, sw.high_hz, vfoRef.current, sw.fixed);
}
} catch {}
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
@@ -236,7 +312,7 @@ function ScopePanadapter() {
return () => el.removeEventListener('wheel', onWheel);
}, [on]);
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => {
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number, fixedMode: boolean) => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
@@ -245,66 +321,133 @@ function ScopePanadapter() {
const ctx = cv.getContext('2d');
if (!ctx) return;
ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
ctx.clearRect(0, 0, w, h);
// Auto-scale: track the peak, decaying slowly so the floor doesn't jump.
const peak = Math.max(...amp);
peakRef.current = Math.max(peak, peakRef.current * 0.95, 40);
const scale = peakRef.current;
const n = amp.length;
// Background — deep navy vertical gradient.
const bg = ctx.createLinearGradient(0, 0, 0, h);
bg.addColorStop(0, '#0b1220'); bg.addColorStop(1, '#05070e');
ctx.fillStyle = bg; ctx.fillRect(0, 0, w, h);
// Grid.
ctx.strokeStyle = 'rgba(120,120,120,0.15)';
ctx.strokeStyle = 'rgba(120,150,200,0.08)';
ctx.lineWidth = 1;
for (let i = 1; i < 4; i++) { const y = (h * i) / 4; ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke(); }
for (let i = 1; i < 8; i++) { const x = (w * i) / 8; ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke(); }
// Spectrum trace, filled under the line.
const n = amp.length;
const xOf = (i: number) => (i / (n - 1)) * w;
const yOf = (v: number) => h - Math.min(1, v / scale) * h;
// Peak-hold line (slow decay) — a faint ghost of recent maxima.
const hold = holdRef.current;
if (hold.length !== n) hold.length = n, hold.fill(0);
for (let i = 0; i < n; i++) hold[i] = Math.max(amp[i], hold[i] * 0.92);
// Filled spectrum area.
ctx.beginPath();
ctx.moveTo(0, h);
for (let i = 0; i < n; i++) {
const x = (i / (n - 1)) * w;
const y = h - Math.min(1, amp[i] / scale) * h;
ctx.lineTo(x, y);
}
ctx.lineTo(w, h);
ctx.closePath();
for (let i = 0; i < n; i++) ctx.lineTo(xOf(i), yOf(amp[i]));
ctx.lineTo(w, h); ctx.closePath();
const grad = ctx.createLinearGradient(0, 0, 0, h);
grad.addColorStop(0, 'rgba(56,189,248,0.55)');
grad.addColorStop(1, 'rgba(56,189,248,0.05)');
ctx.fillStyle = grad;
ctx.fill();
ctx.strokeStyle = '#38bdf8';
ctx.lineWidth = 1.25;
ctx.stroke();
grad.addColorStop(0, 'rgba(56,189,248,0.40)');
grad.addColorStop(1, 'rgba(56,189,248,0.02)');
ctx.fillStyle = grad; ctx.fill();
// VFO marker: a vertical line at the current frequency within the span.
if (vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz) {
const x = ((vfoHz - lowHz) / (highHz - lowHz)) * w;
ctx.strokeStyle = 'rgba(239,68,68,0.95)';
ctx.lineWidth = 1.5;
// Peak-hold trace (thin, faint).
ctx.beginPath();
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(hold[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = 'rgba(148,197,255,0.35)'; ctx.lineWidth = 1; ctx.stroke();
// Live spectrum trace with a soft glow.
ctx.save();
ctx.shadowColor = 'rgba(56,189,248,0.7)'; ctx.shadowBlur = 6;
ctx.beginPath();
for (let i = 0; i < n; i++) { const x = xOf(i), y = yOf(amp[i]); i === 0 ? ctx.moveTo(x, y) : ctx.lineTo(x, y); }
ctx.strokeStyle = '#7dd3fc'; ctx.lineWidth = 1.5; ctx.lineJoin = 'round'; ctx.stroke();
ctx.restore();
// VFO marker: translucent band + crisp line + top marker triangle. In centre
// mode the span tracks the VFO, so fall back to the exact centre when the
// reported freq isn't inside the (just-updated) span — you always see where
// you are.
const inSpan = vfoHz > 0 && lowHz > 0 && highHz > lowHz && vfoHz >= lowHz && vfoHz <= highHz;
const markerX = inSpan ? ((vfoHz - lowHz) / (highHz - lowHz)) * w : (!fixedMode ? w / 2 : -1);
if (markerX >= 0) {
const x = markerX;
ctx.fillStyle = 'rgba(244,63,94,0.10)'; ctx.fillRect(x - 5, 0, 10, h);
ctx.strokeStyle = 'rgba(244,63,94,0.9)'; ctx.lineWidth = 1.25;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
ctx.fillStyle = 'rgba(244,63,94,0.95)';
ctx.beginPath(); ctx.moveTo(x - 4, 0); ctx.lineTo(x + 4, 0); ctx.lineTo(x, 6); ctx.closePath(); ctx.fill();
}
// Frequency scale (edges + centre), only when the rig reported the span.
if (lowHz > 0 && highHz > lowHz) {
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
ctx.fillStyle = 'rgba(226,232,240,0.75)';
ctx.font = '10px ui-monospace, monospace';
ctx.textBaseline = 'bottom';
ctx.textAlign = 'left'; ctx.fillText(mhz(lowHz), 3, h - 2);
ctx.textAlign = 'center'; ctx.fillText(mhz((lowHz + highHz) / 2), w / 2, h - 2);
ctx.textAlign = 'right'; ctx.fillText(mhz(highHz), w - 3, h - 2);
// Frequency scale. In fixed mode the rig reports usable edge frequencies, so
// we label low/centre/high from them. In centre mode the header frame's edge
// pair isn't a usable low..high range, but the scope is centred on the VFO —
// so we always label the centre with the live VFO frequency (which we fetch
// each sweep), and only add edge labels when the reported edges genuinely
// bracket the VFO. That guarantees you always see your frequency in CTR.
const mhz = (hz: number) => (hz / 1e6).toFixed(3);
ctx.font = '10px ui-monospace, monospace';
ctx.textBaseline = 'bottom';
ctx.shadowColor = 'rgba(0,0,0,0.8)'; ctx.shadowBlur = 3;
ctx.fillStyle = 'rgba(226,232,240,0.85)';
const label = (txt: string, x: number, align: CanvasTextAlign) => { ctx.textAlign = align; ctx.fillText(txt, x, h - 3); };
const validEdges = lowHz > 0 && highHz > lowHz;
if (fixedMode) {
if (validEdges) {
label(mhz(lowHz), 4, 'left');
label(mhz((lowHz + highHz) / 2), w / 2, 'center');
label(mhz(highHz), w - 4, 'right');
}
} else {
if (validEdges && vfoHz >= lowHz && vfoHz <= highHz) {
label(mhz(lowHz), 4, 'left');
label(mhz(highHz), w - 4, 'right');
}
if (vfoHz > 0) label(mhz(vfoHz), w / 2, 'center');
}
ctx.shadowBlur = 0;
drawWaterfall(amp, scale);
};
// drawWaterfall scrolls the history down one row and paints the newest sweep
// as a colour-mapped line at the top.
const drawWaterfall = (amp: number[], scale: number) => {
const cv = wfRef.current;
if (!cv) return;
const w = Math.max(1, cv.clientWidth), h = Math.max(1, cv.clientHeight);
if (cv.width !== w || cv.height !== h) { cv.width = w; cv.height = h; }
const ctx = cv.getContext('2d');
if (!ctx) return;
// Scroll everything down by one pixel row.
ctx.drawImage(cv, 0, 0, w, h - 1, 0, 1, w, h - 1);
// Paint the new top row.
const row = ctx.createImageData(w, 1);
const n = amp.length;
for (let x = 0; x < w; x++) {
const i = Math.min(n - 1, Math.round((x / (w - 1)) * (n - 1)));
const [r, g, b] = wfColor(amp[i] / scale);
const o = x * 4;
row.data[o] = r; row.data[o + 1] = g; row.data[o + 2] = b; row.data[o + 3] = 255;
}
ctx.putImageData(row, 0, 0);
};
// Collapsible card: when the scope is off, only the header band shows (the
// canvas is hidden entirely) so it doesn't waste vertical space. The CTR/FIX
// and ON/OFF controls live in the header itself.
return (
<Card icon={Activity} title={t('icmp.spectrum')} accent="#38bdf8">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground truncate">
{on ? (fixed ? t('icmp.scopeFixed') : t('icmp.scopeCenter')) : t('icmp.scopeOff')}
</span>
<div className="flex items-center gap-2 shrink-0">
<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">
<Activity className="size-4" style={{ color: '#38bdf8' }} />
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{t('icmp.spectrum')}</span>
<div className="ml-auto flex items-center gap-2 shrink-0">
{on && (
<Segmented value={fixed ? 'FIX' : 'CTR'} options={[{ v: 'CTR', l: 'CTR' }, { v: 'FIX', l: 'FIX' }]}
onChange={(v) => setMode(v === 'FIX')} />
@@ -312,11 +455,16 @@ function ScopePanadapter() {
<Chip label={on ? 'ON' : 'OFF'} on={on} onClick={toggle} />
</div>
</div>
<div className="rounded-lg border border-border bg-black/70 overflow-hidden">
<canvas ref={canvasRef} onDoubleClick={onDblClick}
className="w-full block cursor-crosshair" style={{ height: 150 }} />
</div>
</Card>
{on && (
<div className="p-3">
<div className="rounded-xl overflow-hidden ring-1 ring-sky-500/20 shadow-lg shadow-sky-500/5 bg-[#05070e]">
<canvas ref={canvasRef} onDoubleClick={onDblClick}
className="w-full block cursor-crosshair" style={{ height: 140 }} />
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
</div>
</div>
)}
</div>
);
}
@@ -330,6 +478,7 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
const [busy, setBusy] = useState(false);
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(() => {});
const refresh = async () => {
@@ -364,6 +513,27 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
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">
@@ -389,6 +559,16 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
{st.split ? <span className="rounded-md bg-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null}
</div>
{/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
<div className="hidden md:flex items-center">
{tx ? (
<HdrMeter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
) : (() => { const sp = sParts(st.s_meter); return (
<HdrMeter 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} />
); })()}
</div>
<button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
@@ -399,18 +579,21 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Live meters. */}
<Card icon={Zap} title={t('icmp.meters')} accent="#f59e0b">
{tx ? (
<>
{/* 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))}
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
<ShiftRow label="ΔTX" accent="#f59e0b" on={st.xit_on} hz={st.rit_hz}
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
{tx && (
<div className="pt-1 border-t border-border/60 space-y-3">
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
</>
) : (() => { 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} />
); })()}
</div>
)}
</Card>
{/* Transmit controls. */}