diff --git a/app.go b/app.go index 7fc392b..b7567a5 100644 --- a/app.go +++ b/app.go @@ -40,6 +40,7 @@ import ( "hamlog/internal/qso" "hamlog/internal/rotator/pst" "hamlog/internal/settings" + "hamlog/internal/antgenius" "hamlog/internal/ultrabeam" "hamlog/internal/winkeyer" @@ -83,6 +84,9 @@ const ( keyCATPollMs = "cat.poll_ms" keyCATDelayMs = "cat.delay_ms" // pause between commands keyCATDigitalDefault = "cat.digital_default" // mode to use when CAT reports DATA + keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) + keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) + keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // global (not per-profile) like CAT/rotator. Device fields store the @@ -137,6 +141,11 @@ const ( keyUltrabeamFollow = "ultrabeam.follow" // "1" → re-tune to the rig frequency keyUltrabeamStep = "ultrabeam.step_khz" // re-tune hysteresis: 25 | 50 | 100 kHz + // Antenna Genius (4O3A) antenna switch — Hardware → Antenna Genius. TCP + // port is fixed at 9007, so only the IP is configurable. + keyAntGeniusEnabled = "antgenius.enabled" + keyAntGeniusHost = "antgenius.host" + // WinKeyer CW keyer (serial) — Hardware → CW Keyer. keyWKEnabled = "winkeyer.enabled" keyWKPort = "winkeyer.port" @@ -241,11 +250,14 @@ type QSLDefaults struct { // individual key/value pairs to keep the settings table flat. type CATSettings struct { Enabled bool `json:"enabled"` - Backend string `json:"backend"` // "omnirig" | "flex" + Backend string `json:"backend"` // "omnirig" | "flex" | "icom" OmniRigNum int `json:"omnirig_rig"` // 1 or 2 (OmniRig "Rig1"/"Rig2" slot) FlexHost string `json:"flex_host"` // FlexRadio IP (native backend) FlexPort int `json:"flex_port"` // FlexRadio TCP port (default 4992) FlexSpots bool `json:"flex_spots"` // push cluster spots to the panadapter + IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) + IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) + IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) PollMs int `json:"poll_ms"` // poll interval in ms (default 250) DelayMs int `json:"delay_ms"` // pause between commands (default 0) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) @@ -377,6 +389,7 @@ type App struct { clublog *clublog.Manager ultrabeam *ultrabeam.Client // Ultrabeam antenna (TCP); nil when disabled ubFollowStop chan struct{} // stops the "follow frequency" loop; nil when off + antgenius *antgenius.Client // Antenna Genius (4O3A) switch (TCP); nil when disabled audioMgr *audio.Manager qsoRec *audio.Recorder // continuous QSO recorder (rolling pre-roll) cwMu sync.Mutex // guards the CW decoder lifecycle @@ -809,6 +822,8 @@ func (a *App) startup(ctx context.Context) { // Ultrabeam antenna: connect in the background if enabled. a.startUltrabeam() + // Antenna Genius switch: connect in the background if enabled. + a.startAntGenius() // Autostart: launch the active profile's configured external programs that // aren't already running (WSJT-X, JTAlert, rotator control, …). Background @@ -3786,7 +3801,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } @@ -3797,6 +3812,9 @@ func (a *App) GetCATSettings() (CATSettings, error) { FlexHost: m[keyCATFlexHost], FlexPort: 4992, FlexSpots: m[keyCATFlexSpots] == "1", + IcomPort: m[keyCATIcomPort], + IcomBaud: 115200, + IcomAddr: 0x98, // IC-7610 default PollMs: 250, DelayMs: 0, DigitalDefault: m[keyCATDigitalDefault], @@ -3804,6 +3822,12 @@ func (a *App) GetCATSettings() (CATSettings, error) { if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { out.FlexPort = n } + if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { + out.IcomBaud = n + } + if n, _ := strconv.Atoi(m[keyCATIcomAddr]); n > 0 && n <= 0xFF { + out.IcomAddr = n + } if out.Backend == "" { out.Backend = "omnirig" } @@ -3836,6 +3860,12 @@ func (a *App) SaveCATSettings(s CATSettings) error { if s.FlexPort <= 0 || s.FlexPort > 65535 { s.FlexPort = 4992 } + if s.IcomBaud <= 0 { + s.IcomBaud = 115200 + } + if s.IcomAddr <= 0 || s.IcomAddr > 0xFF { + s.IcomAddr = 0x98 + } if s.PollMs < 50 || s.PollMs > 2000 { s.PollMs = 250 } @@ -3860,6 +3890,9 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATFlexHost: strings.TrimSpace(s.FlexHost), keyCATFlexPort: strconv.Itoa(s.FlexPort), keyCATFlexSpots: flexSpots, + keyCATIcomPort: strings.TrimSpace(s.IcomPort), + keyCATIcomBaud: strconv.Itoa(s.IcomBaud), + keyCATIcomAddr: strconv.Itoa(s.IcomAddr), keyCATPollMs: strconv.Itoa(s.PollMs), keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), @@ -6769,6 +6802,100 @@ func (a *App) GetFlexState() cat.FlexTXState { return st } +// ── Icom CI-V control panel (receive DSP) ────────────────────────────────── + +func (a *App) GetIcomState() cat.IcomTXState { + if a.cat == nil { + return cat.IcomTXState{} + } + st, _ := a.cat.IcomState() + return st +} + +func (a *App) IcomRefresh() error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.RefreshIcom() }) +} + +func (a *App) IcomSetAFGain(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAFGain(p) }) +} + +func (a *App) IcomSetRFGain(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetRFGain(p) }) +} + +func (a *App) IcomSetNB(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNB(on) }) +} + +func (a *App) IcomSetNBLevel(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNBLevel(p) }) +} + +func (a *App) IcomSetNR(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNR(on) }) +} + +func (a *App) IcomSetNRLevel(p int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetNRLevel(p) }) +} + +func (a *App) IcomSetANF(on bool) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetANF(on) }) +} + +func (a *App) IcomSetAGC(mode string) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAGC(mode) }) +} + +func (a *App) IcomSetPreamp(n int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetPreamp(n) }) +} + +func (a *App) IcomSetAtt(db int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetAtt(db) }) +} + +func (a *App) IcomSetFilter(n int) error { + if a.cat == nil { + return fmt.Errorf("cat not initialized") + } + return a.cat.IcomDo(func(ic cat.IcomController) error { return ic.SetIcomFilter(n) }) +} + func (a *App) FlexSetPower(p int) error { if a.cat == nil { return fmt.Errorf("cat not initialized") @@ -7053,6 +7180,10 @@ func (a *App) reloadCAT() { } } a.cat.Start(fb) + case "icom": + // Native Icom CI-V over the radio's USB serial port (local control). + // Same civ protocol a future network backend will reuse for remote. + a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault)) default: // Unknown backend → stop and emit a dummy state so the UI shows it. a.cat.Stop() @@ -7712,6 +7843,86 @@ func (a *App) TestUltrabeam(s UltrabeamSettings) error { return fmt.Errorf("no response from %s:%d", s.Host, s.Port) } +// ── Antenna Genius (4O3A) antenna switch (TCP, port fixed 9007) ───────────── + +// AntGeniusSettings is the JSON shape for the Hardware → Antenna Genius panel. +// The TCP port is fixed at 9007 on the device, so only the IP is configurable. +type AntGeniusSettings struct { + Enabled bool `json:"enabled"` + Host string `json:"host"` +} + +// GetAntGeniusSettings returns the persisted Antenna Genius config. +func (a *App) GetAntGeniusSettings() (AntGeniusSettings, error) { + out := AntGeniusSettings{} + if a.settings == nil { + return out, fmt.Errorf("db not initialized") + } + m, err := a.settings.GetMany(a.ctx, keyAntGeniusEnabled, keyAntGeniusHost) + if err != nil { + return out, err + } + out.Enabled = m[keyAntGeniusEnabled] == "1" + out.Host = m[keyAntGeniusHost] + return out, nil +} + +// SaveAntGeniusSettings persists the config and (re)starts or stops the client. +func (a *App) SaveAntGeniusSettings(s AntGeniusSettings) error { + if a.settings == nil { + return fmt.Errorf("db not initialized") + } + for k, v := range map[string]string{ + keyAntGeniusEnabled: boolStr(s.Enabled), + keyAntGeniusHost: strings.TrimSpace(s.Host), + } { + if err := a.settings.Set(a.ctx, k, v); err != nil { + return err + } + } + a.startAntGenius() + return nil +} + +// startAntGenius stops any existing client and starts a fresh one if enabled. +func (a *App) startAntGenius() { + if a.antgenius != nil { + a.antgenius.Stop() + a.antgenius = nil + } + s, err := a.GetAntGeniusSettings() + if err != nil || !s.Enabled || strings.TrimSpace(s.Host) == "" { + return + } + a.antgenius = antgenius.New(s.Host, 9007) + _ = a.antgenius.Start() +} + +// GetAntGeniusStatus returns the switch's current state for the UI poll +// (connection, active antenna per port, and the configured antenna list). +func (a *App) GetAntGeniusStatus() antgenius.Status { + if a.antgenius == nil { + return antgenius.Status{} + } + return a.antgenius.GetStatus() +} + +// AntGeniusActivate selects an antenna on a port (1 = A, 2 = B). +func (a *App) AntGeniusActivate(port, antenna int) error { + if a.antgenius == nil { + return fmt.Errorf("Antenna Genius not connected — enable it in Settings → Antenna Genius") + } + return a.antgenius.Activate(port, antenna) +} + +// AntGeniusDeselect clears the active antenna on a port (sets it to "None"). +func (a *App) AntGeniusDeselect(port int) error { + if a.antgenius == nil { + return fmt.Errorf("Antenna Genius not connected") + } + return a.antgenius.Activate(port, 0) +} + // --- WinKeyer (CW keyer) bindings --- // WKMacro is one CW message slot (F1…): a short label + the macro text, which diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a06b46..66308b8 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -19,6 +19,7 @@ import { RotatorGoTo, RotatorStop, GetRotatorHeading, GetDBConnectionInfo, GetLogbookRevision, GetUltrabeamStatus, SetUltrabeamDirection, + GetAntGeniusStatus, GetAntGeniusSettings, AntGeniusActivate, OpenExternalURL, ConnectAllClusters, DisconnectAllClusters, GetClusterStatus, SendClusterCommand, ListClusterServers, ClusterSpotStatuses, SendClusterSpot, @@ -56,6 +57,8 @@ import { QSOEditModal } from '@/components/QSOEditModal'; import { BandMap } from '@/components/BandMap'; import { WorldMap, LocatorMap } from '@/components/MainMap'; import { FlexPanel } from '@/components/FlexPanel'; +import { IcomPanel } from '@/components/IcomPanel'; +import { AntGeniusPanel, type AGStatus } from '@/components/AntGeniusPanel'; import { FilterBuilder, type QueryFilter } from '@/components/FilterBuilder'; import { AwardsPanel } from '@/components/AwardsPanel'; import { RecentQSOsGrid } from '@/components/RecentQSOsGrid'; @@ -332,6 +335,12 @@ export default function App() { const [catState, setCatState] = useState({ enabled: false, connected: false } as any); const [rotatorHeading, setRotatorHeading] = useState<{ enabled: boolean; ok: boolean; azimuth: number }>({ enabled: false, ok: false, azimuth: 0 }); const [ubStatus, setUbStatus] = useState<{ enabled: boolean; connected: boolean; direction: number; moving: boolean }>({ enabled: false, connected: false, direction: 0, moving: false }); + const [agStatus, setAgStatus] = useState({ connected: false, port_a: 0, port_b: 0, antennas: [] }); + const [agEnabled, setAgEnabled] = useState(false); + // Per-port optimistic selection that the status poll must not revert until the + // device confirms it (or it expires) — otherwise a stale poll right after a + // click reverts the UI and the click looks like it did nothing. + const agPending = useRef<{ a?: { v: number; t: number }; b?: { v: number; t: number } }>({}); const [dbConn, setDbConn] = useState<{ backend: string; label: string } | null>(null); // Mode OpsLog shows when the rig reports generic DIG_U/DIG_L. OmniRig // can't tell us if it's FT8 vs FT4 vs RTTY, so the user picks the default @@ -966,6 +975,7 @@ export default function App() { // Portable UI toggles (mirrored to the DB via writeUiPref / syncPortablePrefs). const [showRotor, setShowRotor] = useState(() => localStorage.getItem('opslog.showRotor') !== '0'); + const [showAntGenius, setShowAntGenius] = useState(() => localStorage.getItem('opslog.showAntGenius') !== '0'); const [showBeamOnMap, setShowBeamOnMap] = useState(() => localStorage.getItem('opslog.showBeamOnMap') !== '0'); // Award code → scanned field (e.g. POTA→pota_ref, WWFF→wwff). Used to route @@ -1061,6 +1071,38 @@ export default function App() { return () => { alive = false; window.clearInterval(id); }; }, []); + // Poll the Antenna Genius switch for active antenna per port + the list. + // Re-read the enabled flag each tick so toggling it in Settings makes the + // top-bar icon appear/disappear without an app restart. + useEffect(() => { + let alive = true; + const tick = async () => { + try { const en: any = await GetAntGeniusSettings(); if (alive) setAgEnabled(!!en?.enabled); } catch {} + try { + const s = (await GetAntGeniusStatus()) as AGStatus; + if (!alive || !s) return; + const now = Date.now(); + const pend = agPending.current; + // Keep an optimistic selection until the device confirms it or it ages out. + if (pend.a) { if (now > pend.a.t || s.port_a === pend.a.v) delete pend.a; else s.port_a = pend.a.v; } + if (pend.b) { if (now > pend.b.t || s.port_b === pend.b.v) delete pend.b; else s.port_b = pend.b.v; } + // Only update when something actually changed — avoids re-rendering the + // widget every 1.5s (which made buttons flicker on hover). + setAgStatus((prev) => (JSON.stringify(prev) === JSON.stringify(s) ? prev : s)); + } catch {} + }; + tick(); + const id = window.setInterval(tick, 1500); + return () => { alive = false; window.clearInterval(id); }; + }, []); + const agActivate = (port: number, antenna: number) => { + // Optimistic: reflect the change immediately and pin it for ~3s so the next + // poll (which may still carry the old cached value) can't revert it. + agPending.current[port === 1 ? 'a' : 'b'] = { v: antenna, t: Date.now() + 3000 }; + setAgStatus((s) => ({ ...s, ...(port === 1 ? { port_a: antenna } : { port_b: antenna }) })); + AntGeniusActivate(port, antenna).catch((e) => setError(String(e?.message ?? e))); + }; + // RX band auto-follows the TX band (only differs for cross-band work). useEffect(() => { setBandRx(band); }, [band]); @@ -2921,6 +2963,21 @@ export default function App() { > + {agEnabled && ( + + )} {chatAvailable && ( + ); + }; + + return ( +
+
+ + Antenna Genius + + + + {status.connected ? 'online' : 'offline'} + + +
+ +
+ {!status.connected ? ( +
+
Connecting…
+ {status.last_error &&
{status.last_error}
} +
+ ) : list.length === 0 ? ( +
No antennas configured.
+ ) : list.map((a) => { + const aActive = status.port_a === a.index; + const bActive = status.port_b === a.index; + const aTx = aActive && !!status.tx_a; + const bTx = bActive && !!status.tx_b; + const nameCls = (aTx || bTx) + ? 'bg-gradient-to-r from-red-500 to-rose-600 text-white border-red-400/40 shadow-[0_0_11px_rgba(244,63,94,0.35)]' + : aActive + ? 'bg-gradient-to-r from-emerald-500 to-emerald-600 text-white border-emerald-400/40 shadow-[0_0_11px_rgba(16,185,129,0.3)]' + : bActive + ? 'bg-gradient-to-r from-sky-500 to-sky-600 text-white border-sky-400/40 shadow-[0_0_11px_rgba(14,165,233,0.3)]' + : 'bg-card/70 text-foreground/80 border-border hover:bg-muted/60'; + return ( +
+ +
+ {pretty(a.name)} +
+ +
+ ); + })} +
+
+ ); +} diff --git a/frontend/src/components/IcomPanel.tsx b/frontend/src/components/IcomPanel.tsx new file mode 100644 index 0000000..7fdf65c --- /dev/null +++ b/frontend/src/components/IcomPanel.tsx @@ -0,0 +1,186 @@ +import { useEffect, useRef, useState } from 'react'; +import { Radio, AudioLines, RefreshCw } from 'lucide-react'; +import { + GetIcomState, IcomRefresh, + IcomSetAFGain, IcomSetRFGain, IcomSetNB, IcomSetNBLevel, IcomSetNR, IcomSetNRLevel, + IcomSetANF, IcomSetAGC, IcomSetPreamp, IcomSetAtt, IcomSetFilter, +} from '../../wailsjs/go/main/App'; +import { cn } from '@/lib/utils'; + +type IcomState = { + available: boolean; model?: string; mode?: string; + af_gain: number; rf_gain: number; + nb: boolean; nb_level: number; nr: boolean; nr_level: number; anf: boolean; + agc?: string; preamp: number; att: number; filter: number; +}; + +const ZERO: IcomState = { + available: false, af_gain: 0, rf_gain: 0, + nb: false, nb_level: 0, nr: false, nr_level: 0, anf: false, + preamp: 0, att: 0, filter: 1, +}; + +function Slider({ value, onChange, disabled, accent = '#2563eb' }: { + value: number; onChange: (v: number) => void; disabled?: boolean; accent?: string; +}) { + const v = Math.max(0, Math.min(100, value)); + return ( + onChange(parseInt(e.target.value, 10))} + className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default', + '[&::-webkit-slider-thumb]:appearance-none [&::-webkit-slider-thumb]:size-3.5 [&::-webkit-slider-thumb]:rounded-full', + '[&::-webkit-slider-thumb]:bg-white [&::-webkit-slider-thumb]:border-2 [&::-webkit-slider-thumb]:shadow-sm')} + style={{ background: `linear-gradient(to right, ${accent} ${v}%, #d8cfb8 ${v}%)`, borderColor: accent }} + /> + ); +} + +function Segmented({ value, options, onChange }: { + value: string; options: { v: string; l: string }[]; onChange: (v: string) => void; +}) { + return ( +
+ {options.map((o) => ( + + ))} +
+ ); +} + +function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) { + return ( + + ); +} + +function LevelRow({ label, on, onToggle, value, onLevel }: { + label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void; +}) { + return ( +
+ + + {value} +
+ ); +} + +function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) { + return ( +
+
+ + {title} +
+
{children}
+
+ ); +} + +function Row({ label, children }: { label: string; children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} + +// IcomPanel — receive-DSP control surface for an Icom on the CI-V backend. +// Unlike the Flex (which pushes state), the Icom is polled: the cache reflects +// the last refresh plus optimistic updates. Front-panel knob changes show after +// the next ↻ Refresh. +export function IcomPanel() { + const [st, setSt] = useState(ZERO); + const [busy, setBusy] = useState(false); + + const load = () => GetIcomState().then((s) => setSt((s ?? ZERO) as IcomState)).catch(() => {}); + const refresh = async () => { + setBusy(true); + try { await IcomRefresh(); } catch {} + await load(); + setBusy(false); + }; + + useEffect(() => { + refresh(); + const id = window.setInterval(load, 1500); // cheap cache poll (mode + optimistic state) + return () => window.clearInterval(id); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Optimistic local update + fire the command; the cache poll reconciles. + const set = (patch: Partial, fn: () => Promise) => { + setSt((s) => ({ ...s, ...patch })); + fn().catch(() => {}); + }; + + if (!st.available) { + return ( +
+ Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port. +
+ ); + } + + return ( +
+
+
{st.model || 'Icom'}{st.mode ? {st.mode} : null}
+ +
+ + + + set({ af_gain: v }, () => IcomSetAFGain(v))} /> + {st.af_gain} + + + set({ rf_gain: v }, () => IcomSetRFGain(v))} /> + {st.rf_gain} + + + set({ agc: v }, () => IcomSetAGC(v))} /> + + + set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} /> + + + set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} /> + + + set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} /> + + + + + set({ nb: !st.nb }, () => IcomSetNB(!st.nb))} + onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} /> + set({ nr: !st.nr }, () => IcomSetNR(!st.nr))} + onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} /> +
+ set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} /> + Auto notch filter +
+
+
+ ); +} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 1c5d8f8..5d1a011 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -12,6 +12,7 @@ import { ListProfiles, GetActiveProfile, SaveProfile, DeleteProfile, ActivateProfile, DuplicateProfile, GetRotatorSettings, SaveRotatorSettings, TestRotator, RotatorPark, RotatorStop, GetUltrabeamSettings, SaveUltrabeamSettings, TestUltrabeam, + GetAntGeniusSettings, SaveAntGeniusSettings, GetWinkeyerSettings, SaveWinkeyerSettings, ListSerialPorts, GetAudioSettings, SaveAudioSettings, ListAudioInputDevices, ListAudioOutputDevices, PickAudioFolder, TestPTT, GetClublogCtyInfo, SetClublogCtyEnabled, DownloadClublogCty, @@ -170,6 +171,7 @@ type SectionId = | 'rotator' | 'winkeyer' | 'antenna' + | 'antgenius' | 'audio'; type TreeNode = @@ -207,6 +209,7 @@ const TREE: TreeNode[] = [ { kind: 'item', label: 'Rotator', id: 'rotator' }, { kind: 'item', label: 'CW Keyer', id: 'winkeyer' }, { kind: 'item', label: 'Antenna', id: 'antenna' }, + { kind: 'item', label: 'Antenna Genius', id: 'antgenius' }, { kind: 'item', label: 'Audio devices', id: 'audio' }, ], }, @@ -232,6 +235,7 @@ const SECTION_LABELS: Partial> = { rotator: 'Rotator', winkeyer: 'CW Keyer', antenna: 'Antenna', + antgenius: 'Antenna Genius', audio: 'Audio devices', }; @@ -610,7 +614,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [bandDraft, setBandDraft] = useState(''); const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ - enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, poll_ms: 250, delay_ms: 0, + enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, + icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', }); const [rotator, setRotator] = useState({ @@ -626,6 +631,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [ubTesting, setUbTesting] = useState(false); const [ubTest, setUbTest] = useState<{ ok: boolean; msg: string } | null>(null); + // Antenna Genius (4O3A) switch settings — TCP port is fixed at 9007. + const [antgenius, setAntgenius] = useState<{ enabled: boolean; host: string }>({ enabled: false, host: '' }); + // WinKeyer CW keyer settings + macro editor. type WKMac = { label: string; text: string }; type WKSettings = { @@ -883,6 +891,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan setCatCfg(c); setRotator(r); try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} + try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} setBackupCfg(b as any); setQslDefaults(qd as any); setExtSvc(es as any); @@ -922,6 +931,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan try { setCatCfg(await GetCATSettings() as any); } catch {} try { setRotator(await GetRotatorSettings() as any); } catch {} try { setUltrabeam(await GetUltrabeamSettings() as any); } catch {} + try { setAntgenius(await GetAntGeniusSettings() as any); } catch {} try { setBackupCfg(await GetBackupSettings() as any); } catch {} try { setQslDefaults(await GetQSLDefaults() as any); } catch {} try { setExtSvc(await GetExternalServices() as any); } catch {} @@ -1089,6 +1099,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan await SaveCATSettings(catCfg as any); await SaveRotatorSettings(rotator as any); await SaveUltrabeamSettings(ultrabeam as any); + await SaveAntGeniusSettings(antgenius as any); await SaveWinkeyerSettings(wk as any); await SaveAudioSettings(audioCfg as any); await SaveEmailSettings(emailCfg as any); @@ -1774,6 +1785,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan OmniRig (any rig, Windows COM) FlexRadio / SmartSDR (native) + Icom CI-V (USB serial) @@ -1810,7 +1822,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} - {catCfg.backend === 'omnirig' && ( + {catCfg.backend === 'icom' && ( + <> +
+ +
+ + +
+
+
+ + +
+
+ + { const n = parseInt(e.target.value.replace(/[^0-9a-fA-F]/g, ''), 16); setCatCfg((s) => ({ ...s, icom_addr: (n >= 0 && n <= 0xFF) ? n : s.icom_addr })); }} /> +

IC-7610 = 98, IC-7300 = 94, IC-9700 = A2, IC-705 = A4. Set "CI-V USB Echo Back" OFF and CI-V baud to match on the rig.

+
+ + )} + {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && ( <>
@@ -1977,6 +2022,36 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan ); } + function AntGeniusPanelSettings() { + return ( + <> + +
+ +
+ + setAntgenius((s) => ({ ...s, host: e.target.value }))} + placeholder="192.168.1.60" + className="font-mono" + /> +

TCP port is fixed at 9007.

+
+

+ Once enabled, an Antenna Genius button appears in the top bar to show/hide the antenna-switch widget. In the widget, the A and B buttons select that antenna for the matching port; clicking an already-selected port deselects it (sets the port to None). +

+
+ + ); + } + function RotatorPanel() { return ( <> @@ -3732,6 +3807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan rotator: RotatorPanel, winkeyer: WinkeyerPanel, antenna: UltrabeamPanel, + antgenius: AntGeniusPanelSettings, audio: AudioPanel, }; diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 3aecb46..35618ce 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -5,6 +5,7 @@ import {qso} from '../models'; import {main} from '../models'; import {cat} from '../models'; import {profile} from '../models'; +import {antgenius} from '../models'; import {award} from '../models'; import {awardref} from '../models'; import {cluster} from '../models'; @@ -23,6 +24,10 @@ export function ActivateProfile(arg1:number):Promise; export function AddQSO(arg1:qso.QSO):Promise; +export function AntGeniusActivate(arg1:number,arg2:number):Promise; + +export function AntGeniusDeselect(arg1:number):Promise; + export function ApplyAwardPreset(arg1:string,arg2:string):Promise; export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array):Promise; @@ -195,6 +200,10 @@ export function FlexTune(arg1:boolean):Promise; export function GetActiveProfile():Promise; +export function GetAntGeniusSettings():Promise; + +export function GetAntGeniusStatus():Promise; + export function GetAudioSettings():Promise; export function GetAutostartPrograms():Promise>; @@ -247,6 +256,8 @@ export function GetExternalServices():Promise; export function GetFlexState():Promise; +export function GetIcomState():Promise; + export function GetListsSettings():Promise; export function GetLiveStatusEnabled():Promise; @@ -291,6 +302,30 @@ export function GetWinkeyerStatus():Promise; export function HasBuiltinReferences(arg1:string):Promise; +export function IcomRefresh():Promise; + +export function IcomSetAFGain(arg1:number):Promise; + +export function IcomSetAGC(arg1:string):Promise; + +export function IcomSetANF(arg1:boolean):Promise; + +export function IcomSetAtt(arg1:number):Promise; + +export function IcomSetFilter(arg1:number):Promise; + +export function IcomSetNB(arg1:boolean):Promise; + +export function IcomSetNBLevel(arg1:number):Promise; + +export function IcomSetNR(arg1:boolean):Promise; + +export function IcomSetNRLevel(arg1:number):Promise; + +export function IcomSetPreamp(arg1:number):Promise; + +export function IcomSetRFGain(arg1:number):Promise; + export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise; export function ImportAwardReferencesText(arg1:string,arg2:string):Promise; @@ -421,6 +456,8 @@ export function RunBackupNow():Promise; export function SaveADIFFile():Promise; +export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise; + export function SaveAudioSettings(arg1:main.AudioSettings):Promise; export function SaveAutostartPrograms(arg1:Array):Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index 23ba2a8..04f59d0 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -18,6 +18,14 @@ export function AddQSO(arg1) { return window['go']['main']['App']['AddQSO'](arg1); } +export function AntGeniusActivate(arg1, arg2) { + return window['go']['main']['App']['AntGeniusActivate'](arg1, arg2); +} + +export function AntGeniusDeselect(arg1) { + return window['go']['main']['App']['AntGeniusDeselect'](arg1); +} + export function ApplyAwardPreset(arg1, arg2) { return window['go']['main']['App']['ApplyAwardPreset'](arg1, arg2); } @@ -362,6 +370,14 @@ export function GetActiveProfile() { return window['go']['main']['App']['GetActiveProfile'](); } +export function GetAntGeniusSettings() { + return window['go']['main']['App']['GetAntGeniusSettings'](); +} + +export function GetAntGeniusStatus() { + return window['go']['main']['App']['GetAntGeniusStatus'](); +} + export function GetAudioSettings() { return window['go']['main']['App']['GetAudioSettings'](); } @@ -466,6 +482,10 @@ export function GetFlexState() { return window['go']['main']['App']['GetFlexState'](); } +export function GetIcomState() { + return window['go']['main']['App']['GetIcomState'](); +} + export function GetListsSettings() { return window['go']['main']['App']['GetListsSettings'](); } @@ -554,6 +574,54 @@ export function HasBuiltinReferences(arg1) { return window['go']['main']['App']['HasBuiltinReferences'](arg1); } +export function IcomRefresh() { + return window['go']['main']['App']['IcomRefresh'](); +} + +export function IcomSetAFGain(arg1) { + return window['go']['main']['App']['IcomSetAFGain'](arg1); +} + +export function IcomSetAGC(arg1) { + return window['go']['main']['App']['IcomSetAGC'](arg1); +} + +export function IcomSetANF(arg1) { + return window['go']['main']['App']['IcomSetANF'](arg1); +} + +export function IcomSetAtt(arg1) { + return window['go']['main']['App']['IcomSetAtt'](arg1); +} + +export function IcomSetFilter(arg1) { + return window['go']['main']['App']['IcomSetFilter'](arg1); +} + +export function IcomSetNB(arg1) { + return window['go']['main']['App']['IcomSetNB'](arg1); +} + +export function IcomSetNBLevel(arg1) { + return window['go']['main']['App']['IcomSetNBLevel'](arg1); +} + +export function IcomSetNR(arg1) { + return window['go']['main']['App']['IcomSetNR'](arg1); +} + +export function IcomSetNRLevel(arg1) { + return window['go']['main']['App']['IcomSetNRLevel'](arg1); +} + +export function IcomSetPreamp(arg1) { + return window['go']['main']['App']['IcomSetPreamp'](arg1); +} + +export function IcomSetRFGain(arg1) { + return window['go']['main']['App']['IcomSetRFGain'](arg1); +} + export function ImportADIF(arg1, arg2, arg3, arg4) { return window['go']['main']['App']['ImportADIF'](arg1, arg2, arg3, arg4); } @@ -814,6 +882,10 @@ export function SaveADIFFile() { return window['go']['main']['App']['SaveADIFFile'](); } +export function SaveAntGeniusSettings(arg1) { + return window['go']['main']['App']['SaveAntGeniusSettings'](arg1); +} + export function SaveAudioSettings(arg1) { return window['go']['main']['App']['SaveAudioSettings'](arg1); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 000af35..d13d123 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -65,6 +65,69 @@ export namespace adif { } +export namespace antgenius { + + export class Antenna { + index: number; + name: string; + + static createFrom(source: any = {}) { + return new Antenna(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.index = source["index"]; + this.name = source["name"]; + } + } + export class Status { + connected: boolean; + host?: string; + last_error?: string; + port_a: number; + port_b: number; + tx_a: boolean; + tx_b: boolean; + antennas: Antenna[]; + + static createFrom(source: any = {}) { + return new Status(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.connected = source["connected"]; + this.host = source["host"]; + this.last_error = source["last_error"]; + this.port_a = source["port_a"]; + this.port_b = source["port_b"]; + this.tx_a = source["tx_a"]; + this.tx_b = source["tx_b"]; + this.antennas = this.convertValues(source["antennas"], Antenna); + } + + convertValues(a: any, classs: any, asMap: boolean = false): any { + if (!a) { + return a; + } + if (a.slice && a.map) { + return (a as any[]).map(elem => this.convertValues(elem, classs)); + } else if ("object" === typeof a) { + if (asMap) { + for (const key of Object.keys(a)) { + a[key] = new classs(a[key]); + } + return a; + } + return new classs(a); + } + return a; + } + } + +} + export namespace audio { export class Device { @@ -541,6 +604,44 @@ export namespace cat { return a; } } + export class IcomTXState { + available: boolean; + model?: string; + mode?: string; + af_gain: number; + rf_gain: number; + nb: boolean; + nb_level: number; + nr: boolean; + nr_level: number; + anf: boolean; + agc?: string; + preamp: number; + att: number; + filter: number; + + static createFrom(source: any = {}) { + return new IcomTXState(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.available = source["available"]; + this.model = source["model"]; + this.mode = source["mode"]; + this.af_gain = source["af_gain"]; + this.rf_gain = source["rf_gain"]; + this.nb = source["nb"]; + this.nb_level = source["nb_level"]; + this.nr = source["nr"]; + this.nr_level = source["nr_level"]; + this.anf = source["anf"]; + this.agc = source["agc"]; + this.preamp = source["preamp"]; + this.att = source["att"]; + this.filter = source["filter"]; + } + } export class RigState { enabled: boolean; connected: boolean; @@ -857,6 +958,20 @@ export namespace lookup { export namespace main { + export class AntGeniusSettings { + enabled: boolean; + host: string; + + static createFrom(source: any = {}) { + return new AntGeniusSettings(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.enabled = source["enabled"]; + this.host = source["host"]; + } + } export class AudioSettings { from_radio: string; to_radio: string; @@ -1042,6 +1157,9 @@ export namespace main { flex_host: string; flex_port: number; flex_spots: boolean; + icom_port: string; + icom_baud: number; + icom_addr: number; poll_ms: number; delay_ms: number; digital_default: string; @@ -1058,6 +1176,9 @@ export namespace main { this.flex_host = source["flex_host"]; this.flex_port = source["flex_port"]; this.flex_spots = source["flex_spots"]; + this.icom_port = source["icom_port"]; + this.icom_baud = source["icom_baud"]; + this.icom_addr = source["icom_addr"]; this.poll_ms = source["poll_ms"]; this.delay_ms = source["delay_ms"]; this.digital_default = source["digital_default"]; diff --git a/internal/antgenius/antgenius.go b/internal/antgenius/antgenius.go new file mode 100644 index 0000000..43e80a0 --- /dev/null +++ b/internal/antgenius/antgenius.go @@ -0,0 +1,365 @@ +// Package antgenius drives a 4O3A Antenna Genius switch over its v4 TCP/IP +// text API (default port 9007). On connect the device sends a banner line +// (e.g. "V4.1.16 AG"); commands are "C|\r" and the device replies +// with "R||" (hex "0" = success) plus asynchronous +// "S<0>|" status pushes once you subscribe with "sub port/antenna". +// +// (The older "GSCP" binary-ish framing documented at gscp.arula.rs is only used +// by pre-v4 firmware and is NOT what v4 speaks.) +package antgenius + +import ( + "bufio" + "fmt" + "net" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + defaultPort = 9007 + dialTimeout = 5 * time.Second + writeTimeout = 3 * time.Second + readIdleTimeout = 12 * time.Second // no data for this long → assume the link is dead + keepaliveEvery = 3 * time.Second // periodic "port get" refreshes state + keeps the link alive + reconnectDelay = 2 * time.Second +) + +// Antenna is one configured antenna (index + name as stored on the device). +type Antenna struct { + Index int `json:"index"` + Name string `json:"name"` +} + +// Status is the snapshot the UI renders. +type Status struct { + Connected bool `json:"connected"` + Host string `json:"host,omitempty"` + LastError string `json:"last_error,omitempty"` + PortA int `json:"port_a"` // active antenna index on port A (0 = none) + PortB int `json:"port_b"` // active antenna index on port B + TxA bool `json:"tx_a"` // port A is transmitting + TxB bool `json:"tx_b"` // port B is transmitting + Antennas []Antenna `json:"antennas"` +} + +type Client struct { + host string + port int + + mu sync.Mutex // guards conn + writes + conn net.Conn + + statusMu sync.RWMutex + status Status + antennas map[int]string // index → name (rebuilt into status.Antennas) + + stop chan struct{} + running bool +} + +func New(host string, port int) *Client { + if port <= 0 || port > 65535 { + port = defaultPort + } + return &Client{ + host: host, + port: port, + stop: make(chan struct{}), + antennas: map[int]string{}, + status: Status{Host: host}, + } +} + +func (c *Client) Start() error { + c.running = true + go c.runLoop() + return nil +} + +func (c *Client) Stop() { + if !c.running { + return + } + c.running = false + close(c.stop) + c.mu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.mu.Unlock() +} + +func (c *Client) GetStatus() Status { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Client) setStatus(fn func(*Status)) { + c.statusMu.Lock() + fn(&c.status) + c.statusMu.Unlock() +} + +// Activate selects antenna on a port (1 = A, 2 = B). antenna 0 deselects (sets +// the port to "None"). We set both RX and TX antennas and force manual mode so +// the choice sticks regardless of the device's auto band-following. +func (c *Client) Activate(port, antenna int) error { + if port != 1 && port != 2 { + return fmt.Errorf("antgenius: invalid port %d (1=A, 2=B)", port) + } + if antenna < 0 { + return fmt.Errorf("antgenius: invalid antenna %d", antenna) + } + if err := c.send(fmt.Sprintf("port set %d rxant=%d txant=%d", port, antenna, antenna)); err != nil { + return err + } + // Ask for the new port state so the snapshot reflects it promptly (the + // subscription also pushes it, but this makes the change deterministic). + _ = c.send(fmt.Sprintf("port get %d", port)) + return nil +} + +func (c *Client) runLoop() { + for { + if !c.running { + return + } + conn, err := net.DialTimeout("tcp", net.JoinHostPort(c.host, strconv.Itoa(c.port)), dialTimeout) + if err != nil { + c.setStatus(func(s *Status) { s.Connected = false; s.LastError = "dial: " + err.Error() }) + if c.sleep(reconnectDelay) { + return + } + continue + } + c.mu.Lock() + c.conn = conn + c.mu.Unlock() + c.setStatus(func(s *Status) { s.Connected = true; s.LastError = ""; s.Host = c.host }) + + // Subscribe to live updates and pull the initial state. Command set and + // order mirror a known-working Node-RED v4 client (WA9WUD). + _ = c.send("antenna list") + _ = c.send("sub port all") + _ = c.send("port get 1") + _ = c.send("port get 2") + + done := make(chan struct{}) + go c.keepalive(conn, done) + err = c.readLoop(conn) // blocks until the link errors + close(done) + + c.mu.Lock() + if c.conn == conn { + c.conn = nil + } + c.mu.Unlock() + conn.Close() + c.setStatus(func(s *Status) { + s.Connected = false + if err != nil { + s.LastError = "read: " + err.Error() + } + }) + if c.sleep(reconnectDelay) { + return + } + } +} + +// keepalive periodically re-reads a port so an idle-but-dead link is detected +// (the read loop's idle timeout fires if these stop producing replies). +func (c *Client) keepalive(conn net.Conn, done chan struct{}) { + t := time.NewTicker(keepaliveEvery) + defer t.Stop() + for { + select { + case <-done: + return + case <-c.stop: + return + case <-t.C: + _ = c.send("port get 1") + _ = c.send("port get 2") + } + } +} + +func (c *Client) readLoop(conn net.Conn) error { + r := bufio.NewReader(conn) + var sb strings.Builder + for { + _ = conn.SetReadDeadline(time.Now().Add(readIdleTimeout)) + b, err := r.ReadByte() + if err != nil { + return err + } + if b == '\r' || b == '\n' { + if sb.Len() > 0 { + c.handleLine(sb.String()) + sb.Reset() + } + continue + } + sb.WriteByte(b) + } +} + +// send writes a "C|\r" line to the device. +func (c *Client) send(command string) error { + c.mu.Lock() + defer c.mu.Unlock() + if c.conn == nil { + return fmt.Errorf("antgenius: not connected") + } + _ = c.conn.SetWriteDeadline(time.Now().Add(writeTimeout)) + // The device only accepts the constant "C1|" sequence prefix for every + // command (using incrementing sequence numbers makes it drop the link); + // commands are LF-terminated. + _, err := fmt.Fprintf(c.conn, "C1|%s\n", command) + return err +} + +// handleLine parses one response/status/banner line and updates the snapshot. +func (c *Client) handleLine(line string) { + line = strings.TrimSpace(line) + if line == "" { + return + } + // Banner like "V4.1.16 AG" — just confirms the link is up. + if line[0] == 'V' && strings.Contains(line, "AG") { + c.setStatus(func(s *Status) { s.Connected = true; s.LastError = "" }) + return + } + // R|| or S| + var msg string + switch { + case strings.HasPrefix(line, "R"): + p := strings.SplitN(line, "|", 3) + if len(p) == 3 { + msg = p[2] + } + case strings.HasPrefix(line, "S"): + p := strings.SplitN(line, "|", 2) + if len(p) == 2 { + msg = p[1] + } + } + msg = strings.TrimSpace(msg) + switch { + case strings.HasPrefix(msg, "antenna "): + c.parseAntenna(msg) + case strings.HasPrefix(msg, "port "): + c.parsePort(msg) + } +} + +// parseAntenna handles "antenna name= tx=.. rx=.. inband=..". +// The name may contain spaces, so it's extracted up to the " tx=" field. +func (c *Client) parseAntenna(msg string) { + fields := strings.Fields(msg) + if len(fields) < 2 { + return + } + id, err := strconv.Atoi(fields[1]) + if err != nil { + return + } + name := "" + if i := strings.Index(msg, "name="); i >= 0 { + name = msg[i+len("name="):] + if j := strings.Index(name, " tx="); j >= 0 { + name = name[:j] + } + // The device stores spaces as underscores in names. + name = strings.TrimSpace(strings.ReplaceAll(name, "_", " ")) + } + c.statusMu.Lock() + if name != "" && !isPlaceholderName(name) { + c.antennas[id] = name + } else { + delete(c.antennas, id) // unconfigured slot ("Antenna 4", etc.) → not shown + } + c.status.Antennas = sortedAntennas(c.antennas) + c.status.Connected = true + c.statusMu.Unlock() +} + +// parsePort handles "port ... rxant= txant= ...". The active antenna +// shown is the TX antenna, falling back to the RX antenna when TX is none. +func (c *Client) parsePort(msg string) { + fields := strings.Fields(msg) + if len(fields) < 2 { + return + } + id, err := strconv.Atoi(fields[1]) + if err != nil || (id != 1 && id != 2) { + return + } + tx := kvInt(msg, "txant") + rx := kvInt(msg, "rxant") + active := tx + if active == 0 { + active = rx + } + txOn := kvInt(msg, "tx") != 0 // the standalone "tx=0|1" transmit flag + c.setStatus(func(s *Status) { + s.Connected = true + if id == 1 { + s.PortA, s.TxA = active, txOn + } else { + s.PortB, s.TxB = active, txOn + } + }) +} + +func (c *Client) sleep(d time.Duration) (stopped bool) { + select { + case <-c.stop: + return true + case <-time.After(d): + return false + } +} + +// kvInt extracts the integer value of a "key=" token from a space- +// separated string (returns 0 if absent). +func kvInt(s, key string) int { + i := strings.Index(s, key+"=") + if i < 0 { + return 0 + } + v := s[i+len(key)+1:] + if sp := strings.IndexByte(v, ' '); sp >= 0 { + v = v[:sp] + } + n, _ := strconv.Atoi(strings.TrimSpace(v)) + return n +} + +// isPlaceholderName reports whether name is an unconfigured-slot default like +// "Antenna 4" / "antenna_5" (after underscores become spaces): the word +// "antenna" followed by a number, which the UI shouldn't list. +func isPlaceholderName(name string) bool { + f := strings.Fields(strings.ToLower(name)) + if len(f) != 2 || f[0] != "antenna" { + return false + } + _, err := strconv.Atoi(f[1]) + return err == nil +} + +func sortedAntennas(m map[int]string) []Antenna { + out := make([]Antenna, 0, len(m)) + for idx, name := range m { + out = append(out, Antenna{Index: idx, Name: name}) + } + sort.Slice(out, func(i, j int) bool { return out[i].Index < out[j].Index }) + return out +} diff --git a/internal/antgenius/antgenius_test.go b/internal/antgenius/antgenius_test.go new file mode 100644 index 0000000..1c1a09f --- /dev/null +++ b/internal/antgenius/antgenius_test.go @@ -0,0 +1,71 @@ +package antgenius + +import "testing" + +func TestHandleAntennaList(t *testing.T) { + c := New("x", 9007) + // Names may contain spaces — must be captured up to " tx=". + c.handleLine("R3|0|antenna 1 name=UB VL2.3 tx=ffff rx=ffff inband=0000") + c.handleLine("R3|0|antenna 2 name=DX Commander tx=00ff rx=00ff inband=0000") + st := c.GetStatus() + if len(st.Antennas) != 2 { + t.Fatalf("got %d antennas, want 2: %+v", len(st.Antennas), st.Antennas) + } + if st.Antennas[0].Index != 1 || st.Antennas[0].Name != "UB VL2.3" { + t.Errorf("antenna 1 = %+v", st.Antennas[0]) + } + if st.Antennas[1].Name != "DX Commander" { + t.Errorf("antenna 2 name = %q", st.Antennas[1].Name) + } +} + +func TestAntennaUnderscoreAndPlaceholder(t *testing.T) { + c := New("x", 9007) + c.handleLine("R3|0|antenna 1 name=Hex_Beam tx=ffff rx=ffff inband=0000") // underscore → space + c.handleLine("R3|0|antenna 4 name=Antenna_4 tx=0000 rx=0000 inband=0000") // default → filtered + c.handleLine("R3|0|antenna 5 name=antenna 5 tx=0000 rx=0000 inband=0000") // default → filtered + st := c.GetStatus() + if len(st.Antennas) != 1 || st.Antennas[0].Name != "Hex Beam" { + t.Fatalf("want only [Hex Beam], got %+v", st.Antennas) + } +} + +func TestHandlePortStatus(t *testing.T) { + c := New("x", 9007) + // Async push after "sub port all": active antenna is txant (fallback rxant). + c.handleLine("S0|port 1 source=MANUAL band=6 rxant=2 txant=2 inband=0 inhibit=0") + c.handleLine("S0|port 2 source=AUTO band=0 rxant=0 txant=0 inband=0 inhibit=0") + st := c.GetStatus() + if st.PortA != 2 { + t.Errorf("PortA = %d, want 2", st.PortA) + } + if st.PortB != 0 { + t.Errorf("PortB = %d, want 0 (none)", st.PortB) + } + // A "port get" reply (R-line) must parse the same way. + c.handleLine("R15|0|port 2 source=MANUAL band=3 rxant=5 txant=5 inband=0 inhibit=0") + if st = c.GetStatus(); st.PortB != 5 { + t.Errorf("PortB after port get = %d, want 5", st.PortB) + } +} + +func TestPortTxFallbackToRx(t *testing.T) { + c := New("x", 9007) + c.handleLine("S0|port 1 source=MANUAL band=6 rxant=3 txant=0 inband=0 inhibit=0") + if st := c.GetStatus(); st.PortA != 3 { + t.Errorf("PortA = %d, want 3 (rx fallback when tx=0)", st.PortA) + } +} + +func TestKvInt(t *testing.T) { + s := "port 1 source=MANUAL band=6 rxant=2 txant=7 inhibit=0" + if v := kvInt(s, "txant"); v != 7 { + t.Errorf("txant = %d, want 7", v) + } + if v := kvInt(s, "rxant"); v != 2 { + t.Errorf("rxant = %d, want 2", v) + } + if v := kvInt(s, "missing"); v != 0 { + t.Errorf("missing = %d, want 0", v) + } +} diff --git a/internal/cat/cat.go b/internal/cat/cat.go index 02409a9..25a79bb 100644 --- a/internal/cat/cat.go +++ b/internal/cat/cat.go @@ -356,6 +356,70 @@ func (m *Manager) FlexDo(fn func(FlexController) error) error { }) } +// IcomTXState is the Icom receive-DSP state surfaced to the dedicated Icom +// control tab. Levels are 0-100 (scaled from the rig's 0-255). Unlike Flex, +// the Icom doesn't push changes, so these reflect the last RefreshIcom() read +// plus the optimistic updates each setter applies. +type IcomTXState struct { + Available bool `json:"available"` + Model string `json:"model,omitempty"` + Mode string `json:"mode,omitempty"` + AFGain int `json:"af_gain"` + RFGain int `json:"rf_gain"` + NB bool `json:"nb"` + NBLevel int `json:"nb_level"` + NR bool `json:"nr"` + NRLevel int `json:"nr_level"` + ANF bool `json:"anf"` + AGC string `json:"agc,omitempty"` // FAST | MID | SLOW + 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) +} + +// IcomController is an OPTIONAL backend capability (the Icom CI-V backend): the +// receive-DSP controls shown on the Icom tab. IcomState() is mutex-guarded in +// the backend so it's safe off the CAT goroutine; setters dispatch via IcomDo. +type IcomController interface { + IcomState() IcomTXState + RefreshIcom() error // re-read all DSP state from the rig + SetAFGain(int) error + SetRFGain(int) error + SetNB(bool) error + SetNBLevel(int) error + SetNR(bool) error + SetNRLevel(int) error + SetANF(bool) error + SetAGC(string) error + SetPreamp(int) error + SetAtt(int) error + SetIcomFilter(int) error +} + +// IcomState returns the current Icom DSP state, or (zero, false) when the active +// backend isn't an Icom. Safe to call from any goroutine. +func (m *Manager) IcomState() (IcomTXState, bool) { + m.mu.RLock() + b := m.backend + m.mu.RUnlock() + if ic, ok := b.(IcomController); ok { + return ic.IcomState(), true + } + return IcomTXState{}, false +} + +// IcomDo dispatches an Icom control onto the CAT goroutine. Errors if the +// active backend isn't an Icom. +func (m *Manager) IcomDo(fn func(IcomController) error) error { + return m.exec(func(b Backend) error { + ic, ok := b.(IcomController) + if !ok { + return fmt.Errorf("active CAT backend is not an Icom") + } + return fn(ic) + }) +} + // exec marshals a backend operation onto the CAT goroutine. Returns the // operation's error or a "busy"/"not running" error if dispatch failed. func (m *Manager) exec(fn func(Backend) error) error { diff --git a/internal/cat/civ/civ.go b/internal/cat/civ/civ.go new file mode 100644 index 0000000..38a6d2f --- /dev/null +++ b/internal/cat/civ/civ.go @@ -0,0 +1,242 @@ +// Package civ implements the Icom CI-V protocol independently of the transport +// carrying it. The exact same frames travel over a USB/serial port (local +// control) and, wrapped in Icom's UDP "serial" stream, over the network +// (remote control). Keeping the wire format in one place means the USB backend +// (icomserial) and a future network backend (icomnet) share all of it — only +// the transport differs. +// +// Frame layout: FE FE [sub] [data…] FD +package civ + +import ( + "bytes" + "fmt" +) + +// Protocol bytes. +const ( + Pre = 0xFE // preamble (sent twice at the start of every frame) + End = 0xFD // end-of-message + OK = 0xFB // rig acknowledged a set command + NG = 0xFA // rig rejected a set command + + // AddrController is the conventional address software uses for itself. + AddrController = 0xE0 +) + +// Commands (the few Phase-1 control needs; more get added with the panel). +const ( + CmdTransceiveFreq = 0x00 // unsolicited freq update (dial turned) + CmdTransceiveMode = 0x01 // unsolicited mode update + CmdReadFreq = 0x03 + CmdReadMode = 0x04 + CmdSetFreq = 0x05 + CmdSetMode = 0x06 + 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) + + CmdAtt = 0x11 // attenuator (1 BCD byte of dB; 0x00 = off) + CmdLevel = 0x14 // analogue levels (sub + 2 BCD bytes, 0000-0255) + CmdSwitch = 0x16 // on/off + multi-state DSP settings (sub + 1 byte) + + SubDataMode = 0x06 + SubPTT = 0x00 + + // CmdLevel sub-commands. + SubLevelAF = 0x01 // AF (volume) + SubLevelRF = 0x02 // RF gain + SubLevelNR = 0x06 // noise-reduction depth + SubLevelNB = 0x12 // noise-blanker depth + + // CmdSwitch sub-commands. + SubSwPreamp = 0x02 // 0=off, 1=P.AMP1, 2=P.AMP2 + SubSwAGC = 0x12 // 1=FAST, 2=MID, 3=SLOW + SubSwNB = 0x22 // noise blanker on/off + SubSwNR = 0x40 // noise reduction on/off + SubSwANF = 0x41 // auto-notch on/off +) + +// Icom mode codes (used by CmdReadMode / CmdSetMode). +const ( + ModeLSB = 0x00 + ModeUSB = 0x01 + ModeAM = 0x02 + ModeCW = 0x03 + ModeRTTY = 0x04 + ModeFM = 0x05 + ModeCWR = 0x07 + ModeRTTYR = 0x08 +) + +// Frame builds a complete CI-V frame (preamble … end) for payload, which is the +// command byte followed by any sub-command/data bytes. +func Frame(to, from byte, payload ...byte) []byte { + f := make([]byte, 0, len(payload)+5) + f = append(f, Pre, Pre, to, from) + f = append(f, payload...) + f = append(f, End) + return f +} + +// FreqToBCD encodes a frequency in Hz as the 5 little-endian BCD bytes Icom +// expects (10 digits, 2 per byte, least-significant byte first). +func FreqToBCD(hz int64) []byte { + if hz < 0 { + hz = 0 + } + b := make([]byte, 5) + for i := 0; i < 5; i++ { + lo := hz % 10 + hz /= 10 + hi := hz % 10 + hz /= 10 + b[i] = byte(lo) | byte(hi)<<4 + } + return b +} + +// BCDToFreq decodes Icom little-endian BCD frequency bytes back to Hz. +func BCDToFreq(b []byte) int64 { + var hz int64 + mult := int64(1) + for i := 0; i < len(b) && i < 5; i++ { + hz += int64(b[i]&0x0F) * mult + mult *= 10 + hz += int64(b[i]>>4) * mult + mult *= 10 + } + return hz +} + +// LevelToBCD encodes a 0-255 level as the 2 big-endian BCD bytes Icom's +// CmdLevel commands use (e.g. 128 → 0x01 0x28, 255 → 0x02 0x55). +func LevelToBCD(v int) []byte { + if v < 0 { + v = 0 + } + if v > 255 { + v = 255 + } + return []byte{byte(v / 100), byte(((v/10)%10)<<4 | v%10)} +} + +// BCDToLevel decodes the 2 BCD bytes of a CmdLevel response back to 0-255. +func BCDToLevel(b []byte) int { + if len(b) < 2 { + return 0 + } + return int(b[0])*100 + int(b[1]>>4)*10 + int(b[1]&0x0F) +} + +// ByteToBCD / BCDToByte handle a single packed-BCD byte (used by the +// attenuator, where the value is dB: 0x00, 0x06, 0x12, 0x18…). +func ByteToBCD(v int) byte { + if v < 0 { + v = 0 + } + if v > 99 { + v = 99 + } + return byte((v/10)<<4 | v%10) +} + +func BCDToByte(b byte) int { return int(b>>4)*10 + int(b&0x0F) } + +// ModeToADIF maps an Icom mode byte (plus the data-mode flag) to an ADIF mode +// string. Data mode on USB/LSB is surfaced as "DATA" so the app can substitute +// the user's preferred digital mode (FT8/RTTY/…), matching the OmniRig backend. +func ModeToADIF(m byte, data bool) string { + switch m { + case ModeCW, ModeCWR: + return "CW" + case ModeRTTY, ModeRTTYR: + return "RTTY" + case ModeAM: + return "AM" + case ModeFM: + return "FM" + case ModeLSB, ModeUSB: + if data { + return "DATA" + } + return "SSB" + } + return "" +} + +// ModelName maps a rig's default CI-V address (from CmdReadID) to a readable +// model. Unknown addresses fall back to a hex label. +func ModelName(addr byte) string { + switch addr { + case 0x94: + return "IC-7300" + case 0x98: + return "IC-7610" + case 0xA2: + return "IC-9700" + case 0xA4: + return "IC-705" + case 0x88: + return "IC-7700" + case 0x80: + return "IC-7800" + } + return fmt.Sprintf("Icom (0x%02X)", addr) +} + +// Decoded is one parsed CI-V frame. Data is everything after the command byte +// (so it still carries the sub-command for multi-byte commands like 1A 06). +type Decoded struct { + To byte + From byte + Cmd byte + Data []byte +} + +// Scan extracts every complete frame from buf and reports how many leading +// bytes the caller may now discard. A trailing partial frame (or a lone +// preamble byte) is left unconsumed so it can be completed by the next read. +func Scan(buf []byte) (frames []Decoded, consumed int) { + pos := 0 + for { + p := indexPreamble(buf, pos) + if p < 0 { + // No further preamble. Keep a trailing FE (possible start of the + // next preamble); otherwise everything seen is consumable. + if len(buf) > 0 && buf[len(buf)-1] == Pre { + return frames, len(buf) - 1 + } + return frames, len(buf) + } + start := p + 2 + for start < len(buf) && buf[start] == Pre { // tolerate padding FEs + start++ + } + end := bytes.IndexByte(buf[start:], End) + if end < 0 { + return frames, p // incomplete frame — keep from its preamble + } + end += start + if body := buf[start:end]; len(body) >= 3 { + frames = append(frames, Decoded{ + To: body[0], + From: body[1], + Cmd: body[2], + Data: append([]byte(nil), body[3:]...), + }) + } + pos = end + 1 + consumed = pos + } +} + +// indexPreamble returns the index of the next FE FE pair at or after from. +func indexPreamble(buf []byte, from int) int { + for i := from; i+1 < len(buf); i++ { + if buf[i] == Pre && buf[i+1] == Pre { + return i + } + } + return -1 +} diff --git a/internal/cat/civ/civ_test.go b/internal/cat/civ/civ_test.go new file mode 100644 index 0000000..c1eef12 --- /dev/null +++ b/internal/cat/civ/civ_test.go @@ -0,0 +1,132 @@ +package civ + +import ( + "bytes" + "testing" +) + +func TestFreqBCDRoundTrip(t *testing.T) { + cases := []int64{0, 1, 7074000, 14250000, 28074000, 50313000, 144174000, 1296000000} + for _, hz := range cases { + b := FreqToBCD(hz) + if len(b) != 5 { + t.Fatalf("FreqToBCD(%d) len=%d, want 5", hz, len(b)) + } + if got := BCDToFreq(b); got != hz { + t.Errorf("round trip %d → % X → %d", hz, b, got) + } + } +} + +func TestFreqBCDKnownEncoding(t *testing.T) { + // 14.250.000 Hz → little-endian BCD 00 00 25 14 00. + want := []byte{0x00, 0x00, 0x25, 0x14, 0x00} + if got := FreqToBCD(14250000); !bytes.Equal(got, want) { + t.Errorf("FreqToBCD(14250000) = % X, want % X", got, want) + } +} + +func TestFrame(t *testing.T) { + // Read-frequency request to a 7610 (0x98) from the controller (0xE0). + got := Frame(0x98, AddrController, CmdReadFreq) + want := []byte{0xFE, 0xFE, 0x98, 0xE0, 0x03, 0xFD} + if !bytes.Equal(got, want) { + t.Errorf("Frame = % X, want % X", got, want) + } +} + +func TestScanSingleFreqResponse(t *testing.T) { + // Rig (0x98) → controller (0xE0): freq read response for 14.250 MHz. + in := Frame(AddrController, 0x98, CmdReadFreq, 0x00, 0x00, 0x25, 0x14, 0x00) + frames, consumed := Scan(in) + if consumed != len(in) { + t.Fatalf("consumed=%d, want %d", consumed, len(in)) + } + if len(frames) != 1 { + t.Fatalf("got %d frames, want 1", len(frames)) + } + f := frames[0] + if f.From != 0x98 || f.To != AddrController || f.Cmd != CmdReadFreq { + t.Errorf("addrs/cmd wrong: %+v", f) + } + if hz := BCDToFreq(f.Data); hz != 14250000 { + t.Errorf("decoded freq %d, want 14250000", hz) + } +} + +func TestScanSkipsEchoAndKeepsPartial(t *testing.T) { + echo := Frame(0x98, AddrController, CmdReadFreq) // our outgoing (echoed back) + resp := Frame(AddrController, 0x98, CmdReadMode, ModeCW, 0x01) // a real response + buf := append(append([]byte{}, echo...), resp...) + buf = append(buf, 0xFE, 0xFE, 0x98) // a partial third frame (no FD yet) + + frames, consumed := Scan(buf) + if len(frames) != 2 { + t.Fatalf("got %d frames, want 2", len(frames)) + } + // The partial frame must be left unconsumed so the next read can finish it. + if consumed != len(echo)+len(resp) { + t.Errorf("consumed=%d, want %d (partial frame retained)", consumed, len(echo)+len(resp)) + } + if frames[1].Cmd != CmdReadMode || len(frames[1].Data) < 1 || frames[1].Data[0] != ModeCW { + t.Errorf("second frame wrong: %+v", frames[1]) + } +} + +func TestModeToADIF(t *testing.T) { + cases := []struct { + m byte + data bool + want string + }{ + {ModeUSB, false, "SSB"}, + {ModeLSB, false, "SSB"}, + {ModeUSB, true, "DATA"}, + {ModeCW, false, "CW"}, + {ModeCWR, false, "CW"}, + {ModeRTTY, false, "RTTY"}, + {ModeAM, false, "AM"}, + {ModeFM, false, "FM"}, + } + for _, c := range cases { + if got := ModeToADIF(c.m, c.data); got != c.want { + t.Errorf("ModeToADIF(0x%02X, %v) = %q, want %q", c.m, c.data, got, c.want) + } + } +} + +func TestLevelBCDRoundTrip(t *testing.T) { + for _, v := range []int{0, 1, 50, 99, 100, 128, 200, 255} { + b := LevelToBCD(v) + if len(b) != 2 { + t.Fatalf("LevelToBCD(%d) len=%d", v, len(b)) + } + if got := BCDToLevel(b); got != v { + t.Errorf("level round trip %d → % X → %d", v, b, got) + } + } + // Known encodings from the Icom CI-V reference. + if got := LevelToBCD(128); !bytes.Equal(got, []byte{0x01, 0x28}) { + t.Errorf("LevelToBCD(128) = % X, want 01 28", got) + } + if got := LevelToBCD(255); !bytes.Equal(got, []byte{0x02, 0x55}) { + t.Errorf("LevelToBCD(255) = % X, want 02 55", got) + } +} + +func TestByteBCDRoundTrip(t *testing.T) { + for _, v := range []int{0, 6, 12, 18, 21} { + if got := BCDToByte(ByteToBCD(v)); got != v { + t.Errorf("byte BCD round trip %d → %d", v, got) + } + } +} + +func TestModelName(t *testing.T) { + if got := ModelName(0x98); got != "IC-7610" { + t.Errorf("ModelName(0x98) = %q, want IC-7610", got) + } + if got := ModelName(0x12); got != "Icom (0x12)" { + t.Errorf("ModelName(0x12) = %q, want fallback", got) + } +} diff --git a/internal/cat/icomserial.go b/internal/cat/icomserial.go new file mode 100644 index 0000000..9503a63 --- /dev/null +++ b/internal/cat/icomserial.go @@ -0,0 +1,557 @@ +package cat + +import ( + "fmt" + "strings" + "sync" + "time" + + "hamlog/internal/cat/civ" + + "go.bug.st/serial" +) + +// IcomSerial controls an Icom transceiver over its USB/serial CI-V port (local +// control). It speaks the shared civ protocol, so when the network backend +// (icomnet) is added it will reuse the same encode/decode — only the transport +// changes. Implements Backend; all methods run on the Manager's CAT goroutine, +// so the port is accessed single-threaded (no locking needed). +type IcomSerial struct { + portName string + baud int + rigAddr byte // rig's CI-V address (IC-7610 default 0x98) + digital string // mode to command for DATA (FT8/RTTY/…) + + port serial.Port + rx []byte // accumulated bytes awaiting a complete frame + model string + + curFreq int64 // last frequency read (for sideband choice) + curModeByte byte // last raw Icom mode byte (for filter re-send) + lastSetFreq int64 // last frequency commanded (spot click: freq then mode) + lastSetFreqAt time.Time + + // dsp caches the receive-DSP state for the Icom control tab. Read off the + // CAT goroutine via IcomState(), written on the CAT goroutine (RefreshIcom + // / setters) — hence the mutex. + dspMu sync.Mutex + dsp IcomTXState +} + +const ( + icomReadTimeout = 350 * time.Millisecond // wait for a poll response + icomCmdTimeout = 400 * time.Millisecond // wait for a set ack (FB/FA) +) + +// NewIcomSerial builds an (unconnected) Icom serial backend. baud defaults to +// 115200, rig address to the IC-7610's 0x98 when out of range. +func NewIcomSerial(portName string, baud, civAddr int, digitalDefault string) *IcomSerial { + if baud <= 0 { + baud = 115200 + } + if civAddr <= 0 || civAddr > 0xFF { + civAddr = 0x98 // IC-7610 + } + if digitalDefault == "" { + digitalDefault = "FT8" + } + return &IcomSerial{ + portName: portName, + baud: baud, + rigAddr: byte(civAddr), + digital: strings.ToUpper(digitalDefault), + model: "Icom", + } +} + +func (b *IcomSerial) Name() string { return "icom" } + +func (b *IcomSerial) Connect() error { + if b.portName == "" { + return fmt.Errorf("no serial port configured") + } + port, err := serial.Open(b.portName, &serial.Mode{BaudRate: b.baud}) + if err != nil { + return fmt.Errorf("open %s @ %d baud: %w", b.portName, b.baud, err) + } + // Short read timeout so recv() polls in a tight loop without blocking the + // CAT goroutine when the rig is silent. + _ = port.SetReadTimeout(60 * time.Millisecond) + b.port = port + b.rx = b.rx[:0] + b.model = civ.ModelName(b.rigAddr) + + // Best-effort model identification: ask the rig for its own CI-V address. + if err := b.write(civ.CmdReadID, civ.SubPTT); err == nil { + if f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdReadID && len(d.Data) >= 2 && d.Data[0] == 0x00 + }); err == nil { + b.model = civ.ModelName(f.Data[1]) + } + } + b.readDSP() // best-effort initial snapshot for the control tab + return nil +} + +func (b *IcomSerial) Disconnect() { + if b.port != nil { + _ = b.port.Close() + b.port = nil + } +} + +// ReadState polls the rig for frequency and mode. A failed frequency read is +// treated as "lost the rig" so the Manager reconnects. +func (b *IcomSerial) ReadState() (RigState, error) { + if b.port == nil { + return RigState{}, fmt.Errorf("not connected") + } + s := RigState{Backend: b.Name(), Connected: true, Rig: b.model} + + hz, err := b.readFreq() + if err != nil { + return RigState{}, err + } + s.FreqHz = hz + b.curFreq = hz + + if m, ok := b.readMode(); ok { + b.curModeByte = m + data := b.readDataMode() // best-effort; ignored on failure + s.Mode = civ.ModeToADIF(m, data) + if s.Mode == "DATA" { + s.Mode = b.digital + } + b.dspMu.Lock() + b.dsp.Mode = s.Mode + b.dspMu.Unlock() + } + return s, nil +} + +func (b *IcomSerial) SetFrequency(hz int64) error { + if hz <= 0 { + return fmt.Errorf("invalid frequency") + } + b.lastSetFreq, b.lastSetFreqAt = hz, time.Now() + return b.exec(append([]byte{civ.CmdSetFreq}, civ.FreqToBCD(hz)...)...) +} + +func (b *IcomSerial) SetMode(mode string) error { + code, data, err := b.modeCode(mode) + if err != nil { + return err + } + // Set the base mode (keeping the rig's current filter by sending only the + // mode byte), then set the data-mode flag for digital modes. + if err := b.exec(civ.CmdSetMode, code); err != nil { + return err + } + dataByte := byte(0) + if data { + dataByte = 1 + } + // Filter 0x01 (FIL1) is the conventional default for the data-mode set. + _ = b.exec(civ.CmdExtra, civ.SubDataMode, dataByte, 0x01) + return nil +} + +func (b *IcomSerial) SetPTT(on bool) error { + state := byte(0) + if on { + state = 1 + } + return b.exec(civ.CmdPTT, civ.SubPTT, state) +} + +// ── helpers ─────────────────────────────────────────────────────────────── + +func (b *IcomSerial) write(payload ...byte) error { + _, err := b.port.Write(civ.Frame(b.rigAddr, civ.AddrController, payload...)) + return err +} + +// recv reads from the port until a frame from the rig satisfies match or the +// timeout elapses. Frames that are our own echo (from == controller) or don't +// match are discarded. +func (b *IcomSerial) recv(timeout time.Duration, match func(civ.Decoded) bool) (civ.Decoded, error) { + deadline := time.Now().Add(timeout) + tmp := make([]byte, 256) + for time.Now().Before(deadline) { + n, err := b.port.Read(tmp) + if err != nil { + return civ.Decoded{}, err + } + if n == 0 { + continue + } + b.rx = append(b.rx, tmp[:n]...) + frames, consumed := civ.Scan(b.rx) + if consumed > 0 { + b.rx = append(b.rx[:0], b.rx[consumed:]...) + } + for _, f := range frames { + if f.From != b.rigAddr { + continue // skip echo of our own commands + } + if match(f) { + return f, nil + } + } + } + return civ.Decoded{}, fmt.Errorf("icom: timeout waiting for response") +} + +// exec sends a set command and waits for the rig's OK (FB) / NG (FA) ack. +func (b *IcomSerial) exec(payload ...byte) error { + if err := b.write(payload...); err != nil { + return err + } + f, err := b.recv(icomCmdTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.OK || d.Cmd == civ.NG + }) + if err != nil { + return err + } + if f.Cmd == civ.NG { + return fmt.Errorf("icom: rig rejected command 0x%02X", payload[0]) + } + return nil +} + +func (b *IcomSerial) readFreq() (int64, error) { + if err := b.write(civ.CmdReadFreq); err != nil { + return 0, err + } + f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdReadFreq || d.Cmd == civ.CmdTransceiveFreq + }) + if err != nil { + return 0, err + } + return civ.BCDToFreq(f.Data), nil +} + +func (b *IcomSerial) readMode() (byte, bool) { + if err := b.write(civ.CmdReadMode); err != nil { + return 0, false + } + f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { + return (d.Cmd == civ.CmdReadMode || d.Cmd == civ.CmdTransceiveMode) && len(d.Data) >= 1 + }) + if err != nil { + return 0, false + } + return f.Data[0], true +} + +func (b *IcomSerial) readDataMode() bool { + if err := b.write(civ.CmdExtra, civ.SubDataMode); err != nil { + return false + } + f, err := b.recv(icomReadTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdExtra && len(d.Data) >= 2 && d.Data[0] == civ.SubDataMode + }) + if err != nil { + return false + } + return f.Data[1] != 0 +} + +// modeCode maps an ADIF mode to an Icom mode byte plus whether the data-mode +// flag should be set. SSB sideband follows the usual convention (LSB below +// 10 MHz, USB above); the frequency just commanded is preferred over the last +// poll so a clicked spot (freq then mode) picks the right sideband immediately. +func (b *IcomSerial) modeCode(mode string) (code byte, data bool, err error) { + freq := b.curFreq + if b.lastSetFreq > 0 && time.Since(b.lastSetFreqAt) < 5*time.Second { + freq = b.lastSetFreq + } + usb := byte(civ.ModeUSB) + if freq > 0 && freq < 10_000_000 { + usb = civ.ModeLSB + } + switch strings.ToUpper(strings.TrimSpace(mode)) { + case "CW": + return civ.ModeCW, false, nil + case "SSB": + return usb, false, nil + case "AM": + return civ.ModeAM, false, nil + case "FM": + return civ.ModeFM, false, nil + case "RTTY", "FSK": + return civ.ModeRTTY, false, nil + case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE": + // Digital data modes ride on USB with the data flag set (FT8 etc.). + return civ.ModeUSB, true, nil + } + return 0, false, fmt.Errorf("icom: unsupported mode %q", mode) +} + +// ── IcomController: receive-DSP controls for the Icom tab ─────────────────── + +func (b *IcomSerial) IcomState() IcomTXState { + b.dspMu.Lock() + defer b.dspMu.Unlock() + return b.dsp +} + +// RefreshIcom re-reads the whole DSP snapshot from the rig. Runs on the CAT +// goroutine (dispatched via IcomDo). +func (b *IcomSerial) RefreshIcom() error { + if b.port == nil { + return fmt.Errorf("not connected") + } + b.readDSP() + return nil +} + +// readDSP polls every DSP value once and replaces the cache. Best-effort: a +// value the rig doesn't answer keeps its previous cached value rather than +// stalling (each read has a short timeout). +func (b *IcomSerial) readDSP() { + st := IcomTXState{Available: true, Model: b.model} + b.dspMu.Lock() + st.Mode = b.dsp.Mode // preserve mode (set by ReadState) + b.dspMu.Unlock() + + if v, ok := b.readLevel(civ.SubLevelAF); ok { + st.AFGain = from255(v) + } + if v, ok := b.readLevel(civ.SubLevelRF); ok { + st.RFGain = from255(v) + } + if v, ok := b.readLevel(civ.SubLevelNR); ok { + st.NRLevel = from255(v) + } + if v, ok := b.readLevel(civ.SubLevelNB); ok { + st.NBLevel = from255(v) + } + if v, ok := b.readSwitch(civ.SubSwNB); ok { + st.NB = v != 0 + } + if v, ok := b.readSwitch(civ.SubSwNR); ok { + st.NR = v != 0 + } + if v, ok := b.readSwitch(civ.SubSwANF); ok { + st.ANF = v != 0 + } + if v, ok := b.readSwitch(civ.SubSwAGC); ok { + st.AGC = agcName(v) + } + if v, ok := b.readSwitch(civ.SubSwPreamp); ok { + st.Preamp = int(v) + } + if v, ok := b.readAtt(); ok { + st.Att = v + } + if _, f, ok := b.readModeFilter(); ok { + st.Filter = int(f) + } + + b.dspMu.Lock() + b.dsp = st + b.dspMu.Unlock() +} + +const icomDSPTimeout = 150 * time.Millisecond // shorter: unsupported reads mustn't stall the poll + +func (b *IcomSerial) readLevel(sub byte) (int, bool) { + if err := b.write(civ.CmdLevel, sub); err != nil { + return 0, false + } + f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdLevel && len(d.Data) >= 3 && d.Data[0] == sub + }) + if err != nil { + return 0, false + } + return civ.BCDToLevel(f.Data[1:3]), true +} + +func (b *IcomSerial) readSwitch(sub byte) (byte, bool) { + if err := b.write(civ.CmdSwitch, sub); err != nil { + return 0, false + } + f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdSwitch && len(d.Data) >= 2 && d.Data[0] == sub + }) + if err != nil { + return 0, false + } + return f.Data[1], true +} + +func (b *IcomSerial) readAtt() (int, bool) { + if err := b.write(civ.CmdAtt); err != nil { + return 0, false + } + f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdAtt && len(d.Data) >= 1 + }) + if err != nil { + return 0, false + } + return civ.BCDToByte(f.Data[0]), true +} + +func (b *IcomSerial) readModeFilter() (mode, filter byte, ok bool) { + if err := b.write(civ.CmdReadMode); err != nil { + return 0, 0, false + } + f, err := b.recv(icomDSPTimeout, func(d civ.Decoded) bool { + return d.Cmd == civ.CmdReadMode && len(d.Data) >= 2 + }) + if err != nil { + return 0, 0, false + } + return f.Data[0], f.Data[1], true +} + +func (b *IcomSerial) SetAFGain(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelAF}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.AFGain = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetRFGain(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelRF}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.RFGain = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetNB(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwNB, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.NB = on }) + return nil +} + +func (b *IcomSerial) SetNBLevel(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNB}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.NBLevel = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetNR(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwNR, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.NR = on }) + return nil +} + +func (b *IcomSerial) SetNRLevel(p int) error { + if err := b.exec(append([]byte{civ.CmdLevel, civ.SubLevelNR}, civ.LevelToBCD(to255(p))...)...); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.NRLevel = clampPct(p) }) + return nil +} + +func (b *IcomSerial) SetANF(on bool) error { + if err := b.exec(civ.CmdSwitch, civ.SubSwANF, boolByte(on)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.ANF = on }) + return nil +} + +func (b *IcomSerial) SetAGC(name string) error { + v := agcValue(name) + if v == 0 { + return fmt.Errorf("icom: invalid AGC %q", name) + } + if err := b.exec(civ.CmdSwitch, civ.SubSwAGC, v); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.AGC = strings.ToUpper(name) }) + return nil +} + +func (b *IcomSerial) SetPreamp(n int) error { + if n < 0 || n > 2 { + return fmt.Errorf("icom: invalid preamp %d", n) + } + if err := b.exec(civ.CmdSwitch, civ.SubSwPreamp, byte(n)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.Preamp = n }) + return nil +} + +func (b *IcomSerial) SetAtt(db int) error { + if err := b.exec(civ.CmdAtt, civ.ByteToBCD(db)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.Att = db }) + return nil +} + +func (b *IcomSerial) SetIcomFilter(n int) error { + if n < 1 || n > 3 { + return fmt.Errorf("icom: invalid filter %d", n) + } + if b.curModeByte == 0 { + // Need the current mode to re-send with the chosen filter. + if m, _, ok := b.readModeFilter(); ok { + b.curModeByte = m + } + } + if err := b.exec(civ.CmdSetMode, b.curModeByte, byte(n)); err != nil { + return err + } + b.setCache(func(s *IcomTXState) { s.Filter = n }) + return nil +} + +func (b *IcomSerial) setCache(fn func(*IcomTXState)) { + b.dspMu.Lock() + fn(&b.dsp) + b.dspMu.Unlock() +} + +// ── small helpers ────────────────────────────────────────────────────────── + +func to255(p int) int { return clampPct(p) * 255 / 100 } +func from255(v int) int { return (v*100 + 127) / 255 } +func clampPct(p int) int { return min(100, max(0, p)) } + +func boolByte(on bool) byte { + if on { + return 1 + } + return 0 +} + +func agcName(v byte) string { + switch v { + case 1: + return "FAST" + case 2: + return "MID" + case 3: + return "SLOW" + } + return "" +} + +func agcValue(name string) byte { + switch strings.ToUpper(strings.TrimSpace(name)) { + case "FAST": + return 1 + case "MID": + return 2 + case "SLOW": + return 3 + } + return 0 +}