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 ""
|
||||
}
|
||||
Reference in New Issue
Block a user