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