// 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 } // interruptible is an OPTIONAL backend capability: abort an in-progress Connect // quickly. The network Icom backend's Connect blocks for up to tens of seconds // (UDP handshake + login + waiting for the rig to boot from standby); without a // way to interrupt it, Stop()/Start() would freeze on the poll goroutine until // the dial gives up — which is why Settings "Save & Close" hung for ~1 min once // the link was lost. Backends that don't implement it are simply not interrupted. type interruptible interface { Interrupt() } // 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 b := m.backend m.stopCh = nil m.doneCh = nil m.cmdCh = nil m.backend = nil m.mu.Unlock() if stop != nil { close(stop) } // Abort any in-progress Connect so we don't block on a slow network dial // (the poll goroutine can be tens of seconds deep in the Icom UDP handshake). if iv, ok := b.(interruptible); ok { iv.Interrupt() } 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 LifetimeSec int // panadapter display seconds before auto-removal (0 = backend default) } // 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.) // FlexSliceInfo identifies one FlexRadio receiver slice (A/B/C/D…) for the // panel, so the operator sees every slice and which one is active/TX. type FlexSliceInfo struct { Index int `json:"index"` // 0-based slice index Letter string `json:"letter"` // A, B, C, D… FreqHz int64 `json:"freq_hz"` Mode string `json:"mode,omitempty"` // ADIF mode Band string `json:"band,omitempty"` Active bool `json:"active"` // the focused/operating slice TX bool `json:"tx"` // this slice transmits } type FlexTXState struct { Available bool `json:"available"` // backend is Flex and handshaked Model string `json:"model,omitempty"` // Slices lists every in-use receiver slice (A/B/C/D…) so the panel can show // them all and highlight the active one. The active slice drives everything. Slices []FlexSliceInfo `json:"slices,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"` TXFilterLow int `json:"tx_filter_low"` // TX filter low cut (Hz) TXFilterHigh int `json:"tx_filter_high"` // TX filter high cut (Hz) // Mic profiles (SmartSDR): the list of available profiles + the loaded one. MicProfile string `json:"mic_profile,omitempty"` MicProfiles []string `json:"mic_profiles,omitempty"` 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 Split bool `json:"split"` // RX/TX on separate slices RXFreqHz int64 `json:"rx_freq_hz,omitempty"` // RX slice freq when split TXFreqHz int64 `json:"tx_freq_hz,omitempty"` // TX slice freq when split AGCMode string `json:"agc_mode,omitempty"` AGCThreshold int `json:"agc_threshold"` AudioLevel int `json:"audio_level"` Mute bool `json:"mute"` RXAnt string `json:"rx_ant,omitempty"` // selected RX antenna TXAnt string `json:"tx_ant,omitempty"` // selected TX antenna AntList []string `json:"ant_list,omitempty"` // antennas selectable for RX TXAntList []string `json:"tx_ant_list,omitempty"` // antennas selectable for TX 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"` WNB bool `json:"wnb"` WNBLevel int `json:"wnb_level"` // RIT/XIT — offsets applied to the active slice's RX / TX frequency without // moving the slice. The offset survives the switch being turned off, so // re-enabling restores it, exactly like the radio's own knob. RIT bool `json:"rit"` RITFreq int `json:"rit_freq"` XIT bool `json:"xit"` XITFreq int `json:"xit_freq"` // 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"` Slice int `json:"slice"` // for SLC meters, the slice index it belongs to; -1 otherwise 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 SetTXInhibit(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 SetTXFilter(low, high int) error // transmit-audio bandwidth (Hz) SetMicProfile(string) error // load a SmartSDR mic profile by name 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 SetMute(bool) error SetRXAntenna(string) error SetTXAntenna(string) error SetActiveSlice(int) error // focus slice idx so commands target it SetTXSlice(int) error // make slice idx the transmitter (tx=1) SetSplit(bool) 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 SetWNB(bool) error SetWNBLevel(int) error SetRIT(bool) error SetRITFreq(int) error SetXIT(bool) error SetXITFreq(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"` // Transmit + live status (polled). Transmitting bool `json:"transmitting"` Split bool `json:"split"` SMeter int `json:"s_meter"` // 0-100 (raw 0-255; S9≈120) PowerMeter int `json:"power_meter"` // 0-100 (TX Po) SWRMeter int `json:"swr_meter"` // 0-100 (TX SWR) // RIT / ΔTX (XIT). RITHz int `json:"rit_hz"` // RIT/XIT offset, signed Hz RITOn bool `json:"rit_on"` XITOn bool `json:"xit_on"` // CW keyer (send messages via the rig's internal keyer, CI-V 0x17). KeySpeedWPM int `json:"key_speed_wpm"` // current KEY SPEED in WPM BreakIn int `json:"break_in"` // CW break-in: 0=OFF, 1=SEMI, 2=FULL // Set controls. RFPower int `json:"rf_power"` // 0-100 (TX output) MicGain int `json:"mic_gain"` // 0-100 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"` APF bool `json:"apf"` // audio peak filter (CW only) 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) // Antenna (IC-7610 = ANT1/ANT2). Antenna int `json:"antenna"` // 1 | 2 (0 = unknown) // Filter fine controls: Twin PBT + manual notch (0-100, 50 = centre). PBTInner int `json:"pbt_inner"` PBTOuter int `json:"pbt_outer"` ManualNotch bool `json:"manual_notch"` NotchPos int `json:"notch_pos"` // TX extras. Squelch int `json:"squelch"` Comp bool `json:"comp"` CompLevel int `json:"comp_level"` Monitor bool `json:"monitor"` MonLevel int `json:"mon_level"` VOX bool `json:"vox"` VOXGain int `json:"vox_gain"` AntiVOX int `json:"anti_vox"` } // 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 SetAPF(bool) error SetAGC(string) error SetPreamp(int) error SetAtt(int) error SetIcomFilter(int) error SetRFPower(int) error SetMicGain(int) error SetIcomSplit(bool) error TuneATU() error SetScope(bool) error // enable/disable the spectrum-scope waveform stream SetScopeMode(bool) error // true = fixed span, false = center-on-VFO SetScopeEdges(int64, int64) error // point the fixed scope at low..high Hz (centre/pan) ScopeData() ScopeSweep // latest assembled sweep (empty until enabled) SetRIT(int) error // RIT/ΔTX offset in signed Hz SetRITOn(bool) error // RIT on/off SetXITOn(bool) error // ΔTX (XIT) on/off SendCW(string) error // key a CW message via the rig's keyer (CI-V 0x17) StopCW() error // abort the CW message being sent SetKeySpeed(int) error // CW keyer speed in WPM SetBreakIn(int) error // CW break-in: 0=OFF, 1=SEMI, 2=FULL SetAntenna(int) error // 1 = ANT1, 2 = ANT2 SetPBTInner(int) error // Twin PBT inside (0-100, 50 = centre) SetPBTOuter(int) error // Twin PBT outside (0-100, 50 = centre) SetManualNotch(bool) error SetNotchPos(int) error // manual-notch position (0-100, 50 = centre) SetSquelch(int) error SetComp(bool) error SetCompLevel(int) error SetMonitor(bool) error SetMonLevel(int) error SetVOX(bool) error SetVOXGain(int) error SetAntiVOX(int) error SetPower(bool) error // turn the transceiver on/off (manual — never auto on connect) } // ScopeSweep is one complete spectrum-scope sweep reassembled from the Icom's // divided 0x27 waveform frames. Amp holds one amplitude byte per pixel (raw rig // scale, typically 0-160). Seq increments on every completed sweep so the UI can // tell fresh data from a repeated poll. type ScopeSweep struct { Amp []int `json:"amp"` // []int (not []byte) so it marshals as a JSON number array Seq int `json:"seq"` LowHz int64 `json:"low_hz"` // left edge frequency (0 when unknown) HighHz int64 `json:"high_hz"` // right edge frequency (0 when unknown) Fixed bool `json:"fixed"` // true = fixed-span mode, false = center-on-VFO } // 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 } // IcomScope returns the latest spectrum-scope sweep, or (zero, false) when the // active backend isn't an Icom. The sweep is mutex-guarded in the backend, so // this reads it directly (no CAT-goroutine round trip) — cheap enough to poll. func (m *Manager) IcomScope() (ScopeSweep, bool) { m.mu.RLock() b := m.backend m.mu.RUnlock() if ic, ok := b.(IcomController); ok { return ic.ScopeData(), true } return ScopeSweep{}, 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: // Drain any queued commands before polling. A serial backend reads // many registers per ReadState, so without this the shared select's // fairness lets polls repeatedly win and a user's Set* can lag by // seconds. Servicing commands first bounds that latency to a single // ReadState. for { select { case fn := <-cmds: fn() m.applyCommandDelay() continue default: } break } 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 "" }