feat: Implemented scope on Ethernet for Icom

This commit is contained in:
2026-07-07 09:31:53 +02:00
parent 701e8a2c25
commit 7a24282aa4
12 changed files with 1163 additions and 165 deletions
+107
View File
@@ -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 {
+15 -2
View File
@@ -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<MainPaneKind>('map1');
const [mainPaneRight, setMainPaneRight] = useState<MainPaneKind>('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; }} />
</div>
);
case 'icom':
return (
<div className="h-full w-full min-h-0 rounded-lg overflow-hidden border border-border">
<IcomPanel onReportRST={(r) => { setRstSent(r); rstUserEditedRef.current = true; }} />
</div>
);
case 'netcontrol':
return (
<div className="h-full w-full min-h-0 flex flex-col rounded-lg overflow-hidden border border-border">
<NetControlPanel onLogged={refresh} countries={countries} bands={bands} modes={modes} />
</div>
);
case 'recent':
return (
<div className="h-full w-full min-h-0 flex flex-col bg-card border border-border rounded-lg overflow-hidden">
@@ -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'}
/>
)}
+186 -41
View File
@@ -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 <div className="flex items-center gap-2">{body}</div>;
}
// HdrMeter — a compact live meter for the model header band (S when receiving,
// Po/SWR when transmitting). Clickable variant sends the S reading to RST tx.
function HdrMeter({ label, value, accent, scale, onClick, title }: {
label: string; value: number; accent: string; scale: string; onClick?: () => void; title?: string;
}) {
const v = Math.max(0, Math.min(100, value));
const body = (
<>
<span className="w-5 shrink-0 text-[10px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
<div className="w-24 sm:w-32 h-2 rounded-full bg-muted/70 overflow-hidden">
<div className="h-full rounded-full transition-[width] duration-150" style={{ width: `${v}%`, background: accent }} />
</div>
<span className="w-12 text-right text-[11px] font-mono font-bold tabular-nums" style={{ color: accent }}>{scale}</span>
</>
);
if (onClick) return <button type="button" onClick={onClick} title={title} className="flex items-center gap-1.5 rounded-md hover:bg-muted/60 px-1.5 py-0.5 -my-0.5">{body}</button>;
return <div className="flex items-center gap-1.5 px-1.5">{body}</div>;
}
// ShiftRow — a RIT / ΔTX offset control: on/off chip + a wheel-adjustable signed
// offset (±10 Hz per notch or per ± button) + a clear (0) button.
function ShiftRow({ label, on, hz, accent, onToggle, onDelta, onClear }: {
@@ -475,12 +499,17 @@ function ScopePanadapter() {
export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void } = {}) {
const { t } = useI18n();
const [st, setSt] = useState<IcomState>(ZERO);
const [cat, setCat] = useState<any>(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<IcomState>(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 (
<div className="h-full min-h-0 overflow-auto bg-background">
@@ -559,26 +594,97 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
{st.mode ? <span className="text-xs font-mono text-muted-foreground">{st.mode}</span> : null}
{st.split ? <span className="rounded-md bg-warning/20 px-1.5 py-0.5 text-[10px] font-bold uppercase tracking-wider text-warning">Split</span> : null}
</div>
{/* Live meter in the header band: S when receiving (click → RST tx), Po when transmitting. */}
<div className="hidden md:flex items-center">
{tx ? (
<HdrMeter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
) : (() => { const sp = sParts(st.s_meter); return (
<HdrMeter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
); })()}
<div className="flex items-center gap-1.5">
{/* 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. */}
<button type="button" onClick={() => IcomSetPower(true).catch(() => {})} title={t('icmp.powerOnHint')}
className="inline-flex items-center gap-1 rounded-md border border-success/60 bg-success/10 px-2 py-1 text-xs font-bold text-success hover:bg-success/20">
<Power className="size-3.5" /> ON
</button>
<button type="button" onClick={() => { if (window.confirm(t('icmp.powerOffConfirm'))) IcomSetPower(false).catch(() => {}); }} title={t('icmp.powerOffHint')}
className="inline-flex items-center gap-1 rounded-md border border-destructive/60 bg-destructive/10 px-2 py-1 text-xs font-bold text-destructive hover:bg-destructive/20">
<Power className="size-3.5" /> OFF
</button>
<button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
</button>
</div>
<button type="button" onClick={refresh} disabled={busy}
className="inline-flex items-center gap-1.5 rounded-md border border-border bg-card px-2 py-1 text-xs hover:bg-muted disabled:opacity-40">
<RefreshCw className={cn('size-3.5', busy && 'animate-spin')} /> {t('icmp.refresh')}
</button>
</div>
{/* VFO readout — the RS-BA1-style twin display: MAIN (active) + SUB, the big
tabular frequency, mode badge, band, and the RIT/ΔTX offset. */}
<div className="rounded-xl border border-border bg-muted/25 shadow-inner overflow-hidden">
<div className="grid grid-cols-2 divide-x divide-border/60">
{/* MAIN VFO */}
<div className="px-4 py-3">
<div className="flex items-center justify-between mb-1.5">
<span className={cn('text-[10px] font-bold uppercase tracking-widest', tx ? 'text-destructive' : 'text-success')}>{tx ? 'Main · TX' : 'Main'}</span>
{curMode ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-primary/15 text-primary">{curMode}</span> : null}
</div>
<div className="font-mono font-bold tabular-nums leading-none text-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(mainHz)}</div>
<div className="mt-1.5 flex items-center gap-2 text-[11px] font-mono text-muted-foreground">
<span>{cat?.band || (mainHz ? '' : '—')}</span>
{st.rit_on ? <span className="text-primary">RIT {st.rit_hz > 0 ? '+' : st.rit_hz < 0 ? '' : ''}{Math.abs(st.rit_hz)}</span> : null}
{st.xit_on ? <span className="text-warning">ΔTX</span> : null}
</div>
</div>
{/* SUB VFO (populated in split; dimmed otherwise) */}
<div className={cn('px-4 py-3', !split && 'opacity-40')}>
<div className="flex items-center justify-between mb-1.5">
<span className="text-[10px] font-bold uppercase tracking-widest text-muted-foreground">Sub</span>
{split ? <span className="rounded px-1.5 py-0.5 text-[10px] font-bold bg-warning/15 text-warning">SPLIT</span> : null}
</div>
<div className="font-mono font-bold tabular-nums leading-none text-muted-foreground" style={{ fontSize: 'clamp(1.5rem, 4.5vw, 2.25rem)' }}>{fmtVFO(subHz)}</div>
<div className="mt-1.5 text-[11px] font-mono text-muted-foreground">{split ? 'TX' : ''}</div>
</div>
</div>
{/* Mode selector row (RS-BA1's SSB/CW/RTTY/PSK/AM/FM). */}
<div className="grid grid-cols-6 border-t border-border/60 divide-x divide-border/60">
{MODES.map((m) => {
const on = modeMatches(m, curMode);
return (
<button key={m} type="button" onClick={() => setMode(m)}
className={cn('py-1.5 text-[11px] font-bold tracking-wide transition-colors',
on ? 'bg-primary text-primary-foreground' : 'bg-card/40 text-muted-foreground hover:bg-muted')}>
{m}
</button>
);
})}
</div>
</div>
{/* Live meters — always visible: S (RX, click → RST), Po in watts, SWR. */}
<div className="rounded-xl border border-border bg-card px-3 py-2.5 shadow-sm grid grid-cols-1 sm:grid-cols-3 gap-x-5 gap-y-2">
{(() => { const sp = sParts(st.s_meter); return (
<Meter label="S" value={st.s_meter} accent="#22c55e" scale={sp.label}
title={onReportRST ? t('rst.clickToFill') : undefined}
onClick={onReportRST ? () => onReportRST(sMeterRST(sp.s, sp.over, st.mode)) : undefined} />
); })()}
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter} W`} />
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
</div>
{/* Spectrum panadapter (full width). */}
<ScopePanadapter />
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
{/* Band buttons + antenna selection. */}
<Card icon={Antenna} title={t('icmp.bandsAntenna')} accent="#0891b2">
<div className="grid grid-cols-5 gap-1.5">
{BANDS.map((b) => (
<button key={b.l} type="button" onClick={() => SetCATFrequency(b.hz).catch(() => {})}
className="px-1 py-1.5 rounded-md text-[11px] font-bold border border-border bg-card text-foreground hover:bg-muted transition-colors">
{b.l}
</button>
))}
</div>
<Row label={t('icmp.antenna')}>
<Segmented value={String(st.antenna)} options={[{ v: '1', l: 'ANT1' }, { v: '2', l: 'ANT2' }]}
onChange={(v) => set({ antenna: parseInt(v) }, () => IcomSetAntenna(parseInt(v)))} />
</Row>
</Card>
{/* Clarifiers: RIT & ΔTX (XIT) — wheel or ± to shift, Ctrl+←/→ shifts RIT. */}
<Card icon={SlidersHorizontal} title={t('icmp.clarifiers')} accent="#8b5cf6">
<ShiftRow label="RIT" accent="#8b5cf6" on={st.rit_on} hz={st.rit_hz}
@@ -588,12 +694,6 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
onToggle={() => set({ xit_on: !st.xit_on }, () => IcomSetXITOn(!st.xit_on))}
onDelta={(d) => setRit(st.rit_hz + d)} onClear={() => setRit(0)} />
<p className="text-[11px] text-muted-foreground">{t('icmp.ritHint')}</p>
{tx && (
<div className="pt-1 border-t border-border/60 space-y-3">
<Meter label="Po" value={st.power_meter} accent="#ef4444" scale={`${st.power_meter}%`} />
<Meter label="SWR" value={st.swr_meter} accent="#f59e0b" scale={st.swr_meter > 0 ? `${(1 + st.swr_meter / 33.3).toFixed(1)}` : '1.0'} />
</div>
)}
</Card>
{/* Transmit controls. */}
@@ -619,6 +719,22 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
TUNE
</button>
</div>
{/* Speech processor / monitor / VOX. */}
<div className="pt-2 mt-1 border-t border-border/60 space-y-3">
<LevelRow label="COMP" on={st.comp} value={st.comp_level}
onToggle={() => set({ comp: !st.comp }, () => IcomSetComp(!st.comp))}
onLevel={(v) => set({ comp_level: v }, () => IcomSetCompLevel(v))} />
<LevelRow label="MON" on={st.monitor} value={st.mon_level}
onToggle={() => set({ monitor: !st.monitor }, () => IcomSetMonitor(!st.monitor))}
onLevel={(v) => set({ mon_level: v }, () => IcomSetMonLevel(v))} />
<LevelRow label="VOX" on={st.vox} value={st.vox_gain}
onToggle={() => set({ vox: !st.vox }, () => IcomSetVOX(!st.vox))}
onLevel={(v) => set({ vox_gain: v }, () => IcomSetVOXGain(v))} />
<Row label="Anti-VOX">
<Slider value={st.anti_vox} disabled={!st.vox} onChange={(v) => set({ anti_vox: v }, () => IcomSetAntiVOX(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.anti_vox}</span>
</Row>
</div>
</Card>
<Card icon={Radio} title={t('icmp.receive')} accent="#2563eb">
@@ -630,6 +746,10 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
<Slider value={st.rf_gain} onChange={(v) => set({ rf_gain: v }, () => IcomSetRFGain(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.rf_gain}</span>
</Row>
<Row label={t('icmp.squelch')}>
<Slider value={st.squelch} onChange={(v) => set({ squelch: v }, () => IcomSetSquelch(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.squelch}</span>
</Row>
<Row label="AGC">
<Segmented value={st.agc || ''} options={[{ v: 'FAST', l: 'FAST' }, { v: 'MID', l: 'MID' }, { v: 'SLOW', l: 'SLOW' }]}
onChange={(v) => set({ agc: v }, () => IcomSetAGC(v))} />
@@ -648,6 +768,31 @@ export function IcomPanel({ onReportRST }: { onReportRST?: (rst: string) => void
</Row>
</Card>
{/* Twin PBT + manual notch. Sliders are 0-100 with 50 = centre. */}
<Card icon={Filter} title={t('icmp.passband')} accent="#7c3aed">
<Row label="PBT-IN">
<Slider value={st.pbt_inner} accent="#7c3aed" onChange={(v) => set({ pbt_inner: v }, () => IcomSetPBTInner(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_inner - 50 > 0 ? '+' : ''}{st.pbt_inner - 50}</span>
</Row>
<Row label="PBT-OUT">
<Slider value={st.pbt_outer} accent="#7c3aed" onChange={(v) => set({ pbt_outer: v }, () => IcomSetPBTOuter(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.pbt_outer - 50 > 0 ? '+' : ''}{st.pbt_outer - 50}</span>
</Row>
<button type="button"
onClick={() => { set({ pbt_inner: 50 }, () => IcomSetPBTInner(50)); set({ pbt_outer: 50 }, () => IcomSetPBTOuter(50)); }}
className="w-full py-1 rounded-md text-[11px] font-bold border border-border bg-card text-muted-foreground hover:bg-muted">
{t('icmp.pbtCenter')}
</button>
<div className="pt-1 border-t border-border/60 space-y-3">
<div className="flex items-center gap-2">
<Chip label="MN" on={st.manual_notch} onClick={() => set({ manual_notch: !st.manual_notch }, () => IcomSetManualNotch(!st.manual_notch))} />
<Slider value={st.notch_pos} disabled={!st.manual_notch} accent="#7c3aed" onChange={(v) => set({ notch_pos: v }, () => IcomSetNotchPos(v))} />
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.notch_pos}</span>
</div>
<p className="text-[11px] text-muted-foreground">{t('icmp.manualNotch')}</p>
</div>
</Card>
<Card icon={AudioLines} title={t('icmp.noiseNotch')} accent="#16a34a">
<LevelRow label="NB" on={st.nb} value={st.nb_level}
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
+12 -9
View File
@@ -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<SectionId>((initialSection as SectionId) || 'station');
const [loading, setLoading] = useState(true);
@@ -3931,7 +3934,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<TelemetryToggle />
<LiveStatusToggle />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} />
<MainViewPanes onChanged={onMainPaneChanged} flexAvailable={flexAvailable} icomAvailable={icomAvailable} />
<div className="border-t border-border/60 pt-4 space-y-2">
<h4 className="text-sm font-semibold text-foreground">Password encryption</h4>
+2 -2
View File
@@ -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',
+28
View File
@@ -350,16 +350,30 @@ export function IcomSetAGC(arg1:string):Promise<void>;
export function IcomSetANF(arg1:boolean):Promise<void>;
export function IcomSetAntenna(arg1:number):Promise<void>;
export function IcomSetAntiVOX(arg1:number):Promise<void>;
export function IcomSetAtt(arg1:number):Promise<void>;
export function IcomSetBreakIn(arg1:number):Promise<void>;
export function IcomSetComp(arg1:boolean):Promise<void>;
export function IcomSetCompLevel(arg1:number):Promise<void>;
export function IcomSetFilter(arg1:number):Promise<void>;
export function IcomSetKeySpeed(arg1:number):Promise<void>;
export function IcomSetManualNotch(arg1:boolean):Promise<void>;
export function IcomSetMicGain(arg1:number):Promise<void>;
export function IcomSetMonLevel(arg1:number):Promise<void>;
export function IcomSetMonitor(arg1:boolean):Promise<void>;
export function IcomSetNB(arg1:boolean):Promise<void>;
export function IcomSetNBLevel(arg1:number):Promise<void>;
@@ -368,8 +382,16 @@ export function IcomSetNR(arg1:boolean):Promise<void>;
export function IcomSetNRLevel(arg1:number):Promise<void>;
export function IcomSetNotchPos(arg1:number):Promise<void>;
export function IcomSetPBTInner(arg1:number):Promise<void>;
export function IcomSetPBTOuter(arg1:number):Promise<void>;
export function IcomSetPTT(arg1:boolean):Promise<void>;
export function IcomSetPower(arg1:boolean):Promise<void>;
export function IcomSetPreamp(arg1:number):Promise<void>;
export function IcomSetRFGain(arg1:number):Promise<void>;
@@ -386,6 +408,12 @@ export function IcomSetScopeMode(arg1:boolean):Promise<void>;
export function IcomSetSplit(arg1:boolean):Promise<void>;
export function IcomSetSquelch(arg1:number):Promise<void>;
export function IcomSetVOX(arg1:boolean):Promise<void>;
export function IcomSetVOXGain(arg1:number):Promise<void>;
export function IcomSetXITOn(arg1:boolean):Promise<void>;
export function IcomStopCW():Promise<void>;
+56
View File
@@ -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);
}
+26
View File
@@ -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 {
+46
View File
@@ -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
+14
View File
@@ -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).
+313 -105
View File
@@ -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 <rig> 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 <rig> 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)
+358 -6
View File
@@ -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)