From dd4b0004a5d2cde4396b3ea00179a89bc0cc4997 Mon Sep 17 00:00:00 2001 From: rouggy Date: Sat, 4 Jul 2026 22:06:26 +0200 Subject: [PATCH] feat: icom Scope in Icom Tab --- app.go | 60 ++++ frontend/src/components/IcomPanel.tsx | 308 +++++++++++++++++- frontend/wailsjs/go/main/App.d.ts | 16 + frontend/wailsjs/go/main/App.js | 32 ++ frontend/wailsjs/go/models.ts | 34 ++ internal/cat/cat.go | 60 +++- internal/cat/civ/civ.go | 29 +- internal/cat/icomserial.go | 435 +++++++++++++++++++++++--- 8 files changed, 918 insertions(+), 56 deletions(-) diff --git a/app.go b/app.go index 7cb9f9a..db482a5 100644 --- a/app.go +++ b/app.go @@ -7540,6 +7540,66 @@ func (a *App) IcomSetFilter(n int) error { 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 { if a.cat == nil { return fmt.Errorf("cat not initialized") diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx index 7fdf65c..41b1ffe 100644 --- a/frontend/src/components/IcomPanel.tsx +++ b/frontend/src/components/IcomPanel.tsx @@ -1,31 +1,60 @@ import { useEffect, useRef, useState } from 'react'; -import { Radio, AudioLines, RefreshCw } from 'lucide-react'; +import { Radio, AudioLines, RefreshCw, Mic, Zap, Activity } from 'lucide-react'; import { GetIcomState, IcomRefresh, IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel, IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter, + IcomSetRFPower, IcomSetMicGain, IcomSetSplit, IcomTune, IcomSetPTT, + IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; type IcomState = { 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; nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; agc?: string; preamp: number; att: number; filter: number; }; 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, preamp: 0, att: 0, filter: 1, }; -function Slider({ value, onChange, disabled, accent = '#2563eb' }: { - value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; +function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: { + value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; }) { const v = Math.max(0, Math.min(100, value)); + const ref = useRef(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 ( 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', @@ -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. -// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects -// the last refresh plus optimistic updates. Front-panel knob changes show after -// the next ↻ Refresh. +// Meter — a thin horizontal bar for a live 0-100 reading (S / Po / SWR). +function Meter({ label, value, accent, scale }: { label: string; value: number; accent: string; scale?: string }) { + const v = Math.max(0, Math.min(100, value)); + return ( +
+ {label} +
+
+
+ {scale ?? v} +
+ ); +} + +// 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(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) => { + 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 ( + +
+ + {on ? (fixed ? 'Fixed — double-click / wheel to tune' : 'Center — follows VFO') : 'Scope off'} + +
+ {on && ( + setMode(v === 'FIX')} /> + )} + +
+
+
+ +
+
+ ); +} + +// 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() { const [st, setSt] = useState(ZERO); 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 refresh = async () => { @@ -113,7 +329,7 @@ export function IcomPanel() { useEffect(() => { 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); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); @@ -124,6 +340,18 @@ export function IcomPanel() { 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) { return (
@@ -132,16 +360,70 @@ export function IcomPanel() { ); } + const tx = st.transmitting; + return ( -
-
-
{st.model || 'Icom'}{st.mode ? {st.mode} : null}
+
+
+ {/* Header strip: model + mode + live RX/TX indicator + split badge. */} +
+
+ + + {tx ? 'TX' : 'RX'} + + {st.model || 'Icom'} + {st.mode ? {st.mode} : null} + {st.split ? Split : null} +
+ {/* Spectrum panadapter (full width). */} + + +
+ {/* Live meters. */} + + {tx ? ( + <> + + 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} /> + + ) : ( + + )} + + + {/* Transmit controls. */} + + + set({ rf_power: v }, () => IcomSetRFPower(v))} /> + {st.rf_power} + + + set({ mic_gain: v }, () => IcomSetMicGain(v))} /> + {st.mic_gain} + +
+ + set({ split: !st.split }, () => IcomSetSplit(!st.split))} /> + +
+
+ set({ af_gain: v }, () => IcomSetAFGain(v))} /> @@ -181,6 +463,8 @@ export function IcomPanel() { Auto notch filter
+
+
); } diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 43147d9..119835b 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -333,6 +333,8 @@ export function HasBuiltinReferences(arg1:string):Promise; export function IcomRefresh():Promise; +export function IcomScopeData():Promise; + export function IcomSetAFGain(arg1:number):Promise; export function IcomSetAGC(arg1:string):Promise; @@ -343,6 +345,8 @@ export function IcomSetAtt(arg1:number):Promise; export function IcomSetFilter(arg1:number):Promise; +export function IcomSetMicGain(arg1:number):Promise; + export function IcomSetNB(arg1:boolean):Promise; export function IcomSetNBLevel(arg1:number):Promise; @@ -351,10 +355,22 @@ export function IcomSetNR(arg1:boolean):Promise; export function IcomSetNRLevel(arg1:number):Promise; +export function IcomSetPTT(arg1:boolean):Promise; + export function IcomSetPreamp(arg1:number):Promise; export function IcomSetRFGain(arg1:number):Promise; +export function IcomSetRFPower(arg1:number):Promise; + +export function IcomSetScope(arg1:boolean):Promise; + +export function IcomSetScopeMode(arg1:boolean):Promise; + +export function IcomSetSplit(arg1:boolean):Promise; + +export function IcomTune():Promise; + export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 5a5460a..3e7b2ef 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -630,6 +630,10 @@ export function IcomRefresh() { return window['go']['main']['App']['IcomRefresh'](); } +export function IcomScopeData() { + return window['go']['main']['App']['IcomScopeData'](); +} + export function IcomSetAFGain(arg1) { return window['go']['main']['App']['IcomSetAFGain'](arg1); } @@ -650,6 +654,10 @@ export function IcomSetFilter(arg1) { return window['go']['main']['App']['IcomSetFilter'](arg1); } +export function IcomSetMicGain(arg1) { + return window['go']['main']['App']['IcomSetMicGain'](arg1); +} + export function IcomSetNB(arg1) { return window['go']['main']['App']['IcomSetNB'](arg1); } @@ -666,6 +674,10 @@ export function IcomSetNRLevel(arg1) { return window['go']['main']['App']['IcomSetNRLevel'](arg1); } +export function IcomSetPTT(arg1) { + return window['go']['main']['App']['IcomSetPTT'](arg1); +} + export function IcomSetPreamp(arg1) { return window['go']['main']['App']['IcomSetPreamp'](arg1); } @@ -674,6 +686,26 @@ export function 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) { return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 73718f6..303a3ce 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -671,6 +671,13 @@ export namespace cat { 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; nb: boolean; @@ -692,6 +699,13 @@ export namespace cat { this.available = source["available"]; this.model = source["model"]; 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.rf_gain = source["rf_gain"]; this.nb = source["nb"]; @@ -760,6 +774,26 @@ export namespace cat { 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"]; + } + } } diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 01acba6..95136ec 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -376,8 +376,17 @@ type IcomTXState struct { Available bool `json:"available"` Model string `json:"model,omitempty"` Mode string `json:"mode,omitempty"` - AFGain int `json:"af_gain"` - RFGain int `json:"rf_gain"` + // 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"` + RFGain int `json:"rf_gain"` NB bool `json:"nb"` NBLevel int `json:"nb_level"` NR bool `json:"nr"` @@ -406,6 +415,25 @@ type IcomController interface { SetPreamp(int) error SetAtt(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 @@ -420,6 +448,19 @@ func (m *Manager) IcomState() (IcomTXState, bool) { 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 // active backend isn't an Icom. 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() m.applyCommandDelay() 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 { tryConnect() continue diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go index 15adb41..57ffa6f 100644 --- a/internal/cat/civ/civ.go +++ b/internal/cat/civ/civ.go @@ -40,7 +40,10 @@ const ( CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off) 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) + 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 SubPTT = 0x00 @@ -48,10 +51,28 @@ const ( SubVfoUnselected = 0x01 // CmdVfoFreq: the other VFO (TX in split) // CmdLevel sub-commands. - SubLevelAF = 0x01 // AF (volume) - SubLevelRF = 0x02 // RF gain - SubLevelNR = 0x06 // noise-reduction depth - SubLevelNB = 0x12 // noise-blanker depth + SubLevelAF = 0x01 // AF (volume) + SubLevelRF = 0x02 // RF gain + SubLevelNR = 0x06 // noise-reduction 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. SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2 diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index 9a524a9..d065472 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -6,6 +6,7 @@ import ( "sync" "time" + "hamlog/internal/applog" "hamlog/internal/cat/civ" "go.bug.st/serial" @@ -23,11 +24,36 @@ type IcomSerial struct { digital string // mode to command for DATA (FT8/RTTY/…) port serial.Port - rx []byte // accumulated bytes awaiting a complete frame 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) 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) lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreqAt time.Time @@ -57,11 +83,12 @@ func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *I digitalDefault = "FT8" } return &IcomSerial{ - portName: portName, - baud: baud, - rigAddr: byte(civAddr), - digital: strings.ToUpper(digitalDefault), - model: "Icom", + portName: portName, + baud: baud, + rigAddr: byte(civAddr), + digital: strings.ToUpper(digitalDefault), + model: "Icom", + scopeFixed: true, // rigs default to a fixed-span scope } } @@ -85,9 +112,17 @@ func (b *IcomSerial) Connect() error { _ = port.SetDTR(false) _ = port.SetRTS(false) b.port = port - b.rx = b.rx[:0] 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. if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil { 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]) } } + // 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 return nil } func (b *IcomSerial) Disconnect() { if b.port != nil { - _ = b.port.Close() + _ = b.port.Close() // unblocks the reader's pending Read 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 @@ -151,15 +193,49 @@ func (b *IcomSerial) ReadState() (RigState, error) { b.dspMu.Unlock() } + b.pollN++ + // Split: the selected VFO (read above) is RX; the unselected VFO is TX. ADIF - // convention → FreqHz = TX, RxFreqHz = RX. - if on, ok := b.readSplit(); ok && on { - if txHz, ok2 := b.readTXFreq(); ok2 && txHz > 0 && txHz != s.FreqHz { - s.Split = true - s.RxFreqHz = s.FreqHz // selected VFO = RX - s.FreqHz = txHz // unselected VFO = TX + // 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 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.RxFreqHz = s.FreqHz // selected VFO = RX + 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 } @@ -201,39 +277,252 @@ func (b *IcomSerial) SetPTT(on bool) error { // ── helpers ─────────────────────────────────────────────────────────────── 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...)) return err } -// recv reads from the port until a frame from the rig satisfies match or the -// timeout elapses. Frames that are our own echo (from == controller) or don't -// match are discarded. +// recv waits for a frame the reader routed to respCh that satisfies match, or +// times out. The reader has already discarded echoes and split off scope frames, +// so recv only ever sees candidate control replies. func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) { - deadline := time.Now().Add(timeout) - tmp := make([]byte, 256) - for time.Now().Before(deadline) { - n, err := b.port.Read(tmp) - 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 - } + deadline := time.After(timeout) + for { + select { + case f := <-b.respCh: if match(f) { 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. @@ -296,6 +585,34 @@ func (b *IcomSerial) readTXFreq() (int64, bool) { 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) { if err := b.write(civ.CmdReadMode); err != nil { return 0, false @@ -377,7 +694,14 @@ func (b *IcomSerial) RefreshIcom() error { func (b *IcomSerial) readDSP() { st := IcomTXState{Available: true, Model: b.model} 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() if v, ok := b.readLevel(civ.SubLevelAF); ok { @@ -386,6 +710,12 @@ func (b *IcomSerial) readDSP() { if v, ok := b.readLevel(civ.SubLevelRF); ok { 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 { st.NRLevel = from255(v) } @@ -577,6 +907,35 @@ func (b *IcomSerial) SetIcomFilter(n int) error { 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)) { b.dspMu.Lock() fn(&b.dsp)