feat: icom Scope in Icom Tab

This commit is contained in:
2026-07-04 22:06:26 +02:00
parent abbdde9367
commit dd4b0004a5
8 changed files with 918 additions and 56 deletions
+60
View File
@@ -7540,6 +7540,66 @@ func (a *App) IcomSetFilter(n int) error {
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomFilter(n) }) return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomFilter(n) })
} }
func (a *App) IcomSetRFPower(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFPower(p) })
}
func (a *App) IcomSetMicGain(p int) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMicGain(p) })
}
func (a *App) IcomSetSplit(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplit(on) })
}
func (a *App) IcomTune() error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.TuneATU() })
}
// IcomSetScope enables/disables the spectrum-scope waveform stream.
func (a *App) IcomSetScope(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScope(on) })
}
// IcomScopeData returns the latest reassembled spectrum sweep for the panadapter.
func (a *App) IcomScopeData() cat.ScopeSweep {
if a.cat == nil {
return cat.ScopeSweep{}
}
sw, _ := a.cat.IcomScope()
return sw
}
// IcomSetScopeMode selects fixed-span (true) or center-on-VFO (false) scope mode.
func (a *App) IcomSetScopeMode(fixed bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetScopeMode(fixed) })
}
func (a *App) IcomSetPTT(on bool) error {
if a.cat == nil {
return fmt.Errorf("cat not initialized")
}
return a.cat.SetPTT(on)
}
func (a *App) FlexSetPower(p int) error { func (a *App) FlexSetPower(p int) error {
if a.cat == nil { if a.cat == nil {
return fmt.Errorf("cat not initialized") return fmt.Errorf("cat not initialized")
+296 -12
View File
@@ -1,31 +1,60 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { Radio, AudioLines, RefreshCw } from 'lucide-react'; import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react';
import { import {
GetIcomState, IcomRefresh, GetIcomState, IcomRefresh,
IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel, IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel,
IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter, IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter,
IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT,
IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
type IcomState = { type IcomState = {
available: boolean; model?: string; mode?: string; available: boolean; model?: string; mode?: string;
transmitting: boolean; split: boolean;
s_meter: number; power_meter: number; swr_meter: number;
rf_power: number; mic_gain: number;
af_gain: number; rf_gain: number; af_gain: number; rf_gain: number;
nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean;
agc?: string; preamp: number; att: number; filter: number; agc?: string; preamp: number; att: number; filter: number;
}; };
const ZERO: IcomState = { const ZERO: IcomState = {
available: false, af_gain: 0, rf_gain: 0, 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, nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false,
preamp: 0, att: 0, filter: 1, preamp: 0, att: 0, filter: 1,
}; };
function Slider({ value, onChange, disabled, accent = '#2563eb' }: { function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: {
value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number;
}) { }) {
const v = Math.max(0, Math.min(100, value)); 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 ( return (
<input <input
ref={ref}
type="range" min={0} max={100} value={v} disabled={disabled} type="range" min={0} max={100} value={v} disabled={disabled}
onChange={(e) => onChange(parseInt(e.target.value, 10))} 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', className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
@@ -95,13 +124,200 @@ function Row({ label, children }: { label: string; children: React.ReactNode })
); );
} }
// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend. // Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR).
// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects function Meter({ label, value, accent, scale }: { label: string; value: number; accent: string; scale?: string }) {
// the last refresh plus optimistic updates. Front-panel knob changes show after const v = Math.max(0, Math.min(100, value));
// the next ↻ Refresh. return (
<div className="flex items-center gap-2">
<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>
</div>
);
}
// S-meter raw 0-100 → S-unit label (S9 ≈ 47% on the CI-V 0-255 scale; above = +dB).
function sUnit(v: number): string {
if (v >= 47) {
const over = Math.round((v - 47) / 8.8) * 10;
return over > 0 ? `S9+${over}` : 'S9';
}
return `S${Math.max(0, Math.min(9, Math.round(v / 5.2)))}`;
}
// 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.
function ScopePanadapter() {
const [on, setOn] = useState(false);
const [fixed, setFixed] = useState(true);
const canvasRef = useRef<HTMLCanvasElement>(null);
const peakRef = useRef(160); // running amplitude ceiling for auto-scale
const spanRef = useRef({ low: 0, high: 0 }); // latest sweep edges, for click-to-tune
const vfoRef = useRef(0); // latest VFO frequency, for wheel-tune
const toggle = () => {
const next = !on;
setOn(next);
IcomSetScope(next).catch(() => {});
};
const setMode = (nextFixed: boolean) => {
setFixed(nextFixed);
IcomSetScopeMode(nextFixed).catch(() => {});
};
// Stop the stream when the panel unmounts.
useEffect(() => () => { IcomSetScope(false).catch(() => {}); }, []);
useEffect(() => {
if (!on) return;
let raf = 0, lastSeq = -1, alive = true;
const tick = async () => {
if (!alive) return;
try {
const sw = await IcomScopeData();
if (sw && sw.seq !== lastSeq && sw.amp && sw.amp.length) {
lastSeq = sw.seq;
setFixed(sw.fixed);
spanRef.current = { low: sw.low_hz, high: sw.high_hz };
let vfo = 0;
try {
const cs = await GetCATState();
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);
}
} catch {}
if (alive) raf = window.setTimeout(() => { raf = requestAnimationFrame(tick); }, 40) as unknown as number;
};
raf = requestAnimationFrame(tick);
return () => { alive = false; cancelAnimationFrame(raf); window.clearTimeout(raf); };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [on]);
// Double-click tunes the rig to the clicked frequency.
const onDblClick = (e: React.MouseEvent<HTMLCanvasElement>) => {
const cv = canvasRef.current;
const { low, high } = spanRef.current;
if (!cv || !(low > 0 && high > low)) return;
const rect = cv.getBoundingClientRect();
const frac = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
const hz = Math.round((low + frac * (high - low)) / 100) * 100; // nearest 100 Hz
SetCATFrequency(hz).catch(() => {});
};
// Mouse-wheel over the scope QSYs ±100 Hz. Non-passive listener so we can
// preventDefault (else the page scrolls); optimistic vfoRef so quick spins
// accumulate before the poll reconciles.
useEffect(() => {
const el = canvasRef.current;
if (!el || !on) return;
const onWheel = (e: WheelEvent) => {
if (!vfoRef.current) return;
e.preventDefault();
const next = vfoRef.current + (e.deltaY < 0 ? 100 : -100);
vfoRef.current = next;
SetCATFrequency(next).catch(() => {});
};
el.addEventListener('wheel', onWheel, { passive: false });
return () => el.removeEventListener('wheel', onWheel);
}, [on]);
const draw = (amp: number[], lowHz: number, highHz: number, vfoHz: number) => {
const cv = canvasRef.current;
if (!cv) return;
const dpr = window.devicePixelRatio || 1;
const w = cv.clientWidth, h = cv.clientHeight;
if (cv.width !== w * dpr || cv.height !== h * dpr) { cv.width = w * dpr; cv.height = h * dpr; }
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;
// Grid.
ctx.strokeStyle = 'rgba(120,120,120,0.15)';
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;
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();
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();
// 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;
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
}
// 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);
}
};
return (
<Card icon={Activity} title="Spectrum" accent="#38bdf8">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] text-muted-foreground truncate">
{on ? (fixed ? 'Fixed — double-click / wheel to tune' : 'Center — follows VFO') : 'Scope off'}
</span>
<div className="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')} />
)}
<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>
);
}
// IcomPanel — full control surface (RX DSP + TX) for an Icom on the CI-V backend.
// Unlike the Flex (which pushes state), the Icom is polled: meters/TX state are
// read every cache cycle; DSP set-controls are optimistic and reconcile on the
// next poll. Front-panel knob changes for DSP show after ↻ Refresh.
export function IcomPanel() { export function IcomPanel() {
const [st, setSt] = useState<IcomState>(ZERO); const [st, setSt] = useState<IcomState>(ZERO);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const [tuning, setTuning] = useState(false);
const txRef = useRef(false);
const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {});
const refresh = async () => { const refresh = async () => {
@@ -113,7 +329,7 @@ export function IcomPanel() {
useEffect(() => { useEffect(() => {
refresh(); refresh();
const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state) const id = window.setInterval(load, 500); // fast poll so meters/TX feel live
return () => window.clearInterval(id); return () => window.clearInterval(id);
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
@@ -124,6 +340,18 @@ export function IcomPanel() {
fn().catch(() => {}); fn().catch(() => {});
}; };
const toggleMox = () => {
const next = !txRef.current;
txRef.current = next;
set({ transmitting: next }, () => IcomSetPTT(next));
};
const tune = async () => {
setTuning(true);
try { await IcomTune(); } catch {}
window.setTimeout(() => setTuning(false), 4000);
};
if (!st.available) { if (!st.available) {
return ( return (
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center"> <div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
@@ -132,16 +360,70 @@ export function IcomPanel() {
); );
} }
const tx = st.transmitting;
return ( return (
<div className="h-full overflow-y-auto p-3 space-y-3"> <div className="h-full min-h-0 overflow-auto bg-background">
<div className="flex items-center justify-between"> <div className="max-w-5xl mx-auto p-3 space-y-3">
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div> {/* 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-red-600 text-white' : 'bg-emerald-600 text-white')}>
<span className={cn('size-2 rounded-full', tx ? 'bg-white animate-pulse' : 'bg-white/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-amber-500/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-amber-600">Split</span> : null}
</div>
<button type="button" onClick={refresh} disabled={busy} <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"> 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')} /> Refresh <RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> Refresh
</button> </button>
</div> </div>
{/* Spectrum panadapter (full width). */}
<ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Live meters. */}
<Card icon={Zap} title="Meters" accent="#f59e0b">
{tx ? (
<>
<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'} />
</>
) : (
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sUnit(st.s_meter)} />
)}
</Card>
{/* Transmit controls. */}
<Card icon={Mic} title="Transmit" accent="#ef4444">
<Row label="Power">
<Slider value={st.rf_power} accent="#ef4444" onChange={(v) => set({ rf_power: v }, () => IcomSetRFPower(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_power}</span>
</Row>
<Row label="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-red-600 border-red-600 text-white' : '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))} />
<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-amber-500 border-amber-500 text-white animate-pulse' : 'bg-card text-foreground border-border hover:bg-muted')}>
TUNE
</button>
</div>
</Card>
<Card icon={Radio} title="Receive" accent="#2563eb"> <Card icon={Radio} title="Receive" accent="#2563eb">
<Row label="AF"> <Row label="AF">
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} /> <Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
@@ -182,5 +464,7 @@ export function IcomPanel() {
</div> </div>
</Card> </Card>
</div> </div>
</div>
</div>
); );
} }
+16
View File
@@ -333,6 +333,8 @@ export function HasBuiltinReferences(arg1:string):Promise<boolean>;
export function IcomRefresh():Promise<void>; export function IcomRefresh():Promise<void>;
export function IcomScopeData():Promise<cat.ScopeSweep>;
export function IcomSetAFGain(arg1:number):Promise<void>; export function IcomSetAFGain(arg1:number):Promise<void>;
export function IcomSetAGC(arg1:string):Promise<void>; export function IcomSetAGC(arg1:string):Promise<void>;
@@ -343,6 +345,8 @@ export function IcomSetAtt(arg1:number):Promise<void>;
export function IcomSetFilter(arg1:number):Promise<void>; export function IcomSetFilter(arg1:number):Promise<void>;
export function IcomSetMicGain(arg1:number):Promise<void>;
export function IcomSetNB(arg1:boolean):Promise<void>; export function IcomSetNB(arg1:boolean):Promise<void>;
export function IcomSetNBLevel(arg1:number):Promise<void>; export function IcomSetNBLevel(arg1:number):Promise<void>;
@@ -351,10 +355,22 @@ export function IcomSetNR(arg1:boolean):Promise<void>;
export function IcomSetNRLevel(arg1:number):Promise<void>; export function IcomSetNRLevel(arg1:number):Promise<void>;
export function IcomSetPTT(arg1:boolean):Promise<void>;
export function IcomSetPreamp(arg1:number):Promise<void>; export function IcomSetPreamp(arg1:number):Promise<void>;
export function IcomSetRFGain(arg1:number):Promise<void>; export function IcomSetRFGain(arg1:number):Promise<void>;
export function IcomSetRFPower(arg1:number):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 IcomTune():Promise<void>;
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>; export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
+32
View File
@@ -630,6 +630,10 @@ export function IcomRefresh() {
return window['go']['main']['App']['IcomRefresh'](); return window['go']['main']['App']['IcomRefresh']();
} }
export function IcomScopeData() {
return window['go']['main']['App']['IcomScopeData']();
}
export function IcomSetAFGain(arg1) { export function IcomSetAFGain(arg1) {
return window['go']['main']['App']['IcomSetAFGain'](arg1); return window['go']['main']['App']['IcomSetAFGain'](arg1);
} }
@@ -650,6 +654,10 @@ export function IcomSetFilter(arg1) {
return window['go']['main']['App']['IcomSetFilter'](arg1); return window['go']['main']['App']['IcomSetFilter'](arg1);
} }
export function IcomSetMicGain(arg1) {
return window['go']['main']['App']['IcomSetMicGain'](arg1);
}
export function IcomSetNB(arg1) { export function IcomSetNB(arg1) {
return window['go']['main']['App']['IcomSetNB'](arg1); return window['go']['main']['App']['IcomSetNB'](arg1);
} }
@@ -666,6 +674,10 @@ export function IcomSetNRLevel(arg1) {
return window['go']['main']['App']['IcomSetNRLevel'](arg1); return window['go']['main']['App']['IcomSetNRLevel'](arg1);
} }
export function IcomSetPTT(arg1) {
return window['go']['main']['App']['IcomSetPTT'](arg1);
}
export function IcomSetPreamp(arg1) { export function IcomSetPreamp(arg1) {
return window['go']['main']['App']['IcomSetPreamp'](arg1); return window['go']['main']['App']['IcomSetPreamp'](arg1);
} }
@@ -674,6 +686,26 @@ export function IcomSetRFGain(arg1) {
return window['go']['main']['App']['IcomSetRFGain'](arg1); return window['go']['main']['App']['IcomSetRFGain'](arg1);
} }
export function IcomSetRFPower(arg1) {
return window['go']['main']['App']['IcomSetRFPower'](arg1);
}
export function IcomSetScope(arg1) {
return window['go']['main']['App']['IcomSetScope'](arg1);
}
export function IcomSetScopeMode(arg1) {
return window['go']['main']['App']['IcomSetScopeMode'](arg1);
}
export function IcomSetSplit(arg1) {
return window['go']['main']['App']['IcomSetSplit'](arg1);
}
export function IcomTune() {
return window['go']['main']['App']['IcomTune']();
}
export function ImportADIF(arg1, arg2, arg3, arg4) { export function ImportADIF(arg1, arg2, arg3, arg4) {
return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4); return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4);
} }
+34
View File
@@ -671,6 +671,13 @@ export namespace cat {
available: boolean; available: boolean;
model?: string; model?: string;
mode?: string; mode?: string;
transmitting: boolean;
split: boolean;
s_meter: number;
power_meter: number;
swr_meter: number;
rf_power: number;
mic_gain: number;
af_gain: number; af_gain: number;
rf_gain: number; rf_gain: number;
nb: boolean; nb: boolean;
@@ -692,6 +699,13 @@ export namespace cat {
this.available = source["available"]; this.available = source["available"];
this.model = source["model"]; this.model = source["model"];
this.mode = source["mode"]; this.mode = source["mode"];
this.transmitting = source["transmitting"];
this.split = source["split"];
this.s_meter = source["s_meter"];
this.power_meter = source["power_meter"];
this.swr_meter = source["swr_meter"];
this.rf_power = source["rf_power"];
this.mic_gain = source["mic_gain"];
this.af_gain = source["af_gain"]; this.af_gain = source["af_gain"];
this.rf_gain = source["rf_gain"]; this.rf_gain = source["rf_gain"];
this.nb = source["nb"]; this.nb = source["nb"];
@@ -760,6 +774,26 @@ export namespace cat {
return a; return a;
} }
} }
export class ScopeSweep {
amp: number[];
seq: number;
low_hz: number;
high_hz: number;
fixed: boolean;
static createFrom(source: any = {}) {
return new ScopeSweep(source);
}
constructor(source: any = {}) {
if ('string' === typeof source) source = JSON.parse(source);
this.amp = source["amp"];
this.seq = source["seq"];
this.low_hz = source["low_hz"];
this.high_hz = source["high_hz"];
this.fixed = source["fixed"];
}
}
} }
+56
View File
@@ -376,6 +376,15 @@ type IcomTXState struct {
Available bool `json:"available"` Available bool `json:"available"`
Model string `json:"model,omitempty"` Model string `json:"model,omitempty"`
Mode string `json:"mode,omitempty"` Mode string `json:"mode,omitempty"`
// Transmit + live status (polled).
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
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)
// Set controls.
RFPower int `json:"rf_power"` // 0-100 (TX output)
MicGain int `json:"mic_gain"` // 0-100
AFGain int `json:"af_gain"` AFGain int `json:"af_gain"`
RFGain int `json:"rf_gain"` RFGain int `json:"rf_gain"`
NB bool `json:"nb"` NB bool `json:"nb"`
@@ -406,6 +415,25 @@ type IcomController interface {
SetPreamp(int) error SetPreamp(int) error
SetAtt(int) error SetAtt(int) error
SetIcomFilter(int) error SetIcomFilter(int) error
SetRFPower(int) error
SetMicGain(int) error
SetIcomSplit(bool) error
TuneATU() error
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)
}
// ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's
// divided 0x27 waveform frames. Amp holds one amplitude byte per pixel (raw rig
// scale, typically 0-160). Seq increments on every completed sweep so the UI can
// tell fresh data from a repeated poll.
type ScopeSweep struct {
Amp []int `json:"amp"` // []int (not []byte) so it marshals as a JSON number array
Seq int `json:"seq"`
LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown)
HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown)
Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO
} }
// IcomState returns the current Icom DSP state, or (zero, false) when the active // IcomState returns the current Icom DSP state, or (zero, false) when the active
@@ -420,6 +448,19 @@ func (m *Manager) IcomState() (IcomTXState, bool) {
return IcomTXState{}, false return IcomTXState{}, false
} }
// IcomScope returns the latest spectrum-scope sweep, or (zero, false) when the
// active backend isn't an Icom. The sweep is mutex-guarded in the backend, so
// this reads it directly (no CAT-goroutine round trip) — cheap enough to poll.
func (m *Manager) IcomScope() (ScopeSweep, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if ic, ok := b.(IcomController); ok {
return ic.ScopeData(), true
}
return ScopeSweep{}, false
}
// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the // IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the
// active backend isn't an Icom. // active backend isn't an Icom.
func (m *Manager) IcomDo(fn func(IcomController) error) error { func (m *Manager) IcomDo(fn func(IcomController) error) error {
@@ -490,6 +531,21 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
fn() fn()
m.applyCommandDelay() m.applyCommandDelay()
case <-ticker.C: case <-ticker.C:
// Drain any queued commands before polling. A serial backend reads
// many registers per ReadState, so without this the shared select's
// fairness lets polls repeatedly win and a user's Set* can lag by
// seconds. Servicing commands first bounds that latency to a single
// ReadState.
for {
select {
case fn := <-cmds:
fn()
m.applyCommandDelay()
continue
default:
}
break
}
if !connected { if !connected {
tryConnect() tryConnect()
continue continue
+21
View File
@@ -40,7 +40,10 @@ const (
CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off) CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off)
CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255) CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255)
CmdMeter = 0x15 // meters (sub + 2 BCD bytes, 0000-0255): S-meter/Po/SWR
CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte) 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)
SubDataMode = 0x06 SubDataMode = 0x06
SubPTT = 0x00 SubPTT = 0x00
@@ -52,6 +55,24 @@ const (
SubLevelRF = 0x02 // RF gain SubLevelRF = 0x02 // RF gain
SubLevelNR = 0x06 // noise-reduction depth SubLevelNR = 0x06 // noise-reduction depth
SubLevelNB = 0x12 // noise-blanker depth SubLevelNB = 0x12 // noise-blanker depth
SubLevelRFPower = 0x0A // TX RF output power
SubLevelMic = 0x0B // mic gain
// CmdMeter sub-commands.
SubMeterS = 0x02 // S-meter (RX)
SubMeterPo = 0x11 // power output (TX)
SubMeterSWR = 0x12 // SWR (TX)
// CmdATU / CmdPTT sub-commands.
SubATU = 0x01 // antenna tuner (data 0x02 = start tune)
// CmdScope sub-commands.
SubScopeData = 0x00 // waveform data frame (divided across several frames)
SubScopeOnOff = 0x10 // turn the scope display itself on/off (00/01)
SubScopeOn = 0x11 // enable/disable waveform data output over CI-V (00/01)
SubScopeMode = 0x14 // center/fixed mode (0=center, 1=fixed) — VERIFY on rig
SubScopeSpan = 0x15 // span in center mode — VERIFY on rig
SubScopeEdge = 0x16 // fixed-mode edge frequencies — VERIFY on rig
// CmdSwitch sub-commands. // CmdSwitch sub-commands.
SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2 SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2
+390 -31
View File
@@ -6,6 +6,7 @@ import (
"sync" "sync"
"time" "time"
"hamlog/internal/applog"
"hamlog/internal/cat/civ" "hamlog/internal/cat/civ"
"go.bug.st/serial" "go.bug.st/serial"
@@ -23,11 +24,36 @@ type IcomSerial struct {
digital string // mode to command for DATA (FT8/RTTY/…) digital string // mode to command for DATA (FT8/RTTY/…)
port serial.Port port serial.Port
rx []byte // accumulated bytes awaiting a complete frame
model string model string
// I/O routing. A single reader goroutine owns port.Read and dispatches every
// decoded rig frame: control replies go to respCh (drained by recv), while
// spectrum-scope frames (0x27) go to specCh for the panadapter. This decouples
// the continuous scope stream from the request/response control path — without
// it, scope frames would flood recv() and stall polling.
respCh chan civ.Decoded
specCh chan civ.Decoded
readerDone chan struct{}
// Spectrum scope (0x27). dualScope marks rigs whose waveform frames carry a
// leading main/sub selector byte (IC-7610/9700). scopeAmp is the latest
// reassembled sweep; scopeMu guards it (written by the scope goroutine, read
// via ScopeData from the binding goroutine).
dualScope bool
scopeMu sync.Mutex
scopeAmp []byte
scopeLow int64 // spectrum left-edge frequency (from the sweep's header frame)
scopeHigh int64 // spectrum right-edge frequency
scopeSeq int
scopeOn bool
scopeFixed bool // true = fixed-span mode (tracked optimistically)
scopeSeen bool // logged the first sweep's structure once (on-rig verification)
curFreq int64 // last frequency read (for sideband choice) curFreq int64 // last frequency read (for sideband choice)
curModeByte byte // last raw Icom mode byte (for filter re-send) curModeByte byte // last raw Icom mode byte (for filter re-send)
pollN int // ReadState cycle counter (staggers slow reads)
splitOn bool // last read split state (refreshed every few cycles)
splitTXFreq int64 // last read unselected/TX VFO freq while in split
readFails int // consecutive ReadState freq-read failures (transient tolerance) readFails int // consecutive ReadState freq-read failures (transient tolerance)
lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreq int64 // last frequency commanded (spot click: freq then mode)
lastSetFreqAt time.Time lastSetFreqAt time.Time
@@ -62,6 +88,7 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I
rigAddr: byte(civAddr), rigAddr: byte(civAddr),
digital: strings.ToUpper(digitalDefault), digital: strings.ToUpper(digitalDefault),
model: "Icom", model: "Icom",
scopeFixed: true, // rigs default to a fixed-span scope
} }
} }
@@ -85,9 +112,17 @@ func (b *IcomSerial) Connect() error {
_ = port.SetDTR(false) _ = port.SetDTR(false)
_ = port.SetRTS(false) _ = port.SetRTS(false)
b.port = port b.port = port
b.rx = b.rx[:0]
b.model = civ.ModelName(b.rigAddr) b.model = civ.ModelName(b.rigAddr)
// Start the reader before any request: recv() now waits on respCh, which only
// the reader feeds. respCh is buffered so a burst (or the scope stream) never
// blocks the reader; specCh holds the latest scope frames for the panadapter.
b.respCh = make(chan civ.Decoded, 64)
b.specCh = make(chan civ.Decoded, 32)
b.readerDone = make(chan struct{})
go b.reader(port, b.readerDone)
go b.scopeLoop(b.specCh, b.readerDone)
// Best-effort model identification: ask the rig for its own CI-V address. // Best-effort model identification: ask the rig for its own CI-V address.
if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil { if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil {
if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool {
@@ -96,15 +131,22 @@ func (b *IcomSerial) Connect() error {
b.model = civ.ModelName(f.Data[1]) b.model = civ.ModelName(f.Data[1])
} }
} }
// Dual-scope rigs (IC-7610/9700) prefix each waveform frame with a main/sub
// selector byte; single-scope rigs (IC-7300…) do not.
b.dualScope = b.rigAddr == 0x98 || b.rigAddr == 0xA2
b.readDSP() // best-effort initial snapshot for the control tab b.readDSP() // best-effort initial snapshot for the control tab
return nil return nil
} }
func (b *IcomSerial) Disconnect() { func (b *IcomSerial) Disconnect() {
if b.port != nil { if b.port != nil {
_ = b.port.Close() _ = b.port.Close() // unblocks the reader's pending Read
b.port = nil b.port = nil
} }
if b.readerDone != nil {
<-b.readerDone // wait for the reader goroutine to exit cleanly
b.readerDone = nil
}
} }
// ReadState polls the rig for frequency and mode. A failed frequency read is // ReadState polls the rig for frequency and mode. A failed frequency read is
@@ -151,15 +193,49 @@ func (b *IcomSerial) ReadState() (RigState, error) {
b.dspMu.Unlock() b.dspMu.Unlock()
} }
b.pollN++
// Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF // Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF
// convention → FreqHz = TX, RxFreqHz = RX. // convention → FreqHz = TX, RxFreqHz = RX. Split changes rarely and its read
// (0x0F + 0x25, each with a 350 ms timeout) is the costliest part of a poll,
// so refresh it only every 4th cycle and reuse the cached value between —
// this keeps the CAT thread free for the freq/mode/meter reads and, above
// all, for the user's Set* commands.
if b.pollN%4 == 1 {
b.splitOn, b.splitTXFreq = false, 0
if on, ok := b.readSplit(); ok && on { if on, ok := b.readSplit(); ok && on {
if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 && txHz != s.FreqHz { if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 {
b.splitOn, b.splitTXFreq = true, txHz
}
}
}
if b.splitOn && b.splitTXFreq > 0 && b.splitTXFreq != s.FreqHz {
s.Split = true s.Split = true
s.RxFreqHz = s.FreqHz // selected VFO = RX s.RxFreqHz = s.FreqHz // selected VFO = RX
s.FreqHz = txHz // unselected VFO = TX s.FreqHz = b.splitTXFreq // unselected VFO = TX
}
// Live meters + TX state for the Icom panel (the rig doesn't push these).
tx := b.readTX()
sm, _ := b.readMeter(civ.SubMeterS)
po, swr := 0, 0
if tx {
if v, ok := b.readMeter(civ.SubMeterPo); ok {
po = v
}
if v, ok := b.readMeter(civ.SubMeterSWR); ok {
swr = v
} }
} }
b.dspMu.Lock()
b.dsp.Available = true
b.dsp.Model = b.model
b.dsp.Transmitting = tx
b.dsp.Split = s.Split
b.dsp.SMeter = sm
b.dsp.PowerMeter = po
b.dsp.SWRMeter = swr
b.dspMu.Unlock()
return s, nil return s, nil
} }
@@ -201,39 +277,252 @@ func (b *IcomSerial) SetPTT(on bool) error {
// ── helpers ─────────────────────────────────────────────────────────────── // ── helpers ───────────────────────────────────────────────────────────────
func (b *IcomSerial) write(payload ...byte) error { func (b *IcomSerial) write(payload ...byte) error {
// 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).
b.drainResp()
_, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...)) _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...))
return err return err
} }
// recv reads from the port until a frame from the rig satisfies match or the // recv waits for a frame the reader routed to respCh that satisfies match, or
// timeout elapses. Frames that are our own echo (from == controller) or don't // times out. The reader has already discarded echoes and split off scope frames,
// match are discarded. // so recv only ever sees candidate control replies.
func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) { func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) {
deadline := time.Now().Add(timeout) deadline := time.After(timeout)
tmp := make([]byte, 256) for {
for time.Now().Before(deadline) { select {
n, err := b.port.Read(tmp) case f := <-b.respCh:
if err != nil {
return civ.Decoded{}, err
}
if n == 0 {
continue
}
b.rx = append(b.rx, tmp[:n]...)
frames, consumed := civ.Scan(b.rx)
if consumed > 0 {
b.rx = append(b.rx[:0], b.rx[consumed:]...)
}
for _, f := range frames {
if f.From != b.rigAddr {
continue // skip echo of our own commands
}
if match(f) { if match(f) {
return f, nil return f, nil
} }
} case <-deadline:
}
return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response") return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response")
}
}
}
// reader is the sole owner of port.Read. It decodes the CI-V byte stream into
// frames and routes each: our own echoes are dropped, spectrum-scope frames
// (0x27) go to specCh, everything else (control replies, acks, unsolicited
// transceive updates) goes to respCh. It exits when the port is closed.
func (b *IcomSerial) reader(port serial.Port, done chan struct{}) {
defer close(done)
tmp := make([]byte, 512)
var rx []byte
for {
n, err := port.Read(tmp)
if err != nil {
return // port closed or failed — Disconnect/reconnect handles it
}
if n == 0 {
continue // read timeout with no data
}
rx = append(rx, tmp[:n]...)
frames, consumed := civ.Scan(rx)
if consumed > 0 {
rx = append(rx[:0], rx[consumed:]...)
}
for _, f := range frames {
if f.From != b.rigAddr {
continue // echo of our own command
}
if f.Cmd == civ.CmdScope {
b.route(b.specCh, f)
continue
}
b.route(b.respCh, f)
}
}
}
// route delivers a frame without ever blocking the reader: if the channel is
// full it drops the oldest entry to make room for the newest.
func (b *IcomSerial) route(ch chan civ.Decoded, f civ.Decoded) {
select {
case ch <- f:
default:
select { // buffer full — discard oldest, then enqueue newest
case <-ch:
default:
}
select {
case ch <- f:
default:
}
}
}
// drainResp empties any pending control frames (non-blocking).
func (b *IcomSerial) drainResp() {
for {
select {
case <-b.respCh:
default:
return
}
}
}
// ── spectrum scope (0x27) ───────────────────────────────────────────────────
// scopeLoop reassembles the Icom's divided waveform frames into complete sweeps.
// Frame layout (verified on an IC-7610): Data = [00, main/sub, seq, total, …].
// The first frame (seq==1) is a HEADER — [info, low-edge 5-BCD, high-edge 5-BCD]
// — and carries NO waveform bytes; frames 2..total each carry a block of
// amplitude bytes. So we parse the edges from frame 1 and concatenate frames
// 2..total for the trace.
func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) {
regions := make(map[byte][]byte)
var total byte
rawN := 0 // diagnostic: dump the first few raw 0x27 frames
loggedCfg := map[byte]bool{} // one-shot dump of each config read response
for {
select {
case <-done:
return
case f := <-spec:
if len(f.Data) < 1 {
continue
}
if f.Data[0] != civ.SubScopeData {
// Non-waveform 0x27 frame = a config read response (mode/span/edge).
// Log each subcommand once so we can confirm its exact byte layout.
if !loggedCfg[f.Data[0]] {
loggedCfg[f.Data[0]] = true
applog.Printf("icom scope cfg 0x%02X: data=[% X]", f.Data[0], f.Data)
}
continue
}
if rawN < 4 {
rawN++
applog.Printf("icom scope raw #%d: len=%d data=[% X]", rawN, len(f.Data), f.Data)
}
idx := 1
if b.dualScope {
if len(f.Data) < 2 || f.Data[1] != 0x00 {
continue // only the MAIN scope
}
idx = 2
}
if len(f.Data) < idx+2 {
continue
}
seq, tot := f.Data[idx], f.Data[idx+1]
region := f.Data[idx+2:]
if seq == 0 || tot == 0 {
continue
}
if seq == 1 { // header frame — begins a new sweep, no waveform data
regions = make(map[byte][]byte)
total = tot
if len(region) >= 11 { // [info][low 5][high 5]
low := civ.BCDToFreq(region[1:6])
high := civ.BCDToFreq(region[6:11])
b.scopeMu.Lock()
b.scopeLow, b.scopeHigh = low, high
b.scopeMu.Unlock()
}
continue
}
if total == 0 || tot != total {
continue // stray frame from a sweep whose header we missed
}
regions[seq] = append([]byte(nil), region...)
if seq == total { // last data frame — assemble in sequence order
b.assembleSweep(regions, total)
}
}
}
}
func (b *IcomSerial) assembleSweep(regions map[byte][]byte, total byte) {
var amp []byte
for s := byte(2); s <= total; s++ {
amp = append(amp, regions[s]...)
}
b.scopeMu.Lock()
b.scopeAmp = amp
b.scopeSeq++
firstLog := !b.scopeSeen
b.scopeSeen = true
low, high := b.scopeLow, b.scopeHigh
b.scopeMu.Unlock()
if firstLog {
applog.Printf("icom scope: first sweep — model=%s total=%d points=%d edges=%d..%d Hz",
b.model, total, len(amp), low, high)
}
}
// SetScope enables or disables the spectrum scope. Two commands are needed and
// RS-BA1 sends both: 0x27 0x10 turns the scope DISPLAY on (without it the rig
// streams nothing — the case when we're remote and can't touch the front panel),
// and 0x27 0x11 turns the waveform data OUTPUT over CI-V on. While on, the reader
// routes every 0x27 frame to scopeLoop.
func (b *IcomSerial) SetScope(on bool) error {
// Some firmwares don't ack 0x27 sets; a timeout here isn't fatal, so log and
// continue rather than abort the second command.
if err := b.exec(civ.CmdScope, civ.SubScopeOnOff, boolByte(on)); err != nil {
applog.Printf("icom scope: display on=%v ack: %v", on, err)
}
if err := b.exec(civ.CmdScope, civ.SubScopeOn, boolByte(on)); err != nil {
applog.Printf("icom scope: output on=%v ack: %v", on, err)
}
b.scopeMu.Lock()
b.scopeOn = on
if !on {
b.scopeAmp = nil
}
b.scopeMu.Unlock()
if on {
// Fire read requests for the mode/span/edge settings; their 0x27 responses
// route to scopeLoop, which logs each once so we can confirm the layout.
// Best-effort (fire-and-forget) — responses are 0x27, not FB/FA acks.
b.scopeReadCfg()
}
return nil
}
// scopeReadCfg requests the scope's mode/span/edge settings for the diagnostic
// log. Sent both with and without the leading main/sub selector byte so we
// capture whichever form the rig answers.
func (b *IcomSerial) scopeReadCfg() {
for _, sub := range []byte{civ.SubScopeMode, civ.SubScopeSpan, civ.SubScopeEdge} {
_ = b.write(civ.CmdScope, sub)
if b.dualScope {
_ = b.write(civ.CmdScope, sub, 0x00)
}
}
}
// SetScopeMode selects fixed-span (true) or center-on-VFO (false). Center mode
// makes the scope follow the VFO, so tuning pans the view left/right.
func (b *IcomSerial) SetScopeMode(fixed bool) error {
mode := boolByte(fixed) // 0 = center, 1 = fixed (verify on rig via the cfg log)
var payload []byte
if b.dualScope {
payload = []byte{civ.CmdScope, civ.SubScopeMode, 0x00, mode}
} else {
payload = []byte{civ.CmdScope, civ.SubScopeMode, mode}
}
if err := b.exec(payload...); err != nil {
applog.Printf("icom scope: set mode fixed=%v ack: %v", fixed, err)
}
b.scopeMu.Lock()
b.scopeFixed = fixed
b.scopeMu.Unlock()
return nil
}
// ScopeData returns a copy of the latest reassembled sweep as a number array.
func (b *IcomSerial) ScopeData() ScopeSweep {
b.scopeMu.Lock()
defer b.scopeMu.Unlock()
amp := make([]int, len(b.scopeAmp))
for i, v := range b.scopeAmp {
amp[i] = int(v)
}
return ScopeSweep{Amp: amp, Seq: b.scopeSeq, LowHz: b.scopeLow, HighHz: b.scopeHigh, Fixed: b.scopeFixed}
} }
// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack. // exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack.
@@ -296,6 +585,34 @@ func (b *IcomSerial) readTXFreq() (int64, bool) {
return civ.BCDToFreq(f.Data[1:]), true return civ.BCDToFreq(f.Data[1:]), true
} }
// readTX reads the transmit state (CI-V 0x1C 0x00): non-zero data = keyed.
func (b *IcomSerial) readTX() bool {
if err := b.write(civ.CmdPTT, civ.SubPTT); err != nil {
return false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdPTT && len(d.Data) >= 2 && d.Data[0] == civ.SubPTT
})
if err != nil {
return false
}
return f.Data[1] != 0
}
// readMeter reads a meter (CI-V 0x15) and returns it scaled to 0-100.
func (b *IcomSerial) readMeter(sub byte) (int, bool) {
if err := b.write(civ.CmdMeter, sub); err != nil {
return 0, false
}
f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool {
return d.Cmd == civ.CmdMeter && len(d.Data) >= 3 && d.Data[0] == sub
})
if err != nil {
return 0, false
}
return from255(civ.BCDToLevel(f.Data[1:3])), true
}
func (b *IcomSerial) readMode() (byte, bool) { func (b *IcomSerial) readMode() (byte, bool) {
if err := b.write(civ.CmdReadMode); err != nil { if err := b.write(civ.CmdReadMode); err != nil {
return 0, false return 0, false
@@ -377,7 +694,14 @@ func (b *IcomSerial) RefreshIcom() error {
func (b *IcomSerial) readDSP() { func (b *IcomSerial) readDSP() {
st := IcomTXState{Available: true, Model: b.model} st := IcomTXState{Available: true, Model: b.model}
b.dspMu.Lock() b.dspMu.Lock()
st.Mode = b.dsp.Mode // preserve mode (set by ReadState) // Preserve the live fields ReadState polls (mode, TX/split, meters) — readDSP
// only refreshes the set-once DSP values.
st.Mode = b.dsp.Mode
st.Transmitting = b.dsp.Transmitting
st.Split = b.dsp.Split
st.SMeter = b.dsp.SMeter
st.PowerMeter = b.dsp.PowerMeter
st.SWRMeter = b.dsp.SWRMeter
b.dspMu.Unlock() b.dspMu.Unlock()
if v, ok := b.readLevel(civ.SubLevelAF); ok { if v, ok := b.readLevel(civ.SubLevelAF); ok {
@@ -386,6 +710,12 @@ func (b *IcomSerial) readDSP() {
if v, ok := b.readLevel(civ.SubLevelRF); ok { if v, ok := b.readLevel(civ.SubLevelRF); ok {
st.RFGain = from255(v) st.RFGain = from255(v)
} }
if v, ok := b.readLevel(civ.SubLevelRFPower); ok {
st.RFPower = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelMic); ok {
st.MicGain = from255(v)
}
if v, ok := b.readLevel(civ.SubLevelNR); ok { if v, ok := b.readLevel(civ.SubLevelNR); ok {
st.NRLevel = from255(v) st.NRLevel = from255(v)
} }
@@ -577,6 +907,35 @@ func (b *IcomSerial) SetIcomFilter(n int) error {
return nil return nil
} }
func (b *IcomSerial) SetRFPower(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRFPower}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.RFPower = clampPct(p) })
return nil
}
func (b *IcomSerial) SetMicGain(p int) error {
if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMic}, civ.LevelToBCD(to255(p))...)...); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.MicGain = clampPct(p) })
return nil
}
func (b *IcomSerial) SetIcomSplit(on bool) error {
if err := b.exec(civ.CmdSplit, boolByte(on)); err != nil {
return err
}
b.setCache(func(s *IcomTXState) { s.Split = on })
return nil
}
// TuneATU triggers a one-shot antenna-tuner tune (CI-V 0x1C 0x01 0x02).
func (b *IcomSerial) TuneATU() error {
return b.exec(civ.CmdATU, civ.SubATU, 0x02)
}
func (b *IcomSerial) setCache(fn func(*IcomTXState)) { func (b *IcomSerial) setCache(fn func(*IcomTXState)) {
b.dspMu.Lock() b.dspMu.Lock()
fn(&b.dsp) fn(&b.dsp)