7ace2cc602
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>
323 lines
8.9 KiB
Go
323 lines
8.9 KiB
Go
// 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 ""
|
|
}
|