From 7a24282aa4696bf78dc41bbbc6df25fe5c752cbc Mon Sep 17 00:00:00 2001 From: rouggy Date: Tue, 7 Jul 2026 09:31:53 +0200 Subject: [PATCH] feat: Implemented scope on Ethernet for Icom --- app.go | 107 ++++++ frontend/src/App.tsx | 17 +- frontend/src/components/IcomPanel.tsx | 227 +++++++++--- frontend/src/components/SettingsModal.tsx | 21 +- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/main/App.d.ts | 28 ++ frontend/wailsjs/go/main/App.js | 56 +++ frontend/wailsjs/go/models.ts | 26 ++ internal/cat/cat.go | 46 +++ internal/cat/civ/civ.go | 14 + internal/cat/icomnet.go | 418 ++++++++++++++++------ internal/cat/icomserial.go | 364 ++++++++++++++++++- 12 files changed, 1163 insertions(+), 165 deletions(-) diff --git a/app.go b/app.go index a6fdc65..1cc3860 100644 --- a/app.go +++ b/app.go @@ -1113,6 +1113,13 @@ func (a *App) shutdown(ctx context.Context) { if a.udp != nil { a.udp.StopAll() } + // Stop CAT so the backend disconnects cleanly. Critical for the Icom network + // backend: without this the rig never gets a disconnect and holds its single + // control session for minutes, refusing every new login (even from the Icom + // Remote Utility) until it times out on its own. + if a.cat != nil { + a.cat.Stop() + } if a.winkeyer != nil { a.winkeyer.Disconnect() } @@ -7885,6 +7892,97 @@ func (a *App) IcomSetSplit(on bool) error { return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomSplit(on) }) } +func (a *App) IcomSetAntenna(n int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntenna(n) }) +} + +func (a *App) IcomSetPBTInner(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTInner(p) }) +} + +func (a *App) IcomSetPBTOuter(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPBTOuter(p) }) +} + +func (a *App) IcomSetManualNotch(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetManualNotch(on) }) +} + +func (a *App) IcomSetNotchPos(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNotchPos(p) }) +} + +func (a *App) IcomSetSquelch(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetSquelch(p) }) +} + +func (a *App) IcomSetComp(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetComp(on) }) +} + +func (a *App) IcomSetCompLevel(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetCompLevel(p) }) +} + +func (a *App) IcomSetMonitor(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonitor(on) }) +} + +func (a *App) IcomSetMonLevel(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetMonLevel(p) }) +} + +func (a *App) IcomSetVOX(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOX(on) }) +} + +func (a *App) IcomSetVOXGain(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetVOXGain(p) }) +} + +func (a *App) IcomSetAntiVOX(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAntiVOX(p) }) +} + func (a *App) IcomTune() error { if a.cat == nil { return fmt.Errorf("cat not initialized") @@ -7892,6 +7990,15 @@ func (a *App) IcomTune() error { return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.TuneATU() }) } +// IcomSetPower turns the radio on or off (manual — the app never wakes the rig +// on connect). ON sends a wake preamble + CI-V 0x18 01; the rig then boots ~15s. +func (a *App) IcomSetPower(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPower(on) }) +} + // IcomSetScope enables/disables the spectrum-scope waveform stream. func (a *App) IcomSetScope(on bool) error { if a.cat == nil { diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index bb8fb92..c6ac290 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -889,12 +889,12 @@ export default function App() { // map ("map1"), the locator street map ("map2"), the cluster grid or the // worked-before grid. Per-profile (stored via SetUIPref → profile-prefixed), // so it's loaded async on mount and re-read on profile:changed below. - type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent'; + type MainPaneKind = 'map1' | 'map2' | 'cluster' | 'worked' | 'flex' | 'recent' | 'icom' | 'netcontrol'; const [mapZoomSignal, setMapZoomSignal] = useState(0); // bump → world map auto-zooms now const [mainPaneLeft, setMainPaneLeft] = useState('map1'); const [mainPaneRight, setMainPaneRight] = useState('map2'); const loadMainPanes = useCallback(async () => { - const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent'; + const valid = (v: string): v is MainPaneKind => v === 'map1' || v === 'map2' || v === 'cluster' || v === 'worked' || v === 'flex' || v === 'recent' || v === 'icom' || v === 'netcontrol'; const [l, r] = await Promise.all([ GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => ''), @@ -3080,6 +3080,18 @@ export default function App() { onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} /> ); + case 'icom': + return ( +
+ { setRstSent(r); rstUserEditedRef.current = true; }} /> +
+ ); + case 'netcontrol': + return ( +
+ +
+ ); case 'recent': return (
@@ -4344,6 +4356,7 @@ export default function App() { onSaved={() => { loadStation(); loadLists(); loadCATCfg(); reloadWk(); }} onMainPaneChanged={(side, v) => { if (side === 'left') setMainPaneLeft(v as MainPaneKind); else setMainPaneRight(v as MainPaneKind); }} flexAvailable={catState.backend === 'flex'} + icomAvailable={catState.backend === 'icom'} /> )} diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx index b21b145..71f0e74 100644 --- a/frontend/src/components/IcomPanel.tsx +++ b/frontend/src/components/IcomPanel.tsx @@ -1,12 +1,15 @@ import { useEffect, useRef, useState } from 'react'; -import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal } from 'lucide-react'; +import { Radio, AudioLines, RefreshCw, Mic, Activity, SlidersHorizontal, Antenna, Filter, Power } 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, + IcomSetScope, IcomScopeData, IcomSetScopeMode, GetCATState, SetCATFrequency, SetCATMode, IcomSetRIT, IcomSetRITOn, IcomSetXITOn, + IcomSetAntenna, IcomSetPBTInner, IcomSetPBTOuter, IcomSetManualNotch, IcomSetNotchPos, + IcomSetSquelch, IcomSetComp, IcomSetCompLevel, IcomSetMonitor, IcomSetMonLevel, + IcomSetVOX, IcomSetVOXGain, IcomSetAntiVOX, IcomSetPower, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; import { useI18n } from '@/lib/i18n'; @@ -21,6 +24,11 @@ type IcomState = { nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; agc?: string; preamp: number; att: number; filter: number; rit_hz: number; rit_on: boolean; xit_on: boolean; + antenna: number; + pbt_inner: number; pbt_outer: number; manual_notch: boolean; notch_pos: number; + squelch: number; comp: boolean; comp_level: number; + monitor: boolean; mon_level: number; + vox: boolean; vox_gain: number; anti_vox: number; }; const ZERO: IcomState = { @@ -30,8 +38,43 @@ const ZERO: IcomState = { nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, preamp: 0, att: 0, filter: 1, rit_hz: 0, rit_on: false, xit_on: false, + antenna: 1, + pbt_inner: 50, pbt_outer: 50, manual_notch: false, notch_pos: 50, + squelch: 0, comp: false, comp_level: 0, + monitor: false, mon_level: 0, + vox: false, vox_gain: 0, anti_vox: 0, }; +// Band buttons jump the VFO to a sensible default frequency (SSB/CW mix) using +// the plain SetFrequency command — no band-stacking codes needed. Hz values. +const BANDS: { l: string; hz: number }[] = [ + { l: '160', hz: 1_840_000 }, { l: '80', hz: 3_750_000 }, { l: '40', hz: 7_100_000 }, + { l: '30', hz: 10_130_000 }, { l: '20', hz: 14_150_000 }, { l: '17', hz: 18_130_000 }, + { l: '15', hz: 21_250_000 }, { l: '12', hz: 24_950_000 }, { l: '10', hz: 28_400_000 }, + { l: '6', hz: 50_150_000 }, +]; + +// Mode buttons for the console (like RS-BA1's row). SetCATMode picks USB/LSB for +// SSB by frequency and the rig's data variant for digital modes. +const MODES = ['SSB', 'CW', 'RTTY', 'PSK', 'AM', 'FM']; + +// fmtVFO renders a Hz frequency the way an Icom front panel does: +// MHz "." 3-digit-kHz "." 2-digit-(10 Hz). 21032000 → "21.032.00". +function fmtVFO(hz?: number): string { + if (!hz || hz <= 0) return '––.–––.––'; + const mhz = Math.floor(hz / 1_000_000); + const khz = Math.floor((hz % 1_000_000) / 1000); + const h2 = Math.floor((hz % 1000) / 10); + return `${mhz}.${String(khz).padStart(3, '0')}.${String(h2).padStart(2, '0')}`; +} + +// modeMatches marks a mode button active, folding the rig's USB/LSB into SSB. +function modeMatches(btn: string, cur?: string): boolean { + if (!cur) return false; + if (btn === 'SSB') return cur === 'SSB' || cur === 'USB' || cur === 'LSB'; + return btn === cur; +} + function Slider({ value, onChange, disabled, accent = '#2563eb', step = 1 }: { value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; step?: number; }) { @@ -148,25 +191,6 @@ function Meter({ label, value, accent, scale, onClick, title }: { label: string; return
{body}
; } -// HdrMeter — a compact live meter for the model header band (S when receiving, -// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx. -function HdrMeter({ label, value, accent, scale, onClick, title }: { - label: string; value: number; accent: string; scale: string; onClick?: () => void; title?: string; -}) { - const v = Math.max(0, Math.min(100, value)); - const body = ( - <> - {label} -
-
-
- {scale} - - ); - if (onClick) return ; - return
{body}
; -} - // ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed // offset (±10 Hz per notch or per ± button) + a clear (0) button. function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: { @@ -475,12 +499,17 @@ function ScopePanadapter() { export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) { const { t } = useI18n(); const [st, setSt] = useState(ZERO); + const [cat, setCat] = useState(null); // RigState (freq/mode/split) for the VFO display const [busy, setBusy] = useState(false); const [tuning, setTuning] = useState(false); const txRef = useRef(false); const stRef = useRef(ZERO); stRef.current = st; - const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); + const load = () => { + GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); + GetCATState().then((c) => setCat(c ?? null)).catch(() => {}); + }; + const setMode = (m: string) => { setCat((c: any) => (c ? { ...c, mode: m } : c)); SetCATMode(m).catch(() => {}); }; const refresh = async () => { setBusy(true); try { await IcomRefresh(); } catch {} @@ -543,6 +572,12 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } const tx = st.transmitting; + // VFO readout. In split the active/listening VFO is RX (freq_rx_hz) and the + // other is TX (freq_hz); otherwise there's a single VFO (freq_hz). + const split = !!cat?.split; + const mainHz: number = split ? (cat?.freq_rx_hz || 0) : (cat?.freq_hz || 0); + const subHz: number = split ? (cat?.freq_hz || 0) : 0; + const curMode: string = cat?.mode || st.mode || ''; return (
@@ -559,26 +594,97 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void {st.mode ? {st.mode} : null} {st.split ? Split : null}
- {/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */} -
- {tx ? ( - - ) : (() => { const sp = sParts(st.s_meter); return ( - onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} /> - ); })()} +
+ {/* Radio power ON / OFF. Manual by design — the app never wakes the rig + on connect; ON sends the wake preamble then the rig boots ~15 s. */} + + +
- +
+ + {/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big + tabular frequency, mode badge, band, and the RIT/ΔTX offset. */} +
+
+ {/* MAIN VFO */} +
+
+ {tx ? 'Main · TX' : 'Main'} + {curMode ? {curMode} : null} +
+
{fmtVFO(mainHz)}
+
+ {cat?.band || (mainHz ? '' : '—')} + {st.rit_on ? RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '−' : ''}{Math.abs(st.rit_hz)} : null} + {st.xit_on ? ΔTX : null} +
+
+ {/* SUB VFO (populated in split; dimmed otherwise) */} +
+
+ Sub + {split ? SPLIT : null} +
+
{fmtVFO(subHz)}
+
{split ? 'TX' : ''}
+
+
+ {/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */} +
+ {MODES.map((m) => { + const on = modeMatches(m, curMode); + return ( + + ); + })} +
+
+ + {/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */} +
+ {(() => { const sp = sParts(st.s_meter); return ( + onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} /> + ); })()} + + 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
{/* Spectrum panadapter (full width). */}
+ {/* Band buttons + antenna selection. */} + +
+ {BANDS.map((b) => ( + + ))} +
+ + set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} /> + +
+ {/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */} void onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))} onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />

