feat: added RIT and XIT for Icom
This commit is contained in:
@@ -7842,6 +7842,30 @@ func (a *App) IcomSetScopeMode(fixed bool) error {
|
||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
|
||||
}
|
||||
|
||||
// IcomSetRIT sets the RIT/ΔTX offset in signed Hz.
|
||||
func (a *App) IcomSetRIT(hz int) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRIT(hz) })
|
||||
}
|
||||
|
||||
// IcomSetRITOn toggles RIT.
|
||||
func (a *App) IcomSetRITOn(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRITOn(on) })
|
||||
}
|
||||
|
||||
// IcomSetXITOn toggles ΔTX (XIT).
|
||||
func (a *App) IcomSetXITOn(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
}
|
||||
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetXITOn(on) })
|
||||
}
|
||||
|
||||
func (a *App) IcomSetPTT(on bool) error {
|
||||
if a.cat == nil {
|
||||
return fmt.Errorf("cat not initialized")
|
||||
|
||||
@@ -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) {
|
||||
// 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.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);
|
||||
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">
|
||||
{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: 150 }} />
|
||||
className="w-full block cursor-crosshair" style={{ height: 140 }} />
|
||||
<canvas ref={wfRef} className="w-full block" style={{ height: 96 }} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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. */}
|
||||
|
||||
@@ -183,7 +183,7 @@ const en: Dict = {
|
||||
'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.',
|
||||
'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay',
|
||||
'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter',
|
||||
'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active',
|
||||
'rst.clickToFill': 'Click to set RST tx from the signal',
|
||||
'qrz.openTitle': 'Open {call} on QRZ.com',
|
||||
// Misc panels/modals (alerts / send-spot / net / udp / filter / details)
|
||||
@@ -359,7 +359,7 @@ const fr: Dict = {
|
||||
'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.',
|
||||
'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai',
|
||||
'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto',
|
||||
'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif',
|
||||
'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal',
|
||||
'qrz.openTitle': 'Ouvrir {call} sur QRZ.com',
|
||||
'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer',
|
||||
|
||||
Vendored
+6
@@ -370,12 +370,18 @@ export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFPower(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRIT(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScope(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetScopeMode(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetSplit(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetXITOn(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomTune():Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
@@ -702,6 +702,14 @@ export function IcomSetRFPower(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRFPower'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetRIT(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRIT'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetRITOn(arg1) {
|
||||
return window['go']['main']['App']['IcomSetRITOn'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetScope(arg1) {
|
||||
return window['go']['main']['App']['IcomSetScope'](arg1);
|
||||
}
|
||||
@@ -714,6 +722,10 @@ export function IcomSetSplit(arg1) {
|
||||
return window['go']['main']['App']['IcomSetSplit'](arg1);
|
||||
}
|
||||
|
||||
export function IcomSetXITOn(arg1) {
|
||||
return window['go']['main']['App']['IcomSetXITOn'](arg1);
|
||||
}
|
||||
|
||||
export function IcomTune() {
|
||||
return window['go']['main']['App']['IcomTune']();
|
||||
}
|
||||
|
||||
@@ -676,6 +676,9 @@ export namespace cat {
|
||||
s_meter: number;
|
||||
power_meter: number;
|
||||
swr_meter: number;
|
||||
rit_hz: number;
|
||||
rit_on: boolean;
|
||||
xit_on: boolean;
|
||||
rf_power: number;
|
||||
mic_gain: number;
|
||||
af_gain: number;
|
||||
@@ -704,6 +707,9 @@ export namespace cat {
|
||||
this.s_meter = source["s_meter"];
|
||||
this.power_meter = source["power_meter"];
|
||||
this.swr_meter = source["swr_meter"];
|
||||
this.rit_hz = source["rit_hz"];
|
||||
this.rit_on = source["rit_on"];
|
||||
this.xit_on = source["xit_on"];
|
||||
this.rf_power = source["rf_power"];
|
||||
this.mic_gain = source["mic_gain"];
|
||||
this.af_gain = source["af_gain"];
|
||||
|
||||
@@ -382,6 +382,10 @@ type IcomTXState struct {
|
||||
SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120)
|
||||
PowerMeter int `json:"power_meter"` // 0-100 (TX Po)
|
||||
SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR)
|
||||
// RIT / ΔTX (XIT).
|
||||
RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz
|
||||
RITOn bool `json:"rit_on"`
|
||||
XITOn bool `json:"xit_on"`
|
||||
// Set controls.
|
||||
RFPower int `json:"rf_power"` // 0-100 (TX output)
|
||||
MicGain int `json:"mic_gain"` // 0-100
|
||||
@@ -422,6 +426,9 @@ type IcomController interface {
|
||||
SetScope(bool) error // enable/disable the spectrum-scope waveform stream
|
||||
SetScopeMode(bool) error // true = fixed span, false = center-on-VFO
|
||||
ScopeData() ScopeSweep // latest assembled sweep (empty until enabled)
|
||||
SetRIT(int) error // RIT/ΔTX offset in signed Hz
|
||||
SetRITOn(bool) error // RIT on/off
|
||||
SetXITOn(bool) error // ΔTX (XIT) on/off
|
||||
}
|
||||
|
||||
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
|
||||
|
||||
@@ -44,6 +44,11 @@ const (
|
||||
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte)
|
||||
CmdATU = 0x1C // sub 0x01 = antenna tuner (0x00 off, 0x01 through, 0x02 tune)
|
||||
CmdScope = 0x27 // spectrum-scope waveform stream (sub 0x00 = data, 0x11 = on/off)
|
||||
CmdRIT = 0x21 // RIT/ΔTX: sub 0x00 offset freq, 0x01 RIT on/off, 0x02 ΔTX(XIT) on/off
|
||||
|
||||
SubRITFreq = 0x00 // RIT/ΔTX offset: 2 BCD bytes (LE, 0-9999) + sign byte (00 +, 01 -)
|
||||
SubRITOn = 0x01 // RIT on/off (00/01)
|
||||
SubXITOn = 0x02 // ΔTX (XIT) on/off (00/01)
|
||||
|
||||
SubDataMode = 0x06
|
||||
SubPTT = 0x00
|
||||
@@ -154,6 +159,38 @@ func BCDToLevel(b []byte) int {
|
||||
return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F)
|
||||
}
|
||||
|
||||
// RITToBCD encodes a RIT/ΔTX offset (Hz, −9999..9999) as the 3 bytes CI-V
|
||||
// command 0x21 0x00 uses: 2 little-endian BCD bytes of the magnitude followed by
|
||||
// a sign byte (0x00 positive, 0x01 negative).
|
||||
func RITToBCD(hz int) []byte {
|
||||
neg := hz < 0
|
||||
if neg {
|
||||
hz = -hz
|
||||
}
|
||||
if hz > 9999 {
|
||||
hz = 9999
|
||||
}
|
||||
lo := byte(hz%10 | (hz/10%10)<<4)
|
||||
hi := byte(hz/100%10 | (hz/1000%10)<<4)
|
||||
sign := byte(0)
|
||||
if neg {
|
||||
sign = 1
|
||||
}
|
||||
return []byte{lo, hi, sign}
|
||||
}
|
||||
|
||||
// BCDToRIT decodes the 3 offset bytes of a 0x21 0x00 response back to signed Hz.
|
||||
func BCDToRIT(b []byte) int {
|
||||
if len(b) < 3 {
|
||||
return 0
|
||||
}
|
||||
v := int(b[0]&0x0F) + int(b[0]>>4)*10 + int(b[1]&0x0F)*100 + int(b[1]>>4)*1000
|
||||
if b[2] != 0 {
|
||||
return -v
|
||||
}
|
||||
return v
|
||||
}
|
||||
|
||||
// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the
|
||||
// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…).
|
||||
func ByteToBCD(v int) byte {
|
||||
|
||||
@@ -277,6 +277,12 @@ func (b *IcomSerial) SetPTT(on bool) error {
|
||||
// ── helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
func (b *IcomSerial) write(payload ...byte) error {
|
||||
// Not connected (rig off / port dropped): fail cleanly instead of
|
||||
// dereferencing a nil port — a Set* dispatched while disconnected (e.g.
|
||||
// clicking Scope ON with the radio off) would otherwise panic the app.
|
||||
if b.port == nil {
|
||||
return fmt.Errorf("icom: not connected")
|
||||
}
|
||||
// Drop any stale/unsolicited frames buffered from before this command so
|
||||
// recv() only sees the reply to THIS request (avoids a previous command's ack
|
||||
// or an unsolicited dial-turn update being mistaken for our response).
|
||||
@@ -514,6 +520,68 @@ func (b *IcomSerial) SetScopeMode(fixed bool) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetRIT sets the RIT/ΔTX offset (signed Hz, ±9999).
|
||||
func (b *IcomSerial) SetRIT(hz int) error {
|
||||
if err := b.exec(append([]byte{civ.CmdRIT, civ.SubRITFreq}, civ.RITToBCD(hz)...)...); err != nil {
|
||||
return err
|
||||
}
|
||||
if hz < -9999 {
|
||||
hz = -9999
|
||||
}
|
||||
if hz > 9999 {
|
||||
hz = 9999
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.RITHz = hz })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetRITOn(on bool) error {
|
||||
if err := b.exec(civ.CmdRIT, civ.SubRITOn, boolByte(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.RITOn = on })
|
||||
return nil
|
||||
}
|
||||
|
||||
func (b *IcomSerial) SetXITOn(on bool) error {
|
||||
if err := b.exec(civ.CmdRIT, civ.SubXITOn, boolByte(on)); err != nil {
|
||||
return err
|
||||
}
|
||||
b.setCache(func(s *IcomTXState) { s.XITOn = on })
|
||||
return nil
|
||||
}
|
||||
|
||||
// readRIT reads the offset + RIT/ΔTX on-off flags into st (best-effort).
|
||||
func (b *IcomSerial) readRIT(st *IcomTXState) {
|
||||
if err := b.write(civ.CmdRIT, civ.SubRITFreq); err == nil {
|
||||
if f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == civ.CmdRIT && len(d.Data) >= 4 && d.Data[0] == civ.SubRITFreq
|
||||
}); err == nil {
|
||||
st.RITHz = civ.BCDToRIT(f.Data[1:4])
|
||||
}
|
||||
}
|
||||
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubRITOn); ok {
|
||||
st.RITOn = v != 0
|
||||
}
|
||||
if v, ok := b.readSwitchSub(civ.CmdRIT, civ.SubXITOn); ok {
|
||||
st.XITOn = v != 0
|
||||
}
|
||||
}
|
||||
|
||||
// readSwitchSub reads a 1-byte on/off value for cmd+sub (generalises readSwitch).
|
||||
func (b *IcomSerial) readSwitchSub(cmd, sub byte) (byte, bool) {
|
||||
if err := b.write(cmd, sub); err != nil {
|
||||
return 0, false
|
||||
}
|
||||
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
|
||||
return d.Cmd == cmd && len(d.Data) >= 2 && d.Data[0] == sub
|
||||
})
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return f.Data[1], true
|
||||
}
|
||||
|
||||
// ScopeData returns a copy of the latest reassembled sweep as a number array.
|
||||
func (b *IcomSerial) ScopeData() ScopeSweep {
|
||||
b.scopeMu.Lock()
|
||||
@@ -743,6 +811,7 @@ func (b *IcomSerial) readDSP() {
|
||||
if _, f, ok := b.readModeFilter(); ok {
|
||||
st.Filter = int(f)
|
||||
}
|
||||
b.readRIT(&st)
|
||||
|
||||
b.dspMu.Lock()
|
||||
b.dsp = st
|
||||
|
||||
@@ -60,6 +60,7 @@ type Decoder struct {
|
||||
state bool // true = mark (key down)
|
||||
stateHops int
|
||||
dotHops float64 // adaptive dot length, in hops
|
||||
markCount int // marks seen since lock (fast WPM adaptation while small)
|
||||
elem []byte // current "." / "-" run for the in-progress character
|
||||
charEmitted bool
|
||||
wordEmitted bool
|
||||
@@ -145,6 +146,7 @@ func (d *Decoder) Reset() {
|
||||
d.state = false
|
||||
d.stateHops = 0
|
||||
d.dotHops = 15
|
||||
d.markCount = 0
|
||||
d.elem = d.elem[:0]
|
||||
d.charEmitted, d.wordEmitted = false, false
|
||||
}
|
||||
@@ -213,10 +215,11 @@ func (d *Decoder) analyze() {
|
||||
// Tiered acquisition: a clearly strong tone locks on the FIRST hop (so we
|
||||
// don't eat the first element of a strong signal), a marginal/weak tone
|
||||
// locks after a couple of stable hops (so we don't lock onto pure noise).
|
||||
if snr > d.strongSNR || (d.candHops >= 3 && snr > d.acqSNR) {
|
||||
if snr > d.strongSNR || (d.candHops >= 2 && snr > d.acqSNR) {
|
||||
d.lockIdx = maxIdx
|
||||
d.peak, d.floor = maxMag, d.noise // seed the envelope to this bin
|
||||
d.quietHops = 0
|
||||
d.markCount = 0 // relearn WPM fast for this new signal
|
||||
}
|
||||
}
|
||||
if d.lockIdx >= 0 {
|
||||
@@ -267,6 +270,17 @@ func (d *Decoder) step() {
|
||||
} else {
|
||||
d.quietHops++
|
||||
if d.quietHops > d.relockHops {
|
||||
// End of the over: flush any pending character and drop a word
|
||||
// space so the next transmission starts a fresh word (the word-gap
|
||||
// timer above can't fire once the lock is gone).
|
||||
if len(d.elem) > 0 && !d.charEmitted {
|
||||
d.flushChar()
|
||||
d.charEmitted = true
|
||||
}
|
||||
if !d.wordEmitted && d.onChar != nil {
|
||||
d.onChar(" ")
|
||||
d.wordEmitted = true
|
||||
}
|
||||
d.lockIdx, d.candIdx, d.candHops = -1, -1, 0
|
||||
}
|
||||
}
|
||||
@@ -311,9 +325,17 @@ func (d *Decoder) endMark(hops int) {
|
||||
}
|
||||
|
||||
// adaptDot nudges the dot-length estimate toward an observation (EMA, clamped
|
||||
// to ~5–100 WPM).
|
||||
// to ~5–100 WPM). The first marks of a new signal adapt FAST so the WPM (and
|
||||
// therefore the character/word gap thresholds) converge within the first
|
||||
// character or two — otherwise a wrong seed mis-times early gaps and runs
|
||||
// characters/words together.
|
||||
func (d *Decoder) adaptDot(obs float64) {
|
||||
d.dotHops = d.dotHops*0.8 + obs*0.2 // slower: a few odd marks can't yank it
|
||||
alpha := 0.2
|
||||
if d.markCount < 6 {
|
||||
alpha = 0.5 // fast convergence on the opening marks
|
||||
}
|
||||
d.markCount++
|
||||
d.dotHops = d.dotHops*(1-alpha) + obs*alpha
|
||||
if d.dotHops < 5 { // 5 hops ≈ 60 WPM ceiling — never 100
|
||||
d.dotHops = 5
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user