Initial codebase: Go + Wails amateur radio logbook
Backend (Go 1.25 / Wails v2): - QSO storage on SQLite (modernc) with embedded migrations (0001..0005) - Streaming ADIF import (batch insert) + WorkedBefore per callsign and DXCC - Callsign lookup with QRZ.com + HamQTH providers (primary/failsafe routing) and SQLite-backed TTL cache - DXCC resolver from cty.dat (auto-download, longest-prefix-match) - Multi-profile operator identities (home/portable/SOTA/contest) — every QSO stamps MY_* from the active profile - CAT control via OmniRig COM on a single OS-locked goroutine, with bidirectional sync (freq/mode/band/split/VFOs) and Rig1/Rig2 hot-swap - Settings store (key/value), CAT debug log at %APPDATA%/HamLog/cat.log Frontend (React 18 + TypeScript + Tailwind v4 + shadcn-style): - Single-row entry strip with CAT-aware band/mode/freq, RST, Start/End UTC, per-field locks (band/mode/freq/start/end) for backdated QSOs - Topbar: live freq (MHz.kHz.Hz dotted), live UTC, band/mode/SPLIT badges, CAT pill with rig selector and clickable Azimuth pill (rotor TODO) - Settings tree: Profiles (Log4OM-style manager), Station Information (edits the active profile), unified Callsign Lookup with Test buttons, Bands/Modes lists, CAT - Worked-before matrix (band × mode × class) with new-DXCC highlighting - ADIF import from menu + Maintenance > Refresh cty.dat Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
// Package cat drives the transceiver via swappable backends (OmniRig, Flex…)
|
||||
// and pushes state changes to the UI through an injected emitter callback.
|
||||
//
|
||||
// The poll loop runs on an OS-thread-locked goroutine so COM-based backends
|
||||
// (OmniRig) work correctly — COM is thread-affine on Windows and must be
|
||||
// initialised, used and uninitialised from the same OS thread.
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Backend abstracts a specific transceiver-control library. All methods run
|
||||
// on the dedicated CAT goroutine spawned by Manager — implementations can
|
||||
// assume single-threaded access and can safely manage thread-bound resources
|
||||
// (e.g. COM objects in OmniRig).
|
||||
type Backend interface {
|
||||
Name() string // "omnirig" | "flex" | …
|
||||
Connect() error
|
||||
Disconnect()
|
||||
ReadState() (RigState, error)
|
||||
SetFrequency(hz int64) error
|
||||
// SetMode receives an ADIF mode string (SSB, CW, FT8, RTTY, AM, FM…).
|
||||
// Implementations decide USB vs LSB (typically by current freq) and
|
||||
// generic vs specific digital modes (most rigs just have DATA).
|
||||
SetMode(mode string) error
|
||||
}
|
||||
|
||||
// RigState is the snapshot exchanged with the frontend.
|
||||
//
|
||||
// FreqHz follows the ADIF FREQ convention: it is the TX frequency. When the
|
||||
// rig is in split, FreqHz is the inactive VFO (where the operator transmits)
|
||||
// and RxFreqHz is the active VFO (where they listen). When not split,
|
||||
// RxFreqHz is 0 — the UI shouldn't show a redundant RX field.
|
||||
type RigState struct {
|
||||
Enabled bool `json:"enabled"` // user toggled CAT on
|
||||
Connected bool `json:"connected"` // backend says rig is online
|
||||
Backend string `json:"backend,omitempty"` // active backend name
|
||||
RigNum int `json:"rig_num,omitempty"` // OmniRig slot 1 or 2 (when applicable)
|
||||
Rig string `json:"rig,omitempty"` // rig model (best-effort)
|
||||
FreqHz int64 `json:"freq_hz,omitempty"` // TX freq (= active VFO when not split)
|
||||
RxFreqHz int64 `json:"freq_rx_hz,omitempty"` // RX freq, only set when Split
|
||||
Split bool `json:"split,omitempty"` // rig is in split mode
|
||||
Mode string `json:"mode,omitempty"` // ADIF mode (SSB/CW/DATA/AM/FM/RTTY)
|
||||
Band string `json:"band,omitempty"` // computed from FreqHz
|
||||
Vfo string `json:"vfo,omitempty"` // "A" | "B" | "AA" | "AB" | "BA" | "BB"
|
||||
Error string `json:"error,omitempty"` // last connect/poll error if any
|
||||
UpdatedAt time.Time `json:"updated_at,omitempty"`
|
||||
}
|
||||
|
||||
// Manager owns the active backend and runs the polling loop.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
state RigState
|
||||
emit func(RigState)
|
||||
backend Backend
|
||||
|
||||
// Set when running. nil when stopped.
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
cmdCh chan func() // marshall arbitrary work onto the CAT goroutine
|
||||
|
||||
pollEvery time.Duration
|
||||
cmdDelay time.Duration // pause after each command (some rigs need it)
|
||||
}
|
||||
|
||||
func NewManager(emit func(RigState)) *Manager {
|
||||
return &Manager{emit: emit, pollEvery: 250 * time.Millisecond}
|
||||
}
|
||||
|
||||
// SetPollInterval changes the polling cadence. Caps at 50ms…2s to avoid
|
||||
// either hammering the rig or feeling laggy.
|
||||
func (m *Manager) SetPollInterval(d time.Duration) {
|
||||
if d < 50*time.Millisecond {
|
||||
d = 50 * time.Millisecond
|
||||
}
|
||||
if d > 2*time.Second {
|
||||
d = 2 * time.Second
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.pollEvery = d
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// SetCommandDelay sets a pause inserted after each CAT command. Some older
|
||||
// Kenwood/Yaesu rigs drop bytes if commands arrive too fast back to back.
|
||||
// Capped at 0…500ms — beyond that, fix your rig.
|
||||
func (m *Manager) SetCommandDelay(d time.Duration) {
|
||||
if d < 0 {
|
||||
d = 0
|
||||
}
|
||||
if d > 500*time.Millisecond {
|
||||
d = 500 * time.Millisecond
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.cmdDelay = d
|
||||
m.mu.Unlock()
|
||||
}
|
||||
|
||||
// State returns a copy of the latest known state.
|
||||
func (m *Manager) State() RigState {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
return m.state
|
||||
}
|
||||
|
||||
// Start spins up the CAT goroutine with the given backend. If a backend is
|
||||
// already running it is stopped first. Errors during Connect are surfaced as
|
||||
// state.Error rather than returned, so the UI can keep retrying via the
|
||||
// poll loop on next reconnect attempt.
|
||||
func (m *Manager) Start(b Backend) {
|
||||
m.Stop()
|
||||
m.mu.Lock()
|
||||
m.stopCh = make(chan struct{})
|
||||
m.doneCh = make(chan struct{})
|
||||
m.cmdCh = make(chan func(), 4)
|
||||
m.backend = b
|
||||
stop := m.stopCh
|
||||
done := m.doneCh
|
||||
cmds := m.cmdCh
|
||||
poll := m.pollEvery
|
||||
m.state = RigState{Enabled: true, Backend: b.Name()}
|
||||
m.mu.Unlock()
|
||||
m.emitState()
|
||||
|
||||
go m.run(b, stop, done, cmds, poll)
|
||||
}
|
||||
|
||||
// Stop signals the CAT goroutine to disconnect and waits for it to exit.
|
||||
func (m *Manager) Stop() {
|
||||
m.mu.Lock()
|
||||
stop := m.stopCh
|
||||
done := m.doneCh
|
||||
m.stopCh = nil
|
||||
m.doneCh = nil
|
||||
m.cmdCh = nil
|
||||
m.backend = nil
|
||||
m.mu.Unlock()
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.state = RigState{Enabled: false}
|
||||
m.mu.Unlock()
|
||||
m.emitState()
|
||||
}
|
||||
|
||||
// SetFrequency dispatches a SetFreq call to the CAT goroutine.
|
||||
func (m *Manager) SetFrequency(hz int64) error {
|
||||
return m.exec(func(b Backend) error { return b.SetFrequency(hz) })
|
||||
}
|
||||
|
||||
// SetMode dispatches a SetMode call to the CAT goroutine.
|
||||
func (m *Manager) SetMode(mode string) error {
|
||||
return m.exec(func(b Backend) error { return b.SetMode(mode) })
|
||||
}
|
||||
|
||||
// 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 {
|
||||
m.mu.RLock()
|
||||
cmds := m.cmdCh
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if cmds == nil || b == nil {
|
||||
return fmt.Errorf("cat not running")
|
||||
}
|
||||
errCh := make(chan error, 1)
|
||||
select {
|
||||
case cmds <- func() { errCh <- fn(b) }:
|
||||
case <-time.After(500 * time.Millisecond):
|
||||
return fmt.Errorf("cat busy")
|
||||
}
|
||||
return <-errCh
|
||||
}
|
||||
|
||||
// run is the CAT goroutine. Owns the backend lifecycle.
|
||||
func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pollEvery time.Duration) {
|
||||
// Lock to a single OS thread — required for COM. Cheap for non-COM backends.
|
||||
runtime.LockOSThread()
|
||||
defer runtime.UnlockOSThread()
|
||||
defer close(done)
|
||||
|
||||
if err := b.Connect(); err != nil {
|
||||
m.update(RigState{
|
||||
Enabled: true, Backend: b.Name(), Connected: false,
|
||||
Error: err.Error(), UpdatedAt: time.Now(),
|
||||
})
|
||||
// Stay idle until Stop is called — let the user fix config and re-Start.
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case fn := <-cmds:
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
defer b.Disconnect()
|
||||
|
||||
ticker := time.NewTicker(pollEvery)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case fn := <-cmds:
|
||||
fn()
|
||||
m.applyCommandDelay()
|
||||
case <-ticker.C:
|
||||
ns, err := b.ReadState()
|
||||
if err != nil {
|
||||
m.update(RigState{
|
||||
Enabled: true, Backend: b.Name(), Connected: false,
|
||||
Error: err.Error(), UpdatedAt: time.Now(),
|
||||
})
|
||||
continue
|
||||
}
|
||||
ns.Enabled = true
|
||||
ns.Backend = b.Name()
|
||||
ns.UpdatedAt = time.Now()
|
||||
if ns.FreqHz != 0 && ns.Band == "" {
|
||||
ns.Band = BandFromHz(ns.FreqHz)
|
||||
}
|
||||
m.update(ns)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) applyCommandDelay() {
|
||||
m.mu.RLock()
|
||||
d := m.cmdDelay
|
||||
m.mu.RUnlock()
|
||||
if d > 0 {
|
||||
time.Sleep(d)
|
||||
}
|
||||
}
|
||||
|
||||
// update stores the new state and emits an event ONLY if something changed
|
||||
// that the UI cares about — avoids flooding the event bus 4x per second.
|
||||
func (m *Manager) update(ns RigState) {
|
||||
m.mu.Lock()
|
||||
changed := !stateUserEqual(m.state, ns)
|
||||
m.state = ns
|
||||
m.mu.Unlock()
|
||||
if changed {
|
||||
m.emitState()
|
||||
}
|
||||
}
|
||||
|
||||
func (m *Manager) emitState() {
|
||||
if m.emit == nil {
|
||||
return
|
||||
}
|
||||
m.emit(m.State())
|
||||
}
|
||||
|
||||
func stateUserEqual(a, b RigState) bool {
|
||||
return a.Enabled == b.Enabled &&
|
||||
a.Connected == b.Connected &&
|
||||
a.Backend == b.Backend &&
|
||||
a.RigNum == b.RigNum &&
|
||||
a.Rig == b.Rig &&
|
||||
a.FreqHz == b.FreqHz &&
|
||||
a.RxFreqHz == b.RxFreqHz &&
|
||||
a.Split == b.Split &&
|
||||
a.Mode == b.Mode &&
|
||||
a.Vfo == b.Vfo &&
|
||||
a.Band == b.Band &&
|
||||
a.Error == b.Error
|
||||
}
|
||||
|
||||
// BandFromHz returns the ADIF band tag covering the given frequency, or "".
|
||||
// Ranges follow IARU/ITU plans. 60m is treated as a single block for
|
||||
// simplicity — channelised access varies by region.
|
||||
func BandFromHz(hz int64) string {
|
||||
mhz := float64(hz) / 1_000_000
|
||||
switch {
|
||||
case mhz >= 1.8 && mhz <= 2.0:
|
||||
return "160m"
|
||||
case mhz >= 3.5 && mhz <= 4.0:
|
||||
return "80m"
|
||||
case mhz >= 5.3 && mhz <= 5.5:
|
||||
return "60m"
|
||||
case mhz >= 7.0 && mhz <= 7.3:
|
||||
return "40m"
|
||||
case mhz >= 10.1 && mhz <= 10.15:
|
||||
return "30m"
|
||||
case mhz >= 14.0 && mhz <= 14.35:
|
||||
return "20m"
|
||||
case mhz >= 18.068 && mhz <= 18.168:
|
||||
return "17m"
|
||||
case mhz >= 21.0 && mhz <= 21.45:
|
||||
return "15m"
|
||||
case mhz >= 24.89 && mhz <= 24.99:
|
||||
return "12m"
|
||||
case mhz >= 28.0 && mhz <= 29.7:
|
||||
return "10m"
|
||||
case mhz >= 50.0 && mhz <= 54.0:
|
||||
return "6m"
|
||||
case mhz >= 70.0 && mhz <= 70.5:
|
||||
return "4m"
|
||||
case mhz >= 144.0 && mhz <= 148.0:
|
||||
return "2m"
|
||||
case mhz >= 222.0 && mhz <= 225.0:
|
||||
return "1.25m"
|
||||
case mhz >= 420.0 && mhz <= 450.0:
|
||||
return "70cm"
|
||||
case mhz >= 902.0 && mhz <= 928.0:
|
||||
return "33cm"
|
||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||
return "23cm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// debugLog writes CAT debug events to %APPDATA%/HamLog/cat.log so users can
|
||||
// diagnose mode/freq mismatches without rebuilding with -windowsconsole.
|
||||
//
|
||||
// Initialised lazily on first use. Falls back to the standard library
|
||||
// default logger (stderr, usually invisible in a Wails GUI build) if the
|
||||
// log file can't be opened.
|
||||
var debugLog = openDebugLog()
|
||||
|
||||
func openDebugLog() *log.Logger {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return log.Default()
|
||||
}
|
||||
dir := filepath.Join(base, "HamLog")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return log.Default()
|
||||
}
|
||||
f, err := os.OpenFile(filepath.Join(dir, "cat.log"),
|
||||
os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0o644)
|
||||
if err != nil {
|
||||
return log.Default()
|
||||
}
|
||||
return log.New(f, "", log.LstdFlags|log.Lmicroseconds)
|
||||
}
|
||||
|
||||
// DebugLogPath returns the path the cat.log file would be opened at, for
|
||||
// surfacing in the UI / docs.
|
||||
func DebugLogPath() string {
|
||||
base, err := os.UserConfigDir()
|
||||
if err != nil {
|
||||
return ""
|
||||
}
|
||||
return filepath.Join(base, "HamLog", "cat.log")
|
||||
}
|
||||
@@ -0,0 +1,321 @@
|
||||
package cat
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/go-ole/go-ole"
|
||||
"github.com/go-ole/go-ole/oleutil"
|
||||
)
|
||||
|
||||
// OmniRig talks to the user's installed OmniRig server over COM.
|
||||
//
|
||||
// All methods MUST be called from the same OS thread (the one Manager.run
|
||||
// locks). COM is thread-affine on Windows — calling these from random
|
||||
// goroutines will return E_FAIL or crash.
|
||||
//
|
||||
// The user must install OmniRig separately and configure their rig (COM port,
|
||||
// baud rate) in OmniRig's own GUI. HamLog just reads/writes through it.
|
||||
type OmniRig struct {
|
||||
RigNum int // 1 (Rig1) or 2 (Rig2)
|
||||
|
||||
omnirig *ole.IDispatch
|
||||
rig *ole.IDispatch
|
||||
}
|
||||
|
||||
// NewOmniRig creates a non-connected backend. Call Connect before use.
|
||||
func NewOmniRig(rigNum int) *OmniRig {
|
||||
if rigNum < 1 || rigNum > 2 {
|
||||
rigNum = 1
|
||||
}
|
||||
return &OmniRig{RigNum: rigNum}
|
||||
}
|
||||
|
||||
func (o *OmniRig) Name() string { return "omnirig" }
|
||||
|
||||
func (o *OmniRig) Connect() error {
|
||||
debugLog.Printf("OmniRig.Connect Rig%d — log path: %s", o.RigNum, DebugLogPath())
|
||||
if err := ole.CoInitializeEx(0, ole.COINIT_APARTMENTTHREADED); err != nil {
|
||||
// 0x1 = S_FALSE → COM already initialised on this thread, fine.
|
||||
if oerr, ok := err.(*ole.OleError); !ok || oerr.Code() != 0x00000001 {
|
||||
return fmt.Errorf("CoInitializeEx: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
unk, err := oleutil.CreateObject("Omnirig.OmnirigX")
|
||||
if err != nil {
|
||||
return fmt.Errorf("Omnirig.OmnirigX not available — is OmniRig installed and running?: %w", err)
|
||||
}
|
||||
omnirig, err := unk.QueryInterface(ole.IID_IDispatch)
|
||||
unk.Release()
|
||||
if err != nil {
|
||||
return fmt.Errorf("query interface: %w", err)
|
||||
}
|
||||
|
||||
rigVar, err := oleutil.GetProperty(omnirig, fmt.Sprintf("Rig%d", o.RigNum))
|
||||
if err != nil {
|
||||
omnirig.Release()
|
||||
return fmt.Errorf("get Rig%d: %w", o.RigNum, err)
|
||||
}
|
||||
o.omnirig = omnirig
|
||||
o.rig = rigVar.ToIDispatch()
|
||||
|
||||
if rt, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||
debugLog.Printf("OmniRig connected to Rig%d type=%q", o.RigNum, rt.ToString())
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (o *OmniRig) Disconnect() {
|
||||
if o.rig != nil {
|
||||
o.rig.Release()
|
||||
o.rig = nil
|
||||
}
|
||||
if o.omnirig != nil {
|
||||
o.omnirig.Release()
|
||||
o.omnirig = nil
|
||||
}
|
||||
ole.CoUninitialize()
|
||||
}
|
||||
|
||||
func (o *OmniRig) ReadState() (RigState, error) {
|
||||
if o.rig == nil {
|
||||
return RigState{}, fmt.Errorf("not connected")
|
||||
}
|
||||
var s RigState
|
||||
s.Backend = o.Name()
|
||||
s.RigNum = o.RigNum
|
||||
|
||||
// Status: 0 = NOTCONFIGURED, 1 = DISABLED, 2 = PORTBUSY,
|
||||
// 3 = NOTRESPONDING, 4 = ONLINE.
|
||||
if statusVar, err := oleutil.GetProperty(o.rig, "Status"); err == nil {
|
||||
s.Connected = statusVar.Val == 4
|
||||
}
|
||||
|
||||
if rigTypeVar, err := oleutil.GetProperty(o.rig, "RigType"); err == nil {
|
||||
s.Rig = rigTypeVar.ToString()
|
||||
}
|
||||
|
||||
if !s.Connected {
|
||||
// Status string from OmniRig is informative for the user.
|
||||
if statusStrVar, err := oleutil.GetProperty(o.rig, "StatusStr"); err == nil {
|
||||
s.Error = statusStrVar.ToString()
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
if modeVar, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||
s.Mode = omniRigMode(modeVar.Val)
|
||||
}
|
||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||
s.Vfo = omniRigVfo(vfoVar.Val)
|
||||
}
|
||||
|
||||
// Read both VFO frequencies separately so we can expose split TX/RX.
|
||||
// Fall back to generic Freq if the rig only exposes the merged property.
|
||||
freqA, freqB := int64(0), int64(0)
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqA"); err == nil {
|
||||
freqA = v.Val
|
||||
}
|
||||
if v, err := oleutil.GetProperty(o.rig, "FreqB"); err == nil {
|
||||
freqB = v.Val
|
||||
}
|
||||
|
||||
// Split detection: trust the explicit Split property when it's set,
|
||||
// BUT only call it a real split if both VFO frequencies are non-zero
|
||||
// and distinct. Bridges like SmartSDR-OmniRig report Split=ON by
|
||||
// default (raw bit PM_SPLITON = 65536) with FreqB=0 because the Flex's
|
||||
// slice model doesn't map to VFO A/B — that would yield a useless
|
||||
// permanent SPLIT badge.
|
||||
if v, err := oleutil.GetProperty(o.rig, "Split"); err == nil && v.Val != 0 {
|
||||
s.Split = true
|
||||
}
|
||||
if s.Split && (freqB == 0 || freqA == freqB) {
|
||||
s.Split = false
|
||||
s.RxFreqHz = 0
|
||||
}
|
||||
|
||||
// OmniRig's Vfo enum is RX-letter then TX-letter (AB = RX A, TX B).
|
||||
// We follow ADIF: FreqHz = TX, RxFreqHz = RX (only meaningful in split).
|
||||
switch s.Vfo {
|
||||
case "AB":
|
||||
s.FreqHz = freqB // TX
|
||||
s.RxFreqHz = freqA // RX
|
||||
case "BA":
|
||||
s.FreqHz = freqA // TX
|
||||
s.RxFreqHz = freqB // RX
|
||||
case "B", "BB":
|
||||
s.FreqHz = freqB
|
||||
default: // "A", "AA", "" — single VFO on A or unknown
|
||||
s.FreqHz = freqA
|
||||
}
|
||||
if s.FreqHz == 0 {
|
||||
// Last resort — some rigs only update generic Freq.
|
||||
if v, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
s.FreqHz = v.Val
|
||||
}
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
func (o *OmniRig) SetFrequency(hz int64) error {
|
||||
if o.rig == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
// OmniRig Freq is a Long (int32). Validate to avoid silent truncation.
|
||||
if hz < 0 || hz > 0x7fffffff {
|
||||
return fmt.Errorf("frequency out of OmniRig int32 range")
|
||||
}
|
||||
hz32 := int32(hz)
|
||||
|
||||
// Pick the right OmniRig property. Many rig .ini files only define a
|
||||
// WRITE command for FreqA/FreqB but not the generic Freq — in which case
|
||||
// PutProperty(Freq) silently succeeds but the rig never moves. Write to
|
||||
// the active VFO's specific property when we know it; fall back to Freq.
|
||||
prop := "FreqA"
|
||||
if vfoVar, err := oleutil.GetProperty(o.rig, "Vfo"); err == nil {
|
||||
switch omniRigVfo(vfoVar.Val) {
|
||||
case "B", "BB", "BA":
|
||||
prop = "FreqB"
|
||||
case "A", "AA", "AB":
|
||||
prop = "FreqA"
|
||||
}
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetFrequency(%d Hz / %.6f MHz) → %s", hz, float64(hz)/1e6, prop)
|
||||
if _, err := oleutil.PutProperty(o.rig, prop, hz32); err != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency(%s) error: %v — falling back to Freq", prop, err)
|
||||
if _, err2 := oleutil.PutProperty(o.rig, "Freq", hz32); err2 != nil {
|
||||
debugLog.Printf("OmniRig.SetFrequency(Freq) also failed: %v", err2)
|
||||
return err2
|
||||
}
|
||||
}
|
||||
|
||||
// Read back the active VFO freq after a short delay so the log shows
|
||||
// whether the rig actually moved. Useful when the .ini accepts the write
|
||||
// silently but the rig doesn't honour it (wrong WRITE command etc.).
|
||||
if fv, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
debugLog.Printf("OmniRig.Freq immediately after Put = %d Hz", fv.Val)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// SetMode maps an ADIF mode to the OmniRig PM_* bit and pushes it to the rig.
|
||||
// For SSB, the USB/LSB side is chosen from the rig's current frequency
|
||||
// following worldwide convention (LSB below 14 MHz, USB above).
|
||||
//
|
||||
// IMPORTANT: OmniRig's Mode property is typed as Long (VT_I4). go-ole would
|
||||
// otherwise wrap a Go int64 into a VT_I8 variant which COM marshalling can
|
||||
// reject silently or misinterpret — passing the wrong bit. Always cast to
|
||||
// int32 explicitly.
|
||||
//
|
||||
// Logs each call to stdout so the user can cross-check what HamLog sent
|
||||
// against OmniRig's Monitor window (right-click systray → Monitor) to find
|
||||
// rig-specific mismatches (e.g. a Kenwood without FM on HF, an .ini with the
|
||||
// wrong CAT command for a mode, etc.).
|
||||
func (o *OmniRig) SetMode(mode string) error {
|
||||
if o.rig == nil {
|
||||
return fmt.Errorf("not connected")
|
||||
}
|
||||
var (
|
||||
bit int64
|
||||
bitName string
|
||||
)
|
||||
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
||||
case "CW":
|
||||
bit, bitName = pmCWU, "PM_CW_U"
|
||||
case "SSB":
|
||||
// Read current freq to decide USB vs LSB.
|
||||
var freq int64
|
||||
if freqVar, err := oleutil.GetProperty(o.rig, "Freq"); err == nil {
|
||||
freq = freqVar.Val
|
||||
}
|
||||
if freq > 0 && freq < 10_000_000 {
|
||||
bit, bitName = pmSSBL, "PM_SSB_L"
|
||||
} else {
|
||||
bit, bitName = pmSSBU, "PM_SSB_U"
|
||||
}
|
||||
case "AM":
|
||||
bit, bitName = pmAM, "PM_AM"
|
||||
case "FM":
|
||||
bit, bitName = pmFM, "PM_FM"
|
||||
case "RTTY", "FSK":
|
||||
// OmniRig has no specific RTTY/FSK mode — falls back to generic
|
||||
// digital USB. Many rigs need RTTY selected manually on the panel.
|
||||
bit, bitName = pmDIGU, "PM_DIG_U"
|
||||
case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DIGITALVOICE", "DATA":
|
||||
bit, bitName = pmDIGU, "PM_DIG_U"
|
||||
default:
|
||||
return fmt.Errorf("OmniRig: unsupported mode %q", mode)
|
||||
}
|
||||
debugLog.Printf("OmniRig.SetMode(%q) → %s = 0x%08X (%d)", mode, bitName, bit, bit)
|
||||
_, err := oleutil.PutProperty(o.rig, "Mode", int32(bit))
|
||||
if err != nil {
|
||||
debugLog.Printf("OmniRig.SetMode error: %v", err)
|
||||
return fmt.Errorf("SetMode(%s) → %s: %w", mode, bitName, err)
|
||||
}
|
||||
|
||||
// Read back what OmniRig now thinks the rig is on (best-effort —
|
||||
// OmniRig is async so this may still be the old value for one poll).
|
||||
if mv, err := oleutil.GetProperty(o.rig, "Mode"); err == nil {
|
||||
debugLog.Printf("OmniRig.Mode immediately after Put = 0x%08X (%d) → %s",
|
||||
mv.Val, mv.Val, omniRigMode(mv.Val))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ===== OmniRig enum decoders =====
|
||||
|
||||
// Bit flags from OmniRig type library (RigParamX enum in OmniRig_TLB.pas).
|
||||
//
|
||||
// Cross-checked against https://github.com/VE3NEA/OmniRig — be careful when
|
||||
// referencing other people's writeups online, several have these one bit
|
||||
// too low which causes every mode to map to the slot below it (AM → DIG_L,
|
||||
// FT8 → SSB_L, etc.).
|
||||
const (
|
||||
pmCWU int64 = 1 << 23 // 0x00800000
|
||||
pmCWL int64 = 1 << 24 // 0x01000000
|
||||
pmSSBU int64 = 1 << 25 // 0x02000000
|
||||
pmSSBL int64 = 1 << 26 // 0x04000000
|
||||
pmDIGU int64 = 1 << 27 // 0x08000000
|
||||
pmDIGL int64 = 1 << 28 // 0x10000000
|
||||
pmAM int64 = 1 << 29 // 0x20000000
|
||||
pmFM int64 = 1 << 30 // 0x40000000 — still fits in int32 (max 2^31-1)
|
||||
)
|
||||
|
||||
// omniRigMode maps the OmniRig Mode bit-flag to an ADIF mode string.
|
||||
// OmniRig only reports rough categories; specific digital modes
|
||||
// (FT8, RTTY, PSK31…) can't be inferred — DATA is returned and the user
|
||||
// can keep / override the mode they already had in the entry form.
|
||||
func omniRigMode(m int64) string {
|
||||
switch {
|
||||
case m&(pmCWU|pmCWL) != 0:
|
||||
return "CW"
|
||||
case m&(pmSSBU|pmSSBL) != 0:
|
||||
return "SSB"
|
||||
case m&(pmDIGU|pmDIGL) != 0:
|
||||
return "DATA"
|
||||
case m&pmAM != 0:
|
||||
return "AM"
|
||||
case m&pmFM != 0:
|
||||
return "FM"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func omniRigVfo(v int64) string {
|
||||
switch {
|
||||
case v&1024 != 0:
|
||||
return "A"
|
||||
case v&2048 != 0:
|
||||
return "B"
|
||||
case v&64 != 0:
|
||||
return "AA"
|
||||
case v&128 != 0:
|
||||
return "AB"
|
||||
case v&256 != 0:
|
||||
return "BA"
|
||||
case v&512 != 0:
|
||||
return "BB"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
Reference in New Issue
Block a user