feat: Support for Antenna Genius
This commit is contained in:
@@ -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
|
||||
|
||||
+77
-2
@@ -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<CATState>({ 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<AGStatus>({ 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() {
|
||||
>
|
||||
<Compass className="size-4" />
|
||||
</button>
|
||||
{agEnabled && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { const v = !showAntGenius; setShowAntGenius(v); writeUiPref('opslog.showAntGenius', v ? '1' : '0'); }}
|
||||
title={showAntGenius ? 'Antenna Genius — shown · click to hide' : 'Antenna Genius · click to show'}
|
||||
className={cn(
|
||||
'relative inline-flex items-center justify-center size-7 rounded-md border transition-colors',
|
||||
showAntGenius ? 'border-emerald-300 bg-emerald-50 text-emerald-700 hover:bg-emerald-100'
|
||||
: 'border-border text-muted-foreground hover:bg-muted',
|
||||
)}
|
||||
>
|
||||
<Antenna className="size-4" />
|
||||
{showAntGenius && agStatus.connected && <span className="absolute -top-0.5 -right-0.5 size-2 rounded-full bg-emerald-500" />}
|
||||
</button>
|
||||
)}
|
||||
{chatAvailable && (
|
||||
<button
|
||||
type="button"
|
||||
@@ -3219,7 +3276,7 @@ export default function App() {
|
||||
{/* Reserved free space to the right. The WinKeyer CW keyer and/or the
|
||||
Digital Voice Keyer take this slot when enabled (Log4OM-style);
|
||||
otherwise it shows the QRZ profile photo. */}
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath))) && (
|
||||
{!compact && (chatShown || wkEnabled || dvkEnabled || lookupResult?.image_url || (showRotor && (rotatorHeading.enabled || dxPath)) || (showAntGenius && agEnabled)) && (
|
||||
<div className="flex-1 min-w-0 min-h-0 flex gap-2.5 items-stretch">
|
||||
{chatShown && (
|
||||
// relative + absolute inner: the chat takes the row height (set by the
|
||||
@@ -3249,6 +3306,15 @@ export default function App() {
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showAntGenius && agEnabled && (
|
||||
<div className="w-[230px] shrink-0 min-h-0">
|
||||
<AntGeniusPanel
|
||||
status={agStatus}
|
||||
onActivate={agActivate}
|
||||
onClose={() => { setShowAntGenius(false); writeUiPref('opslog.showAntGenius', '0'); }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{dvkEnabled && (
|
||||
<div className="w-[264px] shrink-0 min-h-0">
|
||||
<DvkPanel
|
||||
@@ -3316,7 +3382,7 @@ export default function App() {
|
||||
|
||||
{/* ===== CW decoder strip (only when enabled AND mode is CW) ===== */}
|
||||
{cwOn && (
|
||||
<div className="ml-2.5 mb-1 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1 text-xs">
|
||||
<div className="ml-2.5 my-1.5 w-[45%] flex items-center gap-2 rounded-md border border-emerald-300/70 bg-emerald-50/60 px-2 py-1.5 text-xs">
|
||||
<Ear className={cn('size-4 shrink-0', cwStatus.active ? 'text-emerald-600' : 'text-muted-foreground')} />
|
||||
{/* Input-level meter — if this stays flat with a strong signal, the RX
|
||||
audio device is wrong/silent rather than a decode problem. */}
|
||||
@@ -3383,6 +3449,7 @@ export default function App() {
|
||||
<TabsTrigger value="awards">Awards</TabsTrigger>
|
||||
<TabsTrigger value="bandmap">Band Map</TabsTrigger>
|
||||
{catState.backend === 'flex' && <TabsTrigger value="flex">FlexRadio</TabsTrigger>}
|
||||
{catState.backend === 'icom' && <TabsTrigger value="icom">Icom</TabsTrigger>}
|
||||
{/* Not a tab — QRZ blocks embedding, so this opens the call's
|
||||
QRZ.com page in the system browser. Styled like a trigger. */}
|
||||
<button
|
||||
@@ -3696,6 +3763,14 @@ export default function App() {
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Icom CI-V receive-DSP control panel — only when the CAT backend
|
||||
is an Icom. */}
|
||||
{catState.backend === 'icom' && (
|
||||
<TabsContent value="icom" className="flex-1 min-h-0 p-0">
|
||||
<IcomPanel />
|
||||
</TabsContent>
|
||||
)}
|
||||
|
||||
{/* Band Map: several bands shown side-by-side (panadapter-style
|
||||
strips). Pick bands with the chips; each strip is clickable to
|
||||
tune the rig. */}
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
import { Antenna, X } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export type AGAntenna = { index: number; name: string };
|
||||
export type AGStatus = {
|
||||
connected: boolean; host?: string; last_error?: string;
|
||||
port_a: number; port_b: number; tx_a?: boolean; tx_b?: boolean;
|
||||
antennas: AGAntenna[];
|
||||
};
|
||||
|
||||
// Format an antenna name: first letter uppercase, the rest lowercase
|
||||
// (e.g. "DX COMMANDER" → "Dx commander").
|
||||
function pretty(name: string): string {
|
||||
const t = name.trim();
|
||||
if (!t) return t;
|
||||
return t.charAt(0).toUpperCase() + t.slice(1).toLowerCase();
|
||||
}
|
||||
|
||||
// AntGeniusPanel — antenna-switch widget for a 4O3A Antenna Genius, styled to
|
||||
// match the app's light theme with soft gradients + glows. Each antenna row has
|
||||
// a port-A button (left) and port-B button (right). Colours: green = selected on
|
||||
// port A, blue = selected on port B, red (pulsing) = that port is transmitting.
|
||||
// Clicking an already-selected port deselects it (port → None).
|
||||
export function AntGeniusPanel({ status, onActivate, onClose }: {
|
||||
status: AGStatus;
|
||||
onActivate: (port: number, antenna: number) => void; // antenna 0 = deselect
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const list = status.antennas ?? [];
|
||||
|
||||
const PortBtn = ({ port, index, active, tx }: { port: 1 | 2; index: number; active: boolean; tx: boolean }) => {
|
||||
const letter = port === 1 ? 'A' : 'B';
|
||||
const cls = tx
|
||||
? 'bg-gradient-to-b from-red-500 to-rose-600 text-white border-red-400/50 shadow-[0_0_10px_rgba(244,63,94,0.5)] animate-pulse'
|
||||
: active
|
||||
? (port === 1
|
||||
? 'bg-gradient-to-b from-emerald-400 to-emerald-600 text-white border-emerald-300/60 shadow-[0_0_9px_rgba(16,185,129,0.45)]'
|
||||
: 'bg-gradient-to-b from-sky-400 to-sky-600 text-white border-sky-300/60 shadow-[0_0_9px_rgba(14,165,233,0.45)]')
|
||||
: 'bg-card text-muted-foreground border-border hover:bg-muted hover:text-foreground';
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onActivate(port, active ? 0 : index)}
|
||||
title={active ? `Port ${letter} — click to deselect` : `Select on port ${letter}`}
|
||||
className={cn('w-8 shrink-0 rounded-lg text-xs font-bold py-1.5 border transition-all active:scale-95', cls)}
|
||||
>
|
||||
{letter}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="h-full flex flex-col rounded-xl border border-border bg-gradient-to-b from-card to-muted/30 shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/40 shrink-0">
|
||||
<Antenna className={cn('size-4', status.connected ? 'text-emerald-600 drop-shadow-[0_0_3px_rgba(16,185,129,0.55)]' : 'text-muted-foreground')} />
|
||||
<span className="text-xs font-bold uppercase tracking-[0.18em] text-foreground/80">Antenna Genius</span>
|
||||
<span className="flex-1" />
|
||||
<span className="inline-flex items-center gap-1.5 text-[10px] font-mono uppercase tracking-wider">
|
||||
<span className={cn('size-1.5 rounded-full', status.connected ? 'bg-emerald-500 shadow-[0_0_6px_rgba(16,185,129,0.8)] animate-pulse' : 'bg-rose-500')} />
|
||||
<span className={status.connected ? 'text-emerald-600' : 'text-rose-500'}>{status.connected ? 'online' : 'offline'}</span>
|
||||
</span>
|
||||
<button type="button" onClick={onClose} className="ml-1 text-muted-foreground hover:text-foreground transition-colors" title="Close">
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 min-h-0 overflow-y-auto p-2 space-y-1.5">
|
||||
{!status.connected ? (
|
||||
<div className="text-center py-6 text-xs space-y-2">
|
||||
<div className="text-muted-foreground italic animate-pulse">Connecting…</div>
|
||||
{status.last_error && <div className="text-rose-500 font-mono text-[10px] break-words px-2">{status.last_error}</div>}
|
||||
</div>
|
||||
) : list.length === 0 ? (
|
||||
<div className="text-muted-foreground italic text-center py-6 text-xs">No antennas configured.</div>
|
||||
) : 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 (
|
||||
<div key={a.index} className="flex items-center gap-1.5">
|
||||
<PortBtn port={1} index={a.index} active={aActive} tx={aTx} />
|
||||
<div className={cn('flex-1 min-w-0 truncate text-center text-xs font-semibold tracking-wide rounded-lg px-2 py-1.5 border transition-all', nameCls)}>
|
||||
{pretty(a.name)}
|
||||
</div>
|
||||
<PortBtn port={2} index={a.index} active={bActive} tx={bTx} />
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<input
|
||||
type="range" min={0} max={100} value={v} disabled={disabled}
|
||||
onChange={(e) => onChange(parseInt(e.target.value, 10))}
|
||||
className={cn('flex-1 h-1.5 rounded-full appearance-none cursor-pointer disabled:opacity-30 disabled:cursor-default',
|
||||
'[&::-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 (
|
||||
<div className="inline-flex rounded-md border border-border overflow-hidden shrink-0">
|
||||
{options.map((o) => (
|
||||
<button key={o.v} type="button" onClick={() => onChange(o.v)}
|
||||
className={cn('px-2 py-1 text-[11px] font-bold tracking-wide transition-colors border-l border-border first:border-l-0',
|
||||
value === o.v ? 'bg-primary text-primary-foreground' : 'bg-card text-muted-foreground hover:bg-muted')}>
|
||||
{o.l}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Chip({ on, onClick, label }: { on: boolean; onClick: () => void; label: string }) {
|
||||
return (
|
||||
<button type="button" onClick={onClick}
|
||||
className={cn('w-14 shrink-0 px-2 py-1 rounded-md text-[11px] font-bold border transition-colors',
|
||||
on ? 'bg-emerald-600 border-emerald-600 text-white' : 'bg-card text-muted-foreground border-border hover:bg-muted')}>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function LevelRow({ label, on, onToggle, value, onLevel }: {
|
||||
label: string; on: boolean; onToggle: () => void; value: number; onLevel: (v: number) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip on={on} onClick={onToggle} label={label} />
|
||||
<Slider value={value} disabled={!on} onChange={onLevel} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Card({ icon: Icon, title, accent, children }: { icon: any; title: string; accent?: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-xl border border-border bg-card shadow-sm overflow-hidden">
|
||||
<div className="flex items-center gap-2 px-3 py-2 border-b border-border/60 bg-muted/30">
|
||||
<Icon className="size-4" style={{ color: accent ?? 'var(--primary)' }} />
|
||||
<span className="text-xs font-bold uppercase tracking-wider text-foreground/80">{title}</span>
|
||||
</div>
|
||||
<div className="p-3 space-y-3">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Row({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="w-16 shrink-0 text-[11px] font-bold uppercase tracking-wider text-muted-foreground">{label}</span>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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<IcomState>(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<IcomState>, fn: () => Promise<void>) => {
|
||||
setSt((s) => ({ ...s, ...patch }));
|
||||
fn().catch(() => {});
|
||||
};
|
||||
|
||||
if (!st.available) {
|
||||
return (
|
||||
<div className="h-full flex items-center justify-center text-sm text-muted-foreground p-6 text-center">
|
||||
Icom not connected. Enable the Icom CI-V backend in Settings → CAT and connect the radio's USB port.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto p-3 space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-bold">{st.model || 'Icom'}{st.mode ? <span className="ml-2 text-xs font-mono text-muted-foreground">{st.mode}</span> : null}</div>
|
||||
<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')} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<Card icon={Radio} title="Receive" accent="#2563eb">
|
||||
<Row label="AF">
|
||||
<Slider value={st.af_gain} onChange={(v) => set({ af_gain: v }, () => IcomSetAFGain(v))} />
|
||||
<span className="w-8 text-right text-xs font-mono tabular-nums text-muted-foreground">{st.af_gain}</span>
|
||||
</Row>
|
||||
<Row label="RF">
|
||||
<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="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))} />
|
||||
</Row>
|
||||
<Row label="Preamp">
|
||||
<Segmented value={String(st.preamp)} options={[{ v: '0', l: 'OFF' }, { v: '1', l: 'P1' }, { v: '2', l: 'P2' }]}
|
||||
onChange={(v) => set({ preamp: parseInt(v) }, () => IcomSetPreamp(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label="Att">
|
||||
<Segmented value={String(st.att)} options={[{ v: '0', l: 'OFF' }, { v: '6', l: '6dB' }, { v: '12', l: '12dB' }, { v: '18', l: '18dB' }]}
|
||||
onChange={(v) => set({ att: parseInt(v) }, () => IcomSetAtt(parseInt(v)))} />
|
||||
</Row>
|
||||
<Row label="Filter">
|
||||
<Segmented value={String(st.filter)} options={[{ v: '1', l: 'FIL1' }, { v: '2', l: 'FIL2' }, { v: '3', l: 'FIL3' }]}
|
||||
onChange={(v) => set({ filter: parseInt(v) }, () => IcomSetFilter(parseInt(v)))} />
|
||||
</Row>
|
||||
</Card>
|
||||
|
||||
<Card icon={AudioLines} title="Noise / Notch" accent="#16a34a">
|
||||
<LevelRow label="NB" on={st.nb} value={st.nb_level}
|
||||
onToggle={() => set({ nb: !st.nb }, () => IcomSetNB(!st.nb))}
|
||||
onLevel={(v) => set({ nb_level: v }, () => IcomSetNBLevel(v))} />
|
||||
<LevelRow label="NR" on={st.nr} value={st.nr_level}
|
||||
onToggle={() => set({ nr: !st.nr }, () => IcomSetNR(!st.nr))}
|
||||
onLevel={(v) => set({ nr_level: v }, () => IcomSetNRLevel(v))} />
|
||||
<div className="flex items-center gap-2">
|
||||
<Chip label="ANF" on={st.anf} onClick={() => set({ anf: !st.anf }, () => IcomSetANF(!st.anf))} />
|
||||
<span className="text-xs text-muted-foreground">Auto notch filter</span>
|
||||
</div>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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<Record<SectionId, string>> = {
|
||||
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<CATSettings>({
|
||||
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<RotatorSettings>({
|
||||
@@ -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
|
||||
<SelectContent>
|
||||
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1810,7 +1822,40 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</label>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'omnirig' && (
|
||||
{catCfg.backend === 'icom' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>Icom CI-V port</Label>
|
||||
<div className="flex gap-2">
|
||||
<Select value={catCfg.icom_port || ''} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_port: v }))}>
|
||||
<SelectTrigger><SelectValue placeholder="Select COM port" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{wkPorts.length === 0 && <SelectItem value="_" disabled>No ports found</SelectItem>}
|
||||
{wkPorts.map((p) => <SelectItem key={p} value={p}>{p}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button type="button" variant="outline" size="sm"
|
||||
onClick={() => ListSerialPorts().then((p) => setWkPorts((p ?? []) as string[])).catch(() => {})}>↻</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Baud rate</Label>
|
||||
<Select value={String(catCfg.icom_baud || 115200)} onValueChange={(v) => setCatCfg((s) => ({ ...s, icom_baud: parseInt(v) || 115200 }))}>
|
||||
<SelectTrigger><SelectValue /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{[4800, 9600, 19200, 38400, 57600, 115200].map((r) => <SelectItem key={r} value={String(r)}>{r}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-1 col-span-2">
|
||||
<Label>CI-V address (hex)</Label>
|
||||
<Input value={(catCfg.icom_addr ?? 0x98).toString(16).toUpperCase().padStart(2, '0')}
|
||||
onChange={(e) => { 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 })); }} />
|
||||
<p className="text-xs text-muted-foreground">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.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>Poll interval (ms)</Label>
|
||||
@@ -1977,6 +2022,36 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
);
|
||||
}
|
||||
|
||||
function AntGeniusPanelSettings() {
|
||||
return (
|
||||
<>
|
||||
<SectionHeader
|
||||
title="Antenna Genius (4O3A)"
|
||||
hint="OpsLog talks to the 4O3A Antenna Genius switch over TCP (GSCP protocol). The port is fixed at 9007, so only the device IP is needed. A docked widget then lets you switch antennas per port (A/B)."
|
||||
/>
|
||||
<div className="space-y-4 max-w-xl">
|
||||
<label className="flex items-center gap-2 text-sm cursor-pointer">
|
||||
<Checkbox checked={antgenius.enabled} onCheckedChange={(c) => setAntgenius((s) => ({ ...s, enabled: !!c }))} />
|
||||
Enable Antenna Genius control
|
||||
</label>
|
||||
<div className="space-y-1">
|
||||
<Label>Host / IP</Label>
|
||||
<Input
|
||||
value={antgenius.host ?? ''}
|
||||
onChange={(e) => setAntgenius((s) => ({ ...s, host: e.target.value }))}
|
||||
placeholder="192.168.1.60"
|
||||
className="font-mono"
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">TCP port is fixed at 9007.</p>
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
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).
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function RotatorPanel() {
|
||||
return (
|
||||
<>
|
||||
@@ -3732,6 +3807,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
rotator: RotatorPanel,
|
||||
winkeyer: WinkeyerPanel,
|
||||
antenna: UltrabeamPanel,
|
||||
antgenius: AntGeniusPanelSettings,
|
||||
audio: AudioPanel,
|
||||
};
|
||||
|
||||
|
||||
Vendored
+37
@@ -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<void>;
|
||||
|
||||
export function AddQSO(arg1:qso.QSO):Promise<number>;
|
||||
|
||||
export function AntGeniusActivate(arg1:number,arg2:number):Promise<void>;
|
||||
|
||||
export function AntGeniusDeselect(arg1:number):Promise<void>;
|
||||
|
||||
export function ApplyAwardPreset(arg1:string,arg2:string):Promise<number>;
|
||||
|
||||
export function AssignAwardRefToQSOs(arg1:string,arg2:string,arg3:Array<number>):Promise<number>;
|
||||
@@ -195,6 +200,10 @@ export function FlexTune(arg1:boolean):Promise<void>;
|
||||
|
||||
export function GetActiveProfile():Promise<profile.Profile>;
|
||||
|
||||
export function GetAntGeniusSettings():Promise<main.AntGeniusSettings>;
|
||||
|
||||
export function GetAntGeniusStatus():Promise<antgenius.Status>;
|
||||
|
||||
export function GetAudioSettings():Promise<main.AudioSettings>;
|
||||
|
||||
export function GetAutostartPrograms():Promise<Array<main.AutostartProgram>>;
|
||||
@@ -247,6 +256,8 @@ export function GetExternalServices():Promise<extsvc.ExternalServices>;
|
||||
|
||||
export function GetFlexState():Promise<cat.FlexTXState>;
|
||||
|
||||
export function GetIcomState():Promise<cat.IcomTXState>;
|
||||
|
||||
export function GetListsSettings():Promise<main.ListsSettings>;
|
||||
|
||||
export function GetLiveStatusEnabled():Promise<boolean>;
|
||||
@@ -291,6 +302,30 @@ export function GetWinkeyerStatus():Promise<winkeyer.Status>;
|
||||
|
||||
export function HasBuiltinReferences(arg1:string):Promise<boolean>;
|
||||
|
||||
export function IcomRefresh():Promise<void>;
|
||||
|
||||
export function IcomSetAFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetAGC(arg1:string):Promise<void>;
|
||||
|
||||
export function IcomSetANF(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetAtt(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetFilter(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNB(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNBLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetNR(arg1:boolean):Promise<void>;
|
||||
|
||||
export function IcomSetNRLevel(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetPreamp(arg1:number):Promise<void>;
|
||||
|
||||
export function IcomSetRFGain(arg1:number):Promise<void>;
|
||||
|
||||
export function ImportADIF(arg1:string,arg2:string,arg3:boolean,arg4:boolean):Promise<adif.ImportResult>;
|
||||
|
||||
export function ImportAwardReferencesText(arg1:string,arg2:string):Promise<number>;
|
||||
@@ -421,6 +456,8 @@ export function RunBackupNow():Promise<string>;
|
||||
|
||||
export function SaveADIFFile():Promise<string>;
|
||||
|
||||
export function SaveAntGeniusSettings(arg1:main.AntGeniusSettings):Promise<void>;
|
||||
|
||||
export function SaveAudioSettings(arg1:main.AudioSettings):Promise<void>;
|
||||
|
||||
export function SaveAutostartPrograms(arg1:Array<main.AutostartProgram>):Promise<void>;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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<seq>|<command>\r" and the device replies
|
||||
// with "R<seq>|<hex>|<message>" (hex "0" = success) plus asynchronous
|
||||
// "S<0>|<message>" 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<seq>|<command>\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<seq>|<hex>|<message> or S<seq>|<message>
|
||||
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 <id> name=<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 <id> ... rxant=<n> txant=<n> ...". 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=<int>" 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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 <to> <from> <cmd> [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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user