feat: icom Scope in Icom Tab
This commit is contained in:
@@ -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")
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Vendored
+16
@@ -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>;
|
||||||
|
|||||||
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,40 +277,253 @@ 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.
|
||||||
func (b *IcomSerial) exec(payload ...byte) error {
|
func (b *IcomSerial) exec(payload ...byte) error {
|
||||||
@@ -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)
|
||||||
|
|||||||
Reference in New Issue
Block a user