{t('icmp.ritHint')}

- {tx && ( -
- - 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} /> -
- )}
{/* Transmit controls. */} @@ -619,6 +719,22 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void TUNE
+ {/* Speech processor / monitor / VOX. */} +
+ set({ comp: !st.comp }, () => IcomSetComp(!st.comp))} + onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} /> + set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))} + onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} /> + set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))} + onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} /> + + set({ anti_vox: v }, () => IcomSetAntiVOX(v))} /> + {st.anti_vox} + +
@@ -630,6 +746,10 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void set({ rf_gain: v }, () => IcomSetRFGain(v))} /> {st.rf_gain} + + set({ squelch: v }, () => IcomSetSquelch(v))} /> + {st.squelch} + set({ agc: v }, () => IcomSetAGC(v))} /> @@ -648,6 +768,31 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void + {/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */} + + + set({ pbt_inner: v }, () => IcomSetPBTInner(v))} /> + {st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50} + + + set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} /> + {st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50} + + +
+
+ set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} /> + set({ notch_pos: v }, () => IcomSetNotchPos(v))} /> + {st.notch_pos} +
+

{t('icmp.manualNotch')}

+
+
+ set({ nb: !st.nb }, () => IcomSetNB(!st.nb))} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 0deb382..a6f749b 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -146,6 +146,7 @@ interface Props { onSaved: () => void; onMainPaneChanged?: (side: 'left' | 'right', value: string) => void; // live Main-view layout update flexAvailable?: boolean; // CAT backend is FlexRadio → offer it as a Main pane + icomAvailable?: boolean; // CAT backend is Icom → offer the Icom console as a Main pane } // Pretty little card showing what OpsLog will stamp on each QSO based on @@ -573,17 +574,19 @@ const MAIN_PANE_OPTIONS: { value: string; label: string }[] = [ { value: 'cluster', label: 'Cluster spots' }, { value: 'worked', label: 'Worked before' }, { value: 'recent', label: 'Recent QSOs' }, + { value: 'netcontrol', label: 'Net control' }, ]; -function MainViewPanes({ onChanged, flexAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean }) { +function MainViewPanes({ onChanged, flexAvailable, icomAvailable }: { onChanged?: (side: 'left' | 'right', value: string) => void; flexAvailable?: boolean; icomAvailable?: boolean }) { const [left, setLeft] = useState('map1'); const [right, setRight] = useState('map2'); - // FlexRadio is only offered when the CAT backend is a Flex. Sorted A→Z. - const options = (flexAvailable - ? [...MAIN_PANE_OPTIONS, { value: 'flex', label: 'FlexRadio controls' }] - : [...MAIN_PANE_OPTIONS] - ).sort((a, b) => a.label.localeCompare(b.label)); + // Radio-control panes are only offered when that CAT backend is active. Sorted A→Z. + const options = [ + ...MAIN_PANE_OPTIONS, + ...(flexAvailable ? [{ value: 'flex', label: 'FlexRadio controls' }] : []), + ...(icomAvailable ? [{ value: 'icom', label: 'Icom console' }] : []), + ].sort((a, b) => a.label.localeCompare(b.label)); useEffect(() => { - const valid = (v: string) => v === 'flex' || MAIN_PANE_OPTIONS.some((o) => o.value === v); + const valid = (v: string) => v === 'flex' || v === 'icom' || MAIN_PANE_OPTIONS.some((o) => o.value === v); Promise.all([GetUIPref('mainPaneLeft').catch(() => ''), GetUIPref('mainPaneRight').catch(() => '')]) .then(([l, r]) => { if (valid(l)) setLeft(l); if (valid(r)) setRight(r); }); }, []); @@ -756,7 +759,7 @@ function FlexBandAntennasPanel({ bands }: { bands: string[] }) { ); } -export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable }: Props) { +export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChanged, flexAvailable, icomAvailable }: Props) { const { t } = useI18n(); const [selected, setSelected] = useState((initialSection as SectionId) || 'station'); const [loading, setLoading] = useState(true); @@ -3931,7 +3934,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan - +

Password encryption

diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index b7dafda..aada429 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -193,7 +193,7 @@ const en: Dict = { 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'flxp.smartsdrRemote': 'SmartSDR remote control', 'flxp.offline': 'OFFLINE', 'flxp.waiting': 'Waiting for the FlexRadio… (set CAT to FlexRadio and connect)', 'flxp.transmit': 'Transmit', 'flxp.rfPower': 'RF Power', 'flxp.tunePwr': 'Tune Pwr', 'flxp.splitHint': 'Split: RX/TX on separate slices. ON creates a TX slice +1 kHz (CW) / +5 kHz (SSB) up, like SmartSDR.', 'flxp.voxDly': 'VOX Dly', 'flxp.speed': 'Speed', 'flxp.pitch': 'Pitch', 'flxp.delay': 'Delay', 'flxp.receiveActive': 'Receive (active slice)', 'flxp.muted': 'Muted — click to unmute', 'flxp.mute': 'Mute RX audio', 'flxp.filter': 'Filter', 'flxp.amplifier': 'Amplifier', 'flxp.ampInLine': 'Amplifier is in line (transmitting through PA).', 'flxp.ampBypassed': 'Amplifier bypassed (standby).', 'flxp.pgConnected': 'PowerGenius connected', 'flxp.pgOffline': 'PowerGenius offline', 'flxp.fan': 'Fan', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Broadcast', 'flxp.fault': 'FAULT', 'flxp.meters': 'Meters', 'flxp.noMeters': "No meters yet — waiting for the radio's UDP stream…", 'flxp.amplifierHdr': 'AMPLIFIER', - 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', + 'icmp.spectrum': 'Spectrum', 'icmp.scopeFixed': 'Fixed — double-click / wheel to tune', 'icmp.scopeCenter': 'Center — follows VFO', 'icmp.scopeOff': 'Scope off', 'icmp.notConnected': "Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.", 'icmp.refresh': 'Refresh', 'icmp.meters': 'Meters', 'icmp.transmit': 'Transmit', 'icmp.power': 'Power', 'icmp.mic': 'Mic', 'icmp.receive': 'Receive', 'icmp.preamp': 'Preamp', 'icmp.filter': 'Filter', 'icmp.noiseNotch': 'Noise / Notch', 'icmp.autoNotch': 'Auto notch filter', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Wheel or ± to shift · Ctrl+←/→ shifts RIT when active', 'icmp.bandsAntenna': 'Bands & Antenna', 'icmp.antenna': 'Antenna', 'icmp.passband': 'Passband / Notch', 'icmp.pbtCenter': 'Center PBT', 'icmp.manualNotch': 'Manual notch — MN on, then set position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Power the radio ON (boots ~15 s)', 'icmp.powerOffHint': 'Power the radio OFF', 'icmp.powerOffConfirm': 'Switch the radio OFF?', 'rst.clickToFill': 'Click to set RST tx from the signal', 'qrz.openTitle': 'Open {call} on QRZ.com', // Misc panels/modals (alerts / send-spot / net / udp / filter / details) @@ -379,7 +379,7 @@ const fr: Dict = { 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'flxp.smartsdrRemote': 'Contrôle à distance SmartSDR', 'flxp.offline': 'HORS LIGNE', 'flxp.waiting': 'En attente du FlexRadio… (règle le CAT sur FlexRadio et connecte)', 'flxp.transmit': 'Émission', 'flxp.rfPower': 'Puissance RF', 'flxp.tunePwr': 'Puiss. TUNE', 'flxp.splitHint': 'Split : RX/TX sur des slices séparées. ON crée une slice TX +1 kHz (CW) / +5 kHz (SSB) au-dessus, comme SmartSDR.', 'flxp.voxDly': 'Délai VOX', 'flxp.speed': 'Vitesse', 'flxp.pitch': 'Tonalité', 'flxp.delay': 'Délai', 'flxp.receiveActive': 'Réception (slice active)', 'flxp.muted': 'Coupé — clic pour rétablir', 'flxp.mute': "Couper l'audio RX", 'flxp.filter': 'Filtre', 'flxp.amplifier': 'Amplificateur', 'flxp.ampInLine': 'Amplificateur en ligne (émission via le PA).', 'flxp.ampBypassed': 'Amplificateur en bypass (standby).', 'flxp.pgConnected': 'PowerGenius connecté', 'flxp.pgOffline': 'PowerGenius hors ligne', 'flxp.fan': 'Ventilo', 'flxp.fanStandard': 'Standard', 'flxp.fanContest': 'Contest', 'flxp.fanBroadcast': 'Diffusion', 'flxp.fault': 'DÉFAUT', 'flxp.meters': 'Mesures', 'flxp.noMeters': 'Aucune mesure — en attente du flux UDP de la radio…', 'flxp.amplifierHdr': 'AMPLIFICATEUR', - 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', + 'icmp.spectrum': 'Spectre', 'icmp.scopeFixed': 'Fixe — double-clic / molette pour accorder', 'icmp.scopeCenter': 'Centré — suit le VFO', 'icmp.scopeOff': 'Scope éteint', 'icmp.notConnected': 'Icom non connecté. Active le backend CI-V Icom dans Réglages → CAT et connecte le port USB de la radio.', 'icmp.refresh': 'Rafraîchir', 'icmp.meters': 'Mesures', 'icmp.transmit': 'Émission', 'icmp.power': 'Puissance', 'icmp.mic': 'Micro', 'icmp.receive': 'Réception', 'icmp.preamp': 'Préampli', 'icmp.filter': 'Filtre', 'icmp.noiseNotch': 'Bruit / Notch', 'icmp.autoNotch': 'Filtre notch auto', 'icmp.clarifiers': 'RIT / ΔTX', 'icmp.ritHint': 'Molette ou ± pour décaler · Ctrl+←/→ décale le RIT si actif', 'icmp.bandsAntenna': 'Bandes & Antenne', 'icmp.antenna': 'Antenne', 'icmp.passband': 'Passe-bande / Notch', 'icmp.pbtCenter': 'Centrer PBT', 'icmp.manualNotch': 'Notch manuel — active MN, puis règle la position', 'icmp.squelch': 'Squelch', 'icmp.powerOnHint': 'Allumer la radio (démarre en ~15 s)', 'icmp.powerOffHint': 'Éteindre la radio', 'icmp.powerOffConfirm': 'Éteindre la radio ?', 'rst.clickToFill': 'Clic pour remplir le RST tx depuis le signal', 'qrz.openTitle': 'Ouvrir {call} sur QRZ.com', 'altm.filterPh': 'Filtrer…', 'altm.noMatch': 'aucun résultat', 'altm.noneAll': 'aucune sélection = TOUT', 'altm.nSelected': '{n} sélectionné(s)', 'altm.giveName': 'Donne un nom à la règle', 'altm.deleteConfirm': "Supprimer l'alerte « {name} » ?", 'altm.title': 'Gestion des alertes', 'altm.desc': 'Alerte quand un spot correspond à une règle. Filtres vides = TOUT ; les filtres définis sont combinés par ET (ex. France + 20m = stations françaises sur 20m).', 'altm.rules': 'Règles', 'altm.noRules': 'Aucune règle — clique sur +', 'altm.emailTo': "E-mail d'alerte à", 'altm.selectOrCreate': 'Sélectionne ou crée une règle.', 'altm.tabDef': 'Définition', 'altm.tabCall': 'Indicatif / DXCC', 'altm.tabBandMode': 'Bande / Mode', 'altm.tabOrigin': 'Origine', 'altm.ruleName': 'Nom de la règle', 'altm.alertEnabled': 'Alerte activée', 'altm.againAfter': 'Réalerter après (min)', 'altm.againHint': '0 = une fois/session · -1 = toujours', 'altm.actions': 'Actions', 'altm.visual': 'Visuel', 'altm.sound': 'Son', 'altm.email': 'E-mail', 'altm.skipWorked': 'Ignorer les indicatifs déjà contactés (même bande + mode)', 'altm.callsigns': 'Indicatifs (un par ligne, jokers : IW3*, */P)', 'altm.countries': 'Pays (DXCC)', 'altm.continents': 'Continents', 'altm.bands': 'Bandes', 'altm.modes': 'Modes', 'altm.spotterCall': 'Indicatif du spotteur (joker)', 'altm.spotterCallPh': 'ex. F* ou DL1ABC', 'altm.spotterContinents': 'Continents du spotteur', 'altm.spotterCountries': 'Pays du spotteur', 'altm.delete': 'Supprimer', 'altm.saveRule': 'Enregistrer la règle', 'altm.close': 'Fermer', diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index d036d34..a6c5e28 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -350,16 +350,30 @@ export function IcomSetAGC(arg1:string):Promise; export function IcomSetANF(arg1:boolean):Promise; +export function IcomSetAntenna(arg1:number):Promise; + +export function IcomSetAntiVOX(arg1:number):Promise; + export function IcomSetAtt(arg1:number):Promise; export function IcomSetBreakIn(arg1:number):Promise; +export function IcomSetComp(arg1:boolean):Promise; + +export function IcomSetCompLevel(arg1:number):Promise; + export function IcomSetFilter(arg1:number):Promise; export function IcomSetKeySpeed(arg1:number):Promise; +export function IcomSetManualNotch(arg1:boolean):Promise; + export function IcomSetMicGain(arg1:number):Promise; +export function IcomSetMonLevel(arg1:number):Promise; + +export function IcomSetMonitor(arg1:boolean):Promise; + export function IcomSetNB(arg1:boolean):Promise; export function IcomSetNBLevel(arg1:number):Promise; @@ -368,8 +382,16 @@ export function IcomSetNR(arg1:boolean):Promise; export function IcomSetNRLevel(arg1:number):Promise; +export function IcomSetNotchPos(arg1:number):Promise; + +export function IcomSetPBTInner(arg1:number):Promise; + +export function IcomSetPBTOuter(arg1:number):Promise; + export function IcomSetPTT(arg1:boolean):Promise; +export function IcomSetPower(arg1:boolean):Promise; + export function IcomSetPreamp(arg1:number):Promise; export function IcomSetRFGain(arg1:number):Promise; @@ -386,6 +408,12 @@ export function IcomSetScopeMode(arg1:boolean):Promise; export function IcomSetSplit(arg1:boolean):Promise; +export function IcomSetSquelch(arg1:number):Promise; + +export function IcomSetVOX(arg1:boolean):Promise; + +export function IcomSetVOXGain(arg1:number):Promise; + export function IcomSetXITOn(arg1:boolean):Promise; export function IcomStopCW():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index c209bbf..4e26fa5 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -662,6 +662,14 @@ export function IcomSetANF(arg1) { return window['go']['main']['App']['IcomSetANF'](arg1); } +export function IcomSetAntenna(arg1) { + return window['go']['main']['App']['IcomSetAntenna'](arg1); +} + +export function IcomSetAntiVOX(arg1) { + return window['go']['main']['App']['IcomSetAntiVOX'](arg1); +} + export function IcomSetAtt(arg1) { return window['go']['main']['App']['IcomSetAtt'](arg1); } @@ -670,6 +678,14 @@ export function IcomSetBreakIn(arg1) { return window['go']['main']['App']['IcomSetBreakIn'](arg1); } +export function IcomSetComp(arg1) { + return window['go']['main']['App']['IcomSetComp'](arg1); +} + +export function IcomSetCompLevel(arg1) { + return window['go']['main']['App']['IcomSetCompLevel'](arg1); +} + export function IcomSetFilter(arg1) { return window['go']['main']['App']['IcomSetFilter'](arg1); } @@ -678,10 +694,22 @@ export function IcomSetKeySpeed(arg1) { return window['go']['main']['App']['IcomSetKeySpeed'](arg1); } +export function IcomSetManualNotch(arg1) { + return window['go']['main']['App']['IcomSetManualNotch'](arg1); +} + export function IcomSetMicGain(arg1) { return window['go']['main']['App']['IcomSetMicGain'](arg1); } +export function IcomSetMonLevel(arg1) { + return window['go']['main']['App']['IcomSetMonLevel'](arg1); +} + +export function IcomSetMonitor(arg1) { + return window['go']['main']['App']['IcomSetMonitor'](arg1); +} + export function IcomSetNB(arg1) { return window['go']['main']['App']['IcomSetNB'](arg1); } @@ -698,10 +726,26 @@ export function IcomSetNRLevel(arg1) { return window['go']['main']['App']['IcomSetNRLevel'](arg1); } +export function IcomSetNotchPos(arg1) { + return window['go']['main']['App']['IcomSetNotchPos'](arg1); +} + +export function IcomSetPBTInner(arg1) { + return window['go']['main']['App']['IcomSetPBTInner'](arg1); +} + +export function IcomSetPBTOuter(arg1) { + return window['go']['main']['App']['IcomSetPBTOuter'](arg1); +} + export function IcomSetPTT(arg1) { return window['go']['main']['App']['IcomSetPTT'](arg1); } +export function IcomSetPower(arg1) { + return window['go']['main']['App']['IcomSetPower'](arg1); +} + export function IcomSetPreamp(arg1) { return window['go']['main']['App']['IcomSetPreamp'](arg1); } @@ -734,6 +778,18 @@ export function IcomSetSplit(arg1) { return window['go']['main']['App']['IcomSetSplit'](arg1); } +export function IcomSetSquelch(arg1) { + return window['go']['main']['App']['IcomSetSquelch'](arg1); +} + +export function IcomSetVOX(arg1) { + return window['go']['main']['App']['IcomSetVOX'](arg1); +} + +export function IcomSetVOXGain(arg1) { + return window['go']['main']['App']['IcomSetVOXGain'](arg1); +} + export function IcomSetXITOn(arg1) { return window['go']['main']['App']['IcomSetXITOn'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index e1b78b1..f18c82a 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -694,6 +694,19 @@ export namespace cat { preamp: number; att: number; filter: number; + antenna: number; + pbt_inner: number; + pbt_outer: number; + manual_notch: boolean; + notch_pos: number; + squelch: number; + comp: boolean; + comp_level: number; + monitor: boolean; + mon_level: number; + vox: boolean; + vox_gain: number; + anti_vox: number; static createFrom(source: any = {}) { return new IcomTXState(source); @@ -727,6 +740,19 @@ export namespace cat { this.preamp = source["preamp"]; this.att = source["att"]; this.filter = source["filter"]; + this.antenna = source["antenna"]; + this.pbt_inner = source["pbt_inner"]; + this.pbt_outer = source["pbt_outer"]; + this.manual_notch = source["manual_notch"]; + this.notch_pos = source["notch_pos"]; + this.squelch = source["squelch"]; + this.comp = source["comp"]; + this.comp_level = source["comp_level"]; + this.monitor = source["monitor"]; + this.mon_level = source["mon_level"]; + this.vox = source["vox"]; + this.vox_gain = source["vox_gain"]; + this.anti_vox = source["anti_vox"]; } } export class RigState { diff --git a/internal/cat/cat.go b/internal/cat/cat.go index b86256c..3b04d7f 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -32,6 +32,16 @@ type Backend interface { SetPTT(on bool) error } +// interruptible is an OPTIONAL backend capability: abort an in-progress Connect +// quickly. The network Icom backend's Connect blocks for up to tens of seconds +// (UDP handshake + login + waiting for the rig to boot from standby); without a +// way to interrupt it, Stop()/Start() would freeze on the poll goroutine until +// the dial gives up — which is why Settings "Save & Close" hung for ~1 min once +// the link was lost. Backends that don't implement it are simply not interrupted. +type interruptible interface { + Interrupt() +} + // RigState is the snapshot exchanged with the frontend. // // FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the @@ -156,6 +166,7 @@ func (m *Manager) stopLocked() { m.mu.Lock() stop := m.stopCh done := m.doneCh + b := m.backend m.stopCh = nil m.doneCh = nil m.cmdCh = nil @@ -164,6 +175,11 @@ func (m *Manager) stopLocked() { if stop != nil { close(stop) } + // Abort any in-progress Connect so we don't block on a slow network dial + // (the poll goroutine can be tens of seconds deep in the Icom UDP handshake). + if iv, ok := b.(interruptible); ok { + iv.Interrupt() + } if done != nil { <-done } @@ -403,6 +419,22 @@ type IcomTXState struct { Preamp int `json:"preamp"` // 0=off, 1=P.AMP1, 2=P.AMP2 Att int `json:"att"` // dB attenuation, 0=off Filter int `json:"filter"` // 1 | 2 | 3 (FIL1/2/3) + // Antenna (IC-7610 = ANT1/ANT2). + Antenna int `json:"antenna"` // 1 | 2 (0 = unknown) + // Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre). + PBTInner int `json:"pbt_inner"` + PBTOuter int `json:"pbt_outer"` + ManualNotch bool `json:"manual_notch"` + NotchPos int `json:"notch_pos"` + // TX extras. + Squelch int `json:"squelch"` + Comp bool `json:"comp"` + CompLevel int `json:"comp_level"` + Monitor bool `json:"monitor"` + MonLevel int `json:"mon_level"` + VOX bool `json:"vox"` + VOXGain int `json:"vox_gain"` + AntiVOX int `json:"anti_vox"` } // IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the @@ -436,6 +468,20 @@ type IcomController interface { StopCW() error // abort the CW message being sent SetKeySpeed(int) error // CW keyer speed in WPM SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL + SetAntenna(int) error // 1 = ANT1, 2 = ANT2 + SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre) + SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre) + SetManualNotch(bool) error + SetNotchPos(int) error // manual-notch position (0-100, 50 = centre) + SetSquelch(int) error + SetComp(bool) error + SetCompLevel(int) error + SetMonitor(bool) error + SetMonLevel(int) error + SetVOX(bool) error + SetVOXGain(int) error + SetAntiVOX(int) error + SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect) } // ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go index dd4d3bc..41899e1 100644 --- a/internal/cat/civ/civ.go +++ b/internal/cat/civ/civ.go @@ -37,7 +37,9 @@ const ( CmdPTT = 0x1C // sub 0x00 = PTT CmdExtra = 0x1A // sub 0x06 = data mode on modern Icoms CmdReadID = 0x19 // sub 0x00 = rig's own CI-V address (identifies model) + CmdPower = 0x18 // power on/off (sub 0x01 = on, 0x00 = off; on needs an FE wake preamble) + CmdAnt = 0x12 // antenna selector (sub 0x00 = ANT1, 0x01 = ANT2; read = no sub) 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 @@ -67,8 +69,16 @@ const ( // CmdLevel sub-commands. SubLevelAF = 0x01 // AF (volume) SubLevelRF = 0x02 // RF gain + SubLevelSQL = 0x03 // squelch level + SubLevelPBTIn = 0x07 // Twin PBT (inside) — 0-255, 128 = centre + SubLevelPBTOut = 0x08 // Twin PBT (outside) — 0-255, 128 = centre SubLevelNR = 0x06 // noise-reduction depth + SubLevelNotch = 0x0D // manual-notch position — 0-255, 128 = centre + SubLevelComp = 0x0E // speech-compressor level SubLevelNB = 0x12 // noise-blanker depth + SubLevelMon = 0x15 // monitor gain + SubLevelVOXGain = 0x16 // VOX gain + SubLevelAntiVOX = 0x17 // anti-VOX level SubLevelRFPower = 0x0A // TX RF output power SubLevelMic = 0x0B // mic gain @@ -94,7 +104,11 @@ const ( SubSwNB = 0x22 // noise blanker on/off SubSwNR = 0x40 // noise reduction on/off SubSwANF = 0x41 // auto-notch on/off + SubSwComp = 0x44 // speech compressor on/off + SubSwMon = 0x45 // monitor on/off + SubSwVOX = 0x46 // VOX on/off SubSwBreakIn = 0x47 // CW break-in: 0=OFF, 1=SEMI, 2=FULL (needed so 0x17 CW keys TX) + SubSwMN = 0x48 // manual notch on/off ) // CW break-in modes (CmdSwitch 0x47). diff --git a/internal/cat/icomnet.go b/internal/cat/icomnet.go index 3e5fdd0..0ea72b0 100644 --- a/internal/cat/icomnet.go +++ b/internal/cat/icomnet.go @@ -26,6 +26,7 @@ import ( "net" "strings" "sync" + "sync/atomic" "time" ) @@ -53,11 +54,31 @@ func NewIcomNet(host, user, pass string, civAddr int, digitalDefault string) *Ic if strings.TrimSpace(host) == "" { return nil, fmt.Errorf("no rig host configured") } - return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr) + b.dialMu.Lock() + cancel := b.dialCancel + b.dialMu.Unlock() + return dialIcomNet(host, user, pass, "OpsLog", b.rigAddr, cancel) } return b } +// errDialCanceled is returned by dialIcomNet when Interrupt() aborts the dial +// (Stop/Start). The Manager treats it like any connect error and simply stops. +var errDialCanceled = fmt.Errorf("dial canceled") + +// icnCanceled reports whether the dial has been asked to abort. +func icnCanceled(cancel <-chan struct{}) bool { + if cancel == nil { + return false + } + select { + case <-cancel: + return true + default: + return false + } +} + // icomNet is the connected network transport. It satisfies civTransport. type icomNet struct { ctrl *net.UDPConn // control stream (50001) @@ -71,7 +92,9 @@ type icomNet struct { vTracked uint16 vCivSeq uint16 - rx chan []byte // CI-V byte chunks from civPump → Read + rx chan []byte // CI-V byte chunks from civPump → Read (control replies) + scopeRx chan []byte // scope (0x27) frames, kept off rx so the panadapter + // stream can't crowd control replies out (→ ScopeChan) leftover []byte // partial chunk not yet returned by Read (Read-only) readTO time.Duration // Read timeout (SetReadTimeout) @@ -81,14 +104,77 @@ type icomNet struct { sentMu sync.Mutex sentBuf map[uint16][]byte + // Control-stream auth state, carried out of dial so ctrlPump can RENEW the + // login token every ~45 s. The rig invalidates the session ~2 min after login + // without renewal (this was the "loses control after 2 min" drop — RS-BA1/the + // Remote Utility renew too). Owned solely by ctrlPump after dial → no lock. + cTracked uint16 // control-stream tracked seq (continues after dial) + cAuthSeq uint16 // token-packet innerseq + cToken uint32 // login token (opaque, echoed back verbatim) + cTokReq uint16 // token-request id (echoed) + cSentBuf map[uint16][]byte // control-stream retransmit buffer (token renewals) + + // Receive-side retransmit (CI-V stream): track the rig's data-packet send seq + // and ask it to resend any gap. Under the scope stream, UDP drops are common; + // without recovering them the gaps accumulate and the rig drops the WHOLE + // session after ~20 s (RS-BA1/wfview request retransmits, which is why they + // stay up with the panadapter on). Owned solely by civPump → no lock. + rxHaveSeq bool + rxLastSeq uint16 + rxMissing map[uint16]int + + // lastRx is the UnixNano of the last packet received from the rig (any type), + // updated by both pumps. The rig's network server answers pings/idles even + // when the RADIO is in standby, so this tracks the CONTROL-LINK liveness + // independently of whether CI-V is replying — letting ReadState tell "rig off + // but link fine" (stay connected) from "link dead" (reconnect). See Alive(). + lastRx atomic.Int64 + done chan struct{} closeOnce sync.Once } +// ScopeChan exposes the raw scope (0x27) CI-V frames for the scope feeder. +// Satisfies scopeTransport in icomserial.go. +func (n *icomNet) ScopeChan() <-chan []byte { return n.scopeRx } + +// icnEnqueueDrop pushes onto a bounded channel, discarding the oldest entry when +// full — a lagging consumer never blocks the producer (used for the scope stream, +// where only the latest sweep matters). +func icnEnqueueDrop(ch chan []byte, v []byte) { + select { + case ch <- v: + default: + select { + case <-ch: + default: + } + select { + case ch <- v: + default: + } + } +} + func (n *icomNet) SetReadTimeout(d time.Duration) error { n.readTO = d; return nil } func (n *icomNet) SetDTR(bool) error { return nil } // n/a on the network func (n *icomNet) SetRTS(bool) error { return nil } +// markRx records that a packet just arrived from the rig (control-link liveness). +func (n *icomNet) markRx() { n.lastRx.Store(time.Now().UnixNano()) } + +// Alive reports whether the rig's network server is still talking to us. The rig +// pings/idles continuously (even in standby), so a gap means the link — not just +// the radio — is gone. Independent of CI-V replies, so a powered-off rig still +// reads as Alive and the session isn't torn down. Satisfies aliveTransport. +func (n *icomNet) Alive() bool { + last := n.lastRx.Load() + if last == 0 { + return true // just connected, nothing received yet — give it a chance + } + return time.Since(time.Unix(0, last)) < 6*time.Second +} + // Read returns tunnelled CI-V bytes, mimicking a serial port: (0,nil) on // timeout, (n,nil) with data, (0,err) when the link is closed. func (n *icomNet) Read(p []byte) (int, error) { @@ -129,7 +215,8 @@ func (n *icomNet) Write(p []byte) (int, error) { n.vCivSeq++ n.sentMu.Lock() n.sentBuf[seq] = pkt - delete(n.sentBuf, seq-256) // keep the buffer bounded (~last 256 packets) + delete(n.sentBuf, seq-1024) // keep the buffer bounded (~last 1024 packets) so + // the rig's retransmit requests still hit even under sustained CW + poll load n.sentMu.Unlock() if _, err := n.civ.Write(pkt); err != nil { return 0, err @@ -137,28 +224,43 @@ func (n *icomNet) Write(p []byte) (int, error) { return len(p), nil } -// icnTrace toggles verbose CI-V request/reply logging for diagnosing the -// network transport (temporary). -var icnTrace = true +// icnTrace toggles verbose per-frame CI-V request/reply logging for diagnosing +// the network transport. Off by default (the connect-step logs stay); flip to +// true to trace every TX/RX again. +var icnTrace = false func (n *icomNet) Close() error { n.closeOnce.Do(func() { close(n.done) - // Best-effort clean teardown. - _, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close - _, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote)) // disconnect - _, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote)) + // Tell the rig we're leaving so it frees its SINGLE control session at + // once. If it never gets a disconnect it holds the session for minutes and + // refuses every new login — which is why a lost link (or a hard app exit) + // left the rig un-reconnectable, even from the Icom Remote Utility. UDP is + // lossy, so send openClose(close) + disconnect on both streams a few times. + // The whole teardown is bounded to ~90 ms so it never stalls the caller + // (Settings "Save & Close" / a reconnect's Disconnect). + for i := 0; i < 3; i++ { + _, _ = n.civ.Write(icnOpenClose(n.vTracked, n.vID, n.vRemote, n.vCivSeq, 0x00)) // close CI-V + _, _ = n.civ.Write(icnCtrl(0x05, 0, n.vID, n.vRemote)) // disconnect civ + _, _ = n.ctrl.Write(icnCtrl(0x05, 0, n.cID, n.cRemote)) // disconnect ctrl + time.Sleep(25 * time.Millisecond) + } + debugLog.Printf("icom net: sent disconnect to rig (session released)") _ = n.civ.Close() _ = n.ctrl.Close() }) return nil } -// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings -// and sends idle keepalives. Its own goroutine so it never throttles civPump. +// ctrlPump keeps the control stream (50001) alive: replies to the rig's pings, +// sends idle keepalives, RENEWS the login token every ~45 s (without this the rig +// invalidates the session after ~2 min → total loss of control), and answers the +// rig's retransmit requests for those tracked control packets. Its own goroutine +// so it never throttles civPump. func (n *icomNet) ctrlPump() { buf := make([]byte, 4096) lastIdle := time.Now() + lastToken := time.Now() // token was just granted during dial for { select { case <-n.done: @@ -167,19 +269,49 @@ func (n *icomNet) ctrlPump() { } _ = n.ctrl.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) if k, err := n.ctrl.Read(buf); err == nil && k >= 16 { + n.markRx() switch icnLE.Uint16(buf[4:]) { case 0x07: // ping _, _ = n.ctrl.Write(icnPingReply(buf[:k], n.cID, n.cRemote)) - case 0x01: // retransmit request + case 0x01: // retransmit request — resend from the CONTROL sent-buffer if k >= 8 { - n.resend(icnLE.Uint16(buf[6:])) + n.ctrlResend(icnLE.Uint16(buf[6:])) } + case 0x05: // rig-initiated disconnect — it dropped US + debugLog.Printf("icom net: rig sent DISCONNECT on control stream — session dropped by the rig") } } - if time.Since(lastIdle) > 150*time.Millisecond { + if time.Since(lastIdle) > 100*time.Millisecond { _, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote)) lastIdle = time.Now() } + if time.Since(lastToken) > 45*time.Second { + n.renewToken() + lastToken = time.Now() + } + } +} + +// renewToken re-authorizes the session (control 0x40 token packet, requesttype +// 0x05). Tracked so a lost renewal can be retransmitted. Runs only on ctrlPump, +// the sole owner of the control-stream auth state, so no locking is needed. +func (n *icomNet) renewToken() { + seq := n.cTracked + pkt := icnTokenRenew(seq, n.cAuthSeq, n.cTokReq, n.cID, n.cRemote, n.cToken) + n.cTracked++ + n.cAuthSeq++ + n.cSentBuf[seq] = pkt + delete(n.cSentBuf, seq-256) + _, _ = n.ctrl.Write(pkt) + debugLog.Printf("icom net: token renewed (seq %d)", seq) +} + +// ctrlResend answers a control-stream retransmit request from the control +// sent-buffer (token renewals). Separate from resend(), which owns the CI-V +// buffer — the two streams have independent sequence spaces. +func (n *icomNet) ctrlResend(seq uint16) { + if pkt := n.cSentBuf[seq]; pkt != nil { + _, _ = n.ctrl.Write(pkt) } } @@ -190,6 +322,7 @@ func (n *icomNet) ctrlPump() { func (n *icomNet) civPump() { buf := make([]byte, 8192) lastIdle := time.Now() + lastReq := time.Now() for { select { case <-n.done: @@ -198,6 +331,7 @@ func (n *icomNet) civPump() { } _ = n.civ.SetReadDeadline(time.Now().Add(100 * time.Millisecond)) if k, err := n.civ.Read(buf); err == nil && k >= 16 { + n.markRx() switch typ := icnLE.Uint16(buf[4:]); { case typ == 0x07: // ping _, _ = n.civ.Write(icnPingReply(buf[:k], n.vID, n.vRemote)) @@ -205,29 +339,36 @@ func (n *icomNet) civPump() { if k >= 8 { n.resend(icnLE.Uint16(buf[6:])) } + case typ == 0x05: // rig-initiated disconnect — it dropped US + debugLog.Printf("icom net: rig sent DISCONNECT on CI-V stream — session dropped by the rig") case typ == 0x00 && k > 0x15 && buf[0x10] == 0xc1: // CI-V data + n.trackRxSeq(icnLE.Uint16(buf[6:])) // note gaps for retransmit civBytes := buf[0x15:k] - // Skip scope (0x27) frames: over the network the rig streams the - // panadapter continuously as large frames that would crowd control - // replies out of rx. (Network scope is handled separately.) - if !(len(civBytes) >= 5 && civBytes[4] == 0x27) { - if icnTrace { - debugLog.Printf("icom net RX: % X", civBytes) + cp := append([]byte(nil), civBytes...) + // Scope (0x27) frames go to their OWN channel: the panadapter streams + // continuously as large frames and would otherwise crowd control + // replies out of rx (every command would then time out). The scope + // feeder in IcomSerial picks them up. Everything else is a control + // reply → rx → Read. + if len(civBytes) >= 5 && civBytes[4] == 0x27 { + icnEnqueueDrop(n.scopeRx, cp) + break + } + if icnTrace { + debugLog.Printf("icom net RX: % X", civBytes) + } + select { + case n.rx <- cp: + case <-n.done: + return + default: // buffer full — drop oldest, enqueue newest + select { + case <-n.rx: + default: } - cp := append([]byte(nil), civBytes...) select { case n.rx <- cp: - case <-n.done: - return - default: // buffer full — drop oldest, enqueue newest - select { - case <-n.rx: - default: - } - select { - case n.rx <- cp: - default: - } + default: } } } @@ -236,6 +377,82 @@ func (n *icomNet) civPump() { _, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote)) lastIdle = time.Now() } + if time.Since(lastReq) > 100*time.Millisecond { + n.sendRetransmitReq() + lastReq = time.Now() + } + } +} + +// icnMaxMissing caps the outstanding retransmit backlog; a bigger jump is treated +// as a wrap/desync and the tracker resets rather than requesting a storm. +const icnMaxMissing = 50 + +// trackRxSeq records the rig's data-packet send seq (outer seq @0x06) and flags +// any forward gap as missing so sendRetransmitReq can ask for it. Handles uint16 +// wrap via the signed distance; ignores duplicates and already-seen packets. +func (n *icomNet) trackRxSeq(seq uint16) { + if !n.rxHaveSeq { + n.rxHaveSeq = true + n.rxLastSeq = seq + return + } + switch d := int16(seq - n.rxLastSeq); { + case d == 0: // duplicate + case d < 0: // an older seq arrived — a retransmit we were missing + delete(n.rxMissing, seq) + case d == 1: // in order + n.rxLastSeq = seq + case int(d) <= icnMaxMissing: // forward gap — mark the in-between seqs missing + for f := n.rxLastSeq + 1; f != seq; f++ { + n.rxMissing[f] = 0 + } + n.rxLastSeq = seq + default: // huge jump (wrap/desync) — reset to avoid a false retransmit storm + n.rxMissing = make(map[uint16]int) + n.rxLastSeq = seq + } +} + +// sendRetransmitReq asks the rig to resend any CI-V data packets we detected as +// missing. Each seq is requested up to 4 times then dropped. Mirrors the Remote +// Utility/wfview format: a single miss = a 16-byte control (type 0x01, seq set); +// several = a control header + a list of [lo hi lo hi] per seq. +func (n *icomNet) sendRetransmitReq() { + if len(n.rxMissing) == 0 { + return + } + if len(n.rxMissing) > icnMaxMissing { + n.rxMissing = make(map[uint16]int) // hopelessly behind — flush and move on + return + } + var seqs []uint16 + for s, cnt := range n.rxMissing { + if cnt >= 4 { + delete(n.rxMissing, s) + continue + } + n.rxMissing[s] = cnt + 1 + seqs = append(seqs, s) + } + switch { + case len(seqs) == 0: + return + case len(seqs) == 1: + _, _ = n.civ.Write(icnCtrl(0x01, seqs[0], n.vID, n.vRemote)) + default: + b := make([]byte, 16+4*len(seqs)) + icnLE.PutUint32(b[0:], uint32(len(b))) + icnLE.PutUint16(b[4:], 0x01) // type = retransmit request + icnLE.PutUint32(b[8:], n.vID) + icnLE.PutUint32(b[12:], n.vRemote) + off := 16 + for _, s := range seqs { + icnLE.PutUint16(b[off:], s) + icnLE.PutUint16(b[off+2:], s) + off += 4 + } + _, _ = n.civ.Write(b) } } @@ -248,12 +465,17 @@ func (n *icomNet) resend(seq uint16) { n.sentMu.Unlock() if pkt != nil { _, _ = n.civ.Write(pkt) + } else { + // The rig asked for a packet we've already evicted (>256 sent since). It + // can't fill its gap → it eventually drops the session. If this shows up in + // the log around a disconnect, the send buffer is too small for the load. + debugLog.Printf("icom net: retransmit MISS for seq %d (already evicted)", seq) } } // ------------------------- connect ------------------------- -func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, error) { +func dialIcomNet(host, user, pass, compName string, rigAddr byte, cancel <-chan struct{}) (*icomNet, error) { debugLog.Printf("icom net: connecting to %s (user %q, comp %q, rig addr 0x%02X)", host, user, compName, rigAddr) // ---- control stream (50001): handshake → login → token → conninfo ---- craddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(host, "50001")) @@ -265,7 +487,7 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, err return nil, fmt.Errorf("dial control: %w", err) } cID := icnLocalID(ctrl) - cRemote, err := icnHandshake(ctrl, cID) + cRemote, err := icnHandshake(ctrl, cID, cancel) if err != nil { _ = ctrl.Close() debugLog.Printf("icom net: control handshake FAILED (rig unreachable at %s:50001?): %v", host, err) @@ -283,6 +505,10 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, err buf := make([]byte, 2048) deadline := time.Now().Add(5 * time.Second) for token == 0 && time.Now().Before(deadline) { + if icnCanceled(cancel) { + _ = ctrl.Close() + return nil, errDialCanceled + } p, ok := icnRecv(ctrl, 200, buf) if !ok { continue @@ -314,6 +540,10 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, err var rigMAC []byte macEnd := time.Now().Add(1200 * time.Millisecond) for time.Now().Before(macEnd) { + if icnCanceled(cancel) { + _ = ctrl.Close() + return nil, errDialCanceled + } p, ok := icnRecv(ctrl, 150, buf) if !ok { continue @@ -337,6 +567,10 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, err cInner++ drainEnd := time.Now().Add(500 * time.Millisecond) for time.Now().Before(drainEnd) { + if icnCanceled(cancel) { + _ = ctrl.Close() + return nil, errDialCanceled + } if p, ok := icnRecv(ctrl, 100, buf); ok && icnLE.Uint16(p[4:]) == 0x07 { _, _ = ctrl.Write(icnPingReply(p, cID, cRemote)) } @@ -356,14 +590,14 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, err return nil, fmt.Errorf("dial CI-V (local :50002 — is the Icom Remote Utility still running?): %w", err) } vID := icnLocalID(civ) - vRemote, err := icnHandshake(civ, vID) + vRemote, err := icnHandshake(civ, vID, cancel) if err != nil { _ = civ.Close() _ = ctrl.Close() debugLog.Printf("icom net: CI-V handshake FAILED: %v", err) return nil, fmt.Errorf("CI-V handshake: %w", err) } - debugLog.Printf("icom net: CI-V link up — sending power-on, waiting for the rig to boot (~15s)") + debugLog.Printf("icom net: CI-V link up — opening the CI-V data flow (rig power left to the ON button)") // Bigger receive buffers so a burst of scope/CI-V packets doesn't overflow // (dropped packets → the rig's retransmit requests → session drop). @@ -374,93 +608,46 @@ func dialIcomNet(host, user, pass, compName string, rigAddr byte) (*icomNet, err ctrl: ctrl, civ: civ, cID: cID, cRemote: cRemote, vID: vID, vRemote: vRemote, vTracked: 1, vCivSeq: 1, - rx: make(chan []byte, 256), - sentBuf: make(map[uint16][]byte), - done: make(chan struct{}), + rx: make(chan []byte, 256), + scopeRx: make(chan []byte, 8), + sentBuf: make(map[uint16][]byte), + rxMissing: make(map[uint16]int), + done: make(chan struct{}), + // Auth state for periodic token renewal (see ctrlPump). cTracked/cAuthSeq + // continue the control-stream sequences from where the dial's login/token/ + // conninfo left off. + cTracked: cTracked, cAuthSeq: cInner, + cToken: token, cTokReq: tokReq, + cSentBuf: make(map[uint16][]byte), } - // openClose(open) starts the CI-V data flow. + n.markRx() // the successful handshake counts as initial rig activity + // openClose(open) starts the CI-V data flow. We intentionally DO NOT power the + // rig on here — that's a manual ON button now (the user asked not to wake the + // rig at launch). If the rig is in standby the control/CI-V streams still stay + // up and Alive() stays true (the rig's server answers pings even when the radio + // is off), so the session doesn't flap; CI-V just stays silent until ON. ocPkt := icnOpenClose(n.vTracked, vID, vRemote, n.vCivSeq, 0x04) n.sentBuf[n.vTracked] = ocPkt _, _ = civ.Write(ocPkt) n.vTracked++ n.vCivSeq++ - // Power-on (the rig may be in standby — it NG's every command until on): - // an FE wake preamble then FE FE E0 18 01 FD. Harmless if already on. - po := make([]byte, 0, 32) - for i := 0; i < 25; i++ { - po = append(po, 0xFE) - } - po = append(po, 0xFE, 0xFE, rigAddr, 0xE0, 0x18, 0x01, 0xFD) - poPkt := icnCivData(n.vTracked, vID, vRemote, n.vCivSeq, po) - n.sentBuf[n.vTracked] = poPkt - _, _ = civ.Write(poPkt) - n.vTracked++ - n.vCivSeq++ - - // Wait for the rig to finish booting: a rig woken from standby NG's/ignores - // commands for ~10-15 s. Poll read-freq (keeping both streams alive) until it - // answers, so Connect only returns a READY link — otherwise the manager's - // read-timeouts would flap the connection during boot. Give up after 25 s and - // return anyway (the rig may already be on and just quiet). - if n.waitReady(rigAddr, 25*time.Second) { - debugLog.Printf("icom net: rig is READY (answered read-freq) — connection up ✓") - } else { - debugLog.Printf("icom net: boot wait timed out (25s, no freq reply) — proceeding anyway") - } go n.ctrlPump() go n.civPump() return n, nil } -// waitReady polls read-freq until the rig replies (booted) or timeout, replying -// to pings and sending idle keepalives so the session stays up. Runs before the -// pump goroutine starts, so it owns the socket reads. -func (n *icomNet) waitReady(rigAddr byte, timeout time.Duration) bool { - cbuf := make([]byte, 4096) - vbuf := make([]byte, 4096) - readFreq := []byte{0xFE, 0xFE, rigAddr, 0xE0, 0x03, 0xFD} - end := time.Now().Add(timeout) - var lastPoll, lastIdle time.Time - for time.Now().Before(end) { - if p, ok := icnRecv(n.ctrl, 25, cbuf); ok && icnLE.Uint16(p[4:]) == 0x07 { - _, _ = n.ctrl.Write(icnPingReply(p, n.cID, n.cRemote)) - } - if p, ok := icnRecv(n.civ, 25, vbuf); ok { - typ := icnLE.Uint16(p[4:]) - if typ == 0x07 { - _, _ = n.civ.Write(icnPingReply(p, n.vID, n.vRemote)) - } else if typ == 0x00 && len(p) > 0x15 && p[0x10] == 0xc1 { - f := p[0x15:] - // A frequency reply from the rig: FE FE E0 03 … - if len(f) >= 6 && f[0] == 0xFE && f[1] == 0xFE && f[3] == rigAddr && f[4] == 0x03 { - return true - } - } - } - if time.Since(lastIdle) > 180*time.Millisecond { - _, _ = n.ctrl.Write(icnCtrl(0x00, 0, n.cID, n.cRemote)) - _, _ = n.civ.Write(icnCtrl(0x00, 0, n.vID, n.vRemote)) - lastIdle = time.Now() - } - if time.Since(lastPoll) > 1000*time.Millisecond { - _, _ = n.civ.Write(icnCivData(n.vTracked, n.vID, n.vRemote, n.vCivSeq, readFreq)) - n.vTracked++ - n.vCivSeq++ - lastPoll = time.Now() - } - } - return false -} - // icnHandshake: areYouThere(seq0) → iAmHere → areYouReady(seq1) → iAmReady. -func icnHandshake(c *net.UDPConn, myID uint32) (uint32, error) { +func icnHandshake(c *net.UDPConn, myID uint32, cancel <-chan struct{}) (uint32, error) { buf := make([]byte, 2048) _, _ = c.Write(icnCtrl(0x03, 0, myID, 0)) var remoteID uint32 deadline := time.Now().Add(4 * time.Second) lastTry := time.Now() for time.Now().Before(deadline) { + if icnCanceled(cancel) { + return 0, errDialCanceled + } p, ok := icnRecv(c, 200, buf) if !ok { if remoteID == 0 && time.Since(lastTry) > 500*time.Millisecond { @@ -564,6 +751,27 @@ func icnToken(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte return b } +// icnTokenRenew builds the periodic token-renewal packet (control 0x40). Same as +// the login-time token confirm but requesttype 0x05 (renew) with the resetcap +// field (0x0798 BE @0x24) the Remote Utility sends on renewals. Keeps the rig +// from invalidating the session (~2-min timeout without renewal). Offsets per the +// wfview token_packet struct (verified) — protocol facts, not copied code. +func icnTokenRenew(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32) []byte { + b := make([]byte, 0x40) + icnLE.PutUint32(b[0:], 0x40) + icnLE.PutUint16(b[6:], seq) + icnLE.PutUint32(b[8:], sentid) + icnLE.PutUint32(b[12:], rcvdid) + icnBE.PutUint32(b[0x10:], 0x40-0x10) + b[0x14] = 0x01 // requestreply = request + b[0x15] = 0x05 // requesttype = token renewal + icnBE.PutUint16(b[0x16:], innerSeq) + icnLE.PutUint16(b[0x1a:], tokReq) + icnLE.PutUint32(b[0x1c:], token) + icnBE.PutUint16(b[0x24:], 0x0798) // resetcap + return b +} + func icnConnInfo(seq, innerSeq, tokReq uint16, sentid, rcvdid, token uint32, user string, rigMAC []byte, civPort, audioPort uint16) []byte { b := make([]byte, 0x90) icnLE.PutUint32(b[0:], 0x90) diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go index ea2ac8c..eb5f344 100644 --- a/internal/cat/icomserial.go +++ b/internal/cat/icomserial.go @@ -27,6 +27,26 @@ type civTransport interface { SetRTS(bool) error } +// aliveTransport is an OPTIONAL transport capability: report whether the link is +// still up independently of whether the rig answers CI-V. The network transport +// implements it (the rig's server pings even in standby), letting ReadState keep +// the session "connected but rig off" instead of tearing it down and flapping. +// USB doesn't implement it (no such out-of-band signal), so it keeps the bounded +// read-failure tolerance instead. +type aliveTransport interface { + Alive() bool +} + +// scopeTransport is an OPTIONAL transport capability: deliver spectrum-scope +// (0x27) frames on a SEPARATE channel from control replies. The network transport +// implements it so the continuous panadapter stream can't crowd control replies +// out of the main Read path (which made every command time out with the scope +// on). USB doesn't implement it — there the scope frames ride the normal Read +// path and the reader splits them off to specCh. +type scopeTransport interface { + ScopeChan() <-chan []byte +} + // IcomSerial controls an Icom transceiver over the shared civ protocol. The // transport is pluggable via `open`: NewIcomSerial opens a USB/serial port; // NewIcomNet (later) returns one configured with a network transport. Implements @@ -75,6 +95,8 @@ type IcomSerial struct { 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) + dspLoaded bool // readDSP has run since the rig became responsive (loads all + // the panel's set-once controls once the rig actually answers) lastSetFreq int64 // last frequency commanded (spot click: freq then mode) lastSetFreqAt time.Time @@ -83,6 +105,29 @@ type IcomSerial struct { // / setters) — hence the mutex. dspMu sync.Mutex dsp IcomTXState + + // dialCancel is closed by Interrupt() to abort an in-progress network dial + // (icomnet's handshake/login/boot-wait can block ~tens of seconds). A fresh + // channel is made by each Connect. Guarded by dialMu: written on the CAT + // goroutine, closed from the goroutine calling Stop. + dialMu sync.Mutex + dialCancel chan struct{} +} + +// Interrupt aborts an in-progress network Connect so Stop()/Start() don't block +// on a slow UDP handshake (or the 25 s boot-from-standby wait). Safe to call at +// any time and from another goroutine; harmless when no dial is in progress and +// a no-op for the USB transport (which dials instantly). +func (b *IcomSerial) Interrupt() { + b.dialMu.Lock() + if b.dialCancel != nil { + select { + case <-b.dialCancel: // already closed + default: + close(b.dialCancel) + } + } + b.dialMu.Unlock() } const ( @@ -130,6 +175,11 @@ func (b *IcomSerial) Connect() error { if b.open == nil { return fmt.Errorf("no transport configured") } + // Fresh cancel channel for this dial so Interrupt() (called by Stop) can abort + // a slow network handshake instead of freezing the UI. + b.dialMu.Lock() + b.dialCancel = make(chan struct{}) + b.dialMu.Unlock() port, err := b.open() if err != nil { return err @@ -154,6 +204,11 @@ func (b *IcomSerial) Connect() error { b.readerDone = make(chan struct{}) go b.reader(port, b.readerDone) go b.scopeLoop(b.specCh, b.readerDone) + // On the network the scope frames come on their own channel (kept off the + // control Read path); feed them into the same scope pipeline. + if sc, ok := port.(scopeTransport); ok { + go b.netScopeFeeder(sc.ScopeChan(), 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 { @@ -166,7 +221,11 @@ func (b *IcomSerial) Connect() error { // 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 + // Defer the DSP snapshot until the rig actually answers CI-V. Over the network + // the rig may still be booting (or off) at Connect, so an immediate readDSP + // would time out and leave every control at 0 / off with no retry. ReadState + // loads it once on the first successful freq read instead (see dspLoaded). + b.dspLoaded = false return nil } @@ -191,11 +250,38 @@ func (b *IcomSerial) ReadState() (RigState, error) { hz, err := b.readFreq() if err != nil { - // The rig briefly stops answering CI-V while it switches band/VFO. Treat a - // few consecutive misses as transient — keep the connection and report the - // last known state — so a band change doesn't trigger a full disconnect + - // 5 s reconnect (which showed the new frequency ~10 s late). Only after - // several failures do we declare the rig lost so the Manager reconnects. + // Network transport: if the control link is still alive, the rig is simply + // silent — either in standby / powered OFF (the ON button is manual now), or + // mid band-change. Stay CONNECTED and show last-known state (empty until the + // rig is switched on) rather than tearing the whole UDP session down and + // flapping every few seconds. The panel stays up so the ON button works. + if at, ok := b.port.(aliveTransport); ok { + if at.Alive() { + b.readFails = 0 + s.FreqHz = b.curFreq // 0 until the rig is powered on and first read + if b.curModeByte != 0 { + s.Mode = civ.ModeToADIF(b.curModeByte, false) + if s.Mode == "DATA" { + s.Mode = b.digital + } + } + // Keep the Icom panel visible (so ON/OFF are reachable) but show no + // live meters while the rig is silent. + b.dspMu.Lock() + b.dsp.Available = true + b.dsp.Model = b.model + b.dsp.Transmitting = false + b.dsp.SMeter, b.dsp.PowerMeter, b.dsp.SWRMeter = 0, 0, 0 + b.dspMu.Unlock() + return s, nil + } + return RigState{}, err // control link dead → let the Manager reconnect + } + // USB (no liveness signal): the rig briefly stops answering CI-V while it + // switches band/VFO. Tolerate a few consecutive misses as transient — keep + // the connection and report last-known state — so a band change doesn't + // trigger a full disconnect + 5 s reconnect. Only after several failures do + // we declare the rig lost so the Manager reconnects. b.readFails++ if b.readFails <= 6 && b.curFreq > 0 { s.FreqHz = b.curFreq @@ -268,6 +354,15 @@ func (b *IcomSerial) ReadState() (RigState, error) { b.dsp.PowerMeter = po b.dsp.SWRMeter = swr b.dspMu.Unlock() + + // First time the rig answers (it's booted/responsive): load the full DSP + // snapshot so the panel's antenna, sliders, RIT, notch, etc. reflect the rig + // instead of sitting at their zero defaults. Runs once; ↻ Refresh re-reads on + // demand, and a reconnect re-arms it (Connect clears dspLoaded). + if !b.dspLoaded { + b.readDSP() + b.dspLoaded = true + } return s, nil } @@ -306,6 +401,30 @@ func (b *IcomSerial) SetPTT(on bool) error { return b.exec(civ.CmdPTT, civ.SubPTT, state) } +// SetPower turns the transceiver on or off (CI-V 0x18). Power-ON is prefixed with +// a run of 0xFE — the wake preamble Icom rigs need to notice a command while +// asleep (harmless when already awake); after it the rig boots for ~10-15 s. +// Sent raw with no ack wait, since a rig waking up or shutting down won't +// reliably answer. On the network transport the whole buffer becomes one data +// packet, exactly as the Remote Utility sends it. Power is manual (the app never +// wakes the rig on connect), so this is driven by the panel's ON/OFF button. +func (b *IcomSerial) SetPower(on bool) error { + if b.port == nil { + return fmt.Errorf("icom: not connected") + } + if on { + buf := make([]byte, 0, 32) + for i := 0; i < 25; i++ { + buf = append(buf, 0xFE) + } + buf = append(buf, 0xFE, 0xFE, b.rigAddr, civ.AddrController, civ.CmdPower, 0x01, 0xFD) + _, err := b.port.Write(buf) + return err + } + _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, civ.CmdPower, 0x00)) + return err +} + // ── helpers ─────────────────────────────────────────────────────────────── func (b *IcomSerial) write(payload ...byte) error { @@ -374,6 +493,37 @@ func (b *IcomSerial) reader(port civTransport, done chan struct{}) { } } +// netScopeFeeder decodes the raw scope (0x27) CI-V frames the network transport +// delivers on its own channel and routes them into specCh — the same pipeline +// the USB reader feeds — so scopeLoop assembles them identically. Exits when the +// connection's reader does (done closes on Disconnect). +func (b *IcomSerial) netScopeFeeder(ch <-chan []byte, done chan struct{}) { + var buf []byte + for { + select { + case <-done: + return + case raw, ok := <-ch: + if !ok { + return + } + buf = append(buf, raw...) + frames, consumed := civ.Scan(buf) + if consumed > 0 { + buf = append(buf[:0], buf[consumed:]...) + } + for _, f := range frames { + if f.From == b.rigAddr && f.Cmd == civ.CmdScope { + b.route(b.specCh, f) + } + } + if len(buf) > 1<<16 { // a frame that never completes — don't grow forever + buf = buf[:0] + } + } + } +} + // 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) { @@ -451,6 +601,32 @@ func (b *IcomSerial) scopeLoop(spec chan civ.Decoded, done chan struct{}) { if seq == 0 || tot == 0 { continue } + if tot == 1 { + // Network single-frame sweep: the WHOLE sweep is in one frame — + // region = [info][low 5-BCD][high 5-BCD][amplitude bytes…]. Parse the + // edges and take the rest as the trace, then publish immediately. + // (USB splits this across 21 frames; the net rig sends it as one.) + if len(region) >= 11 { + low := civ.BCDToFreq(region[1:6]) + high := civ.BCDToFreq(region[6:11]) + amp := append([]byte(nil), region[11:]...) + b.scopeMu.Lock() + b.scopeLow, b.scopeHigh = low, high + b.scopeAmp = amp + b.scopeSeq++ + firstLog := !b.scopeSeen + b.scopeSeen = true + b.scopeMu.Unlock() + if firstLog { + head := region + if len(head) > 24 { + head = head[:24] + } + applog.Printf("icom scope (net 1-frame): region=%dB head=[% X] → edges %d..%d Hz points=%d", len(region), head, low, high, len(amp)) + } + } + continue + } if seq == 1 { // header frame — begins a new sweep, no waveform data regions = make(map[byte][]byte) total = tot @@ -935,6 +1111,46 @@ func (b *IcomSerial) readDSP() { if v, ok := b.readSwitch(civ.SubSwBreakIn); ok { st.BreakIn = int(v) } + // Antenna + filter fine controls + TX extras. + if v, ok := b.readAnt(); ok { + st.Antenna = v + } + if v, ok := b.readLevel(civ.SubLevelPBTIn); ok { + st.PBTInner = from255(v) + } + if v, ok := b.readLevel(civ.SubLevelPBTOut); ok { + st.PBTOuter = from255(v) + } + if v, ok := b.readSwitch(civ.SubSwMN); ok { + st.ManualNotch = v != 0 + } + if v, ok := b.readLevel(civ.SubLevelNotch); ok { + st.NotchPos = from255(v) + } + if v, ok := b.readLevel(civ.SubLevelSQL); ok { + st.Squelch = from255(v) + } + if v, ok := b.readSwitch(civ.SubSwComp); ok { + st.Comp = v != 0 + } + if v, ok := b.readLevel(civ.SubLevelComp); ok { + st.CompLevel = from255(v) + } + if v, ok := b.readSwitch(civ.SubSwMon); ok { + st.Monitor = v != 0 + } + if v, ok := b.readLevel(civ.SubLevelMon); ok { + st.MonLevel = from255(v) + } + if v, ok := b.readSwitch(civ.SubSwVOX); ok { + st.VOX = v != 0 + } + if v, ok := b.readLevel(civ.SubLevelVOXGain); ok { + st.VOXGain = from255(v) + } + if v, ok := b.readLevel(civ.SubLevelAntiVOX); ok { + st.AntiVOX = from255(v) + } b.dspMu.Lock() b.dsp = st @@ -982,6 +1198,22 @@ func (b *IcomSerial) readAtt() (int, bool) { return civ.BCDToByte(f.Data[0]), true } +func (b *IcomSerial) readAnt() (int, bool) { + if err := b.write(civ.CmdAnt); err != nil { + return 0, false + } + f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdAnt && len(d.Data) >= 1 + }) + if err != nil { + return 0, false + } + if f.Data[0] == 0x01 { + return 2, true + } + return 1, true +} + func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) { if err := b.write(civ.CmdReadMode); err != nil { return 0, 0, false @@ -1123,6 +1355,126 @@ func (b *IcomSerial) SetIcomSplit(on bool) error { return nil } +// ── Antenna ──────────────────────────────────────────────────────────────── + +func (b *IcomSerial) SetAntenna(n int) error { + sub := byte(0x00) // ANT1 + if n == 2 { + sub = 0x01 // ANT2 + } + if err := b.exec(civ.CmdAnt, sub); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { + if n == 2 { + s.Antenna = 2 + } else { + s.Antenna = 1 + } + }) + return nil +} + +// ── Filter: Twin PBT + manual notch ──────────────────────────────────────── + +func (b *IcomSerial) SetPBTInner(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTIn}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.PBTInner = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetPBTOuter(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelPBTOut}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.PBTOuter = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetManualNotch(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwMN, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.ManualNotch = on }) + return nil +} + +func (b *IcomSerial) SetNotchPos(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNotch}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.NotchPos = clampPct(p) }) + return nil +} + +// ── TX extras: squelch / compressor / monitor / VOX ──────────────────────── + +func (b *IcomSerial) SetSquelch(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelSQL}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.Squelch = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetComp(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwComp, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.Comp = on }) + return nil +} + +func (b *IcomSerial) SetCompLevel(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelComp}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.CompLevel = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetMonitor(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwMon, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.Monitor = on }) + return nil +} + +func (b *IcomSerial) SetMonLevel(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelMon}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.MonLevel = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetVOX(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwVOX, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.VOX = on }) + return nil +} + +func (b *IcomSerial) SetVOXGain(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelVOXGain}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.VOXGain = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetAntiVOX(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAntiVOX}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.AntiVOX = clampPct(p) }) + 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)