Files
OpsLog/internal/cat/cat.go
T

592 lines
18 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
// SetPTT keys (on=true) or unkeys the transmitter. Used by the Digital
// Voice Keyer to put the rig into TX while a message plays.
SetPTT(on bool) 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
startMu sync.Mutex // serializes Start/Stop so concurrent calls can't leak a poller
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) {
// Serialize the whole stop-old-then-start-new sequence. Two concurrent
// Start (or Start+Stop) calls could otherwise interleave and leave the
// previous poll goroutine alive — two pollers then fight, e.g. flipping
// OmniRig Rig1/Rig2 endlessly when the user reselects a rig.
m.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
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.startMu.Lock()
defer m.startMu.Unlock()
m.stopLocked()
m.mu.Lock()
m.state = RigState{Enabled: false}
m.mu.Unlock()
m.emitState()
}
// stopLocked tears down any running poller and blocks until it exits. The
// caller must hold startMu so it can't race a concurrent Start.
func (m *Manager) stopLocked() {
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
}
}
// 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) })
}
// SetPTT dispatches a transmit on/off request to the CAT goroutine.
func (m *Manager) SetPTT(on bool) error {
return m.exec(func(b Backend) error { return b.SetPTT(on) })
}
// SpotInfo is one cluster spot to render on a backend that supports a spot
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
// the backend picks a default when it's empty. (Status-based colouring can be
// driven later by setting Color per spot.)
type SpotInfo struct {
FreqHz int64
Callsign string
Mode string
Color string
Comment string
}
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
type Spotter interface {
SendSpot(SpotInfo) error
}
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
// missed spot on the panadapter is harmless.
func (m *Manager) SendSpot(s SpotInfo) {
m.mu.RLock()
cmds := m.cmdCh
b := m.backend
m.mu.RUnlock()
if cmds == nil || b == nil {
return
}
if _, ok := b.(Spotter); !ok {
return
}
select {
case cmds <- func() {
if sp, ok := b.(Spotter); ok {
_ = sp.SendSpot(s)
}
}:
default: // queue busy → drop this spot
}
}
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.)
type FlexTXState struct {
Available bool `json:"available"` // backend is Flex and handshaked
Model string `json:"model,omitempty"`
RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"`
Tune bool `json:"tune"` // tune carrier active
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
VoxEnable bool `json:"vox_enable"`
VoxLevel int `json:"vox_level"`
VoxDelay int `json:"vox_delay"`
ProcEnable bool `json:"proc_enable"`
ProcLevel int `json:"proc_level"`
Mon bool `json:"mon"`
MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"`
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"`
// CW / mode-specific controls.
Mode string `json:"mode,omitempty"` // active slice mode (CW/USB/LSB/DIGU…)
CWSpeed int `json:"cw_speed"`
CWPitch int `json:"cw_pitch"`
CWBreakInDelay int `json:"cw_break_in_delay"`
CWSidetone bool `json:"cw_sidetone"`
CWMonLevel int `json:"cw_mon_level"` // sidetone level
APF bool `json:"apf"`
APFLevel int `json:"apf_level"`
FilterLo int `json:"filter_lo"`
FilterHi int `json:"filter_hi"`
// External amplifier (PowerGenius XL).
AmpAvailable bool `json:"amp_available"`
AmpModel string `json:"amp_model,omitempty"`
AmpOperate bool `json:"amp_operate"`
AmpFault string `json:"amp_fault,omitempty"`
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
Meters []FlexMeter `json:"meters,omitempty"`
}
// FlexMeter is one live meter value (already scaled to real units).
type FlexMeter struct {
ID int `json:"id"`
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"`
Value float64 `json:"value"`
Lo float64 `json:"lo"`
Hi float64 `json:"hi"`
}
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
type FlexController interface {
FlexState() FlexTXState
SetRFPower(int) error
SetTunePower(int) error
SetTune(bool) error
SetVOX(bool) error
SetVOXLevel(int) error
SetVOXDelay(int) error
SetProcessor(bool) error
SetProcessorLevel(int) error
SetMon(bool) error
SetMonLevel(int) error
SetMic(int) error
ATUStart() error
ATUBypass() error
SetATUMemories(bool) error
// RX slice DSP controls (target the active receive slice).
SetAGCMode(string) error
SetAGCThreshold(int) error
SetAudioLevel(int) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetANFLevel(int) error
SetAPF(bool) error
SetAPFLevel(int) error
// CW keyer + mode-specific controls.
SetCWSpeed(int) error
SetCWPitch(int) error
SetCWBreakInDelay(int) error
SetCWSidetone(bool) error
SetSidetoneLevel(int) error
SetCWFilter(int) error
SetFilter(lo, hi int) error
// External amplifier (PowerGenius XL) operate/standby.
SetAmpOperate(bool) error
}
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
// the active backend isn't a Flex. Safe to call from any goroutine.
func (m *Manager) FlexState() (FlexTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if fc, ok := b.(FlexController); ok {
return fc.FlexState(), true
}
return FlexTXState{}, false
}
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
// active backend isn't a Flex.
func (m *Manager) FlexDo(fn func(FlexController) error) error {
return m.exec(func(b Backend) error {
fc, ok := b.(FlexController)
if !ok {
return fmt.Errorf("active CAT backend is not a FlexRadio")
}
return fn(fc)
})
}
// 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 {
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)
defer b.Disconnect()
// Connection is (re)established lazily and retried with a backoff, so a rig
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
// reconnects on its own instead of staying dead until the user toggles CAT.
const reconnectEvery = 5 * time.Second
connected := false
var lastAttempt time.Time
tryConnect := func() {
if connected || time.Since(lastAttempt) < reconnectEvery {
return
}
lastAttempt = time.Now()
if err := b.Connect(); err != nil {
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
return
}
connected = true
}
tryConnect()
ticker := time.NewTicker(pollEvery)
defer ticker.Stop()
for {
select {
case <-stop:
return
case fn := <-cmds:
fn()
m.applyCommandDelay()
case <-ticker.C:
if !connected {
tryConnect()
continue
}
ns, err := b.ReadState()
if err != nil {
// Lost the rig — drop the backend so the next attempt reconnects
// cleanly, then back off before retrying.
connected = false
lastAttempt = time.Now()
b.Disconnect()
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 ""
}