Files
OpsLog/internal/cat/cat.go
T
rouggy 7ace2cc602 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>
2026-05-26 00:16:45 +02:00

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 ""
}