//go:build windows package cat import ( "bufio" "fmt" "math" "net" "regexp" "sort" "strconv" "strings" "sync" "time" ) // Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP // API on port 4992 — a line-based text protocol — and tracks slice state pushed // by the radio in REAL TIME, so frequency/mode/split are always current (unlike // the polled, lagging OmniRig path that needed a second click to fix a mode). // Pure Go, no CGO, and no OmniRig install required for Flex users. type Flex struct { host string port int mu sync.Mutex conn net.Conn wmu sync.Mutex // serialises writes to conn seq int handle string model string gotHandle bool slices map[int]*flexSlice lastStateSig string // last logged derived-state signature (log only on change) spotsEnabled bool // push cluster spots + manage the panadapter overlay spotIdx map[int]bool // panadapter spot indices currently known to the radio pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click) // OnSpotClick is called (off the reader goroutine's hot path) when the user // clicks one of our spots on the panadapter, with the spot's callsign and // frequency. The host wires this to fill the entry form. Set before Connect. OnSpotClick func(callsign string, freqHz int64) } type flexSlice struct { freqHz int64 mode string // raw Flex mode (USB/LSB/CW/DIGU/…) active bool tx bool inUse bool } // flexTriggerRe matches the radio's "spot triggered" notification, sent // when the user clicks one of our spots on the panadapter. var flexTriggerRe = regexp.MustCompile(`spot (\d+) triggered`) // NewFlex builds a Flex backend for the given radio IP (host) and port (4992). // spotsEnabled turns on the panadapter spot overlay (subscribe + clear leftovers // on connect + accept SendSpot). func NewFlex(host string, port int, spotsEnabled bool) *Flex { if port == 0 { port = 4992 } return &Flex{ host: strings.TrimSpace(host), port: port, slices: map[int]*flexSlice{}, spotsEnabled: spotsEnabled, spotIdx: map[int]bool{}, pendingSpot: map[int]string{}, spotCall: map[int]string{}, } } func (f *Flex) Name() string { return "flex" } // Connect dials the radio and subscribes to slice/radio status. The reader // goroutine then keeps our cached state current from the radio's push messages. func (f *Flex) Connect() error { f.mu.Lock() already := f.conn != nil host := f.host port := f.port f.mu.Unlock() if already { return nil } if host == "" { return fmt.Errorf("flex: no radio IP configured") } conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second) if err != nil { return fmt.Errorf("flex: connect %s:%d: %w", host, port, err) } f.mu.Lock() f.conn = conn f.gotHandle = false f.slices = map[int]*flexSlice{} f.mu.Unlock() debugLog.Printf("Flex: connected to %s:%d", host, port) go f.reader(conn) // Identify ourselves in SmartSDR's client list, then stream slice + transmit // (TX/split) status. Command names per the SmartSDR TCP/IP API docs. f.send("client program=OpsLog") f.send("sub slice all") f.send("sub transmit all") f.send("sub radio all") if f.spotsEnabled { // Subscribe so the radio pushes existing spots (we learn their indices), // then wipe the panadapter so stale spots from a previous session or // another logger are cleared before we start adding our own. f.send("sub spot all") go f.clearSpotsOnConnect(conn) } return nil } func (f *Flex) Disconnect() { f.mu.Lock() c := f.conn f.conn = nil f.gotHandle = false f.mu.Unlock() if c != nil { _ = c.Close() debugLog.Printf("Flex: disconnected") } } // send writes a sequenced command (C|) to the radio and returns the // sequence number (so the caller can match the R response, e.g. to learn a // new spot's index). Returns 0 when not connected. Best effort. func (f *Flex) send(cmd string) int { f.mu.Lock() c := f.conn f.seq++ seq := f.seq f.mu.Unlock() if c == nil { return 0 } f.wmu.Lock() _, err := fmt.Fprintf(c, "C%d|%s\n", seq, cmd) f.wmu.Unlock() if err != nil { debugLog.Printf("Flex: send %q failed: %v", cmd, err) return 0 } debugLog.Printf("Flex: → %s", cmd) return seq } // reader consumes the radio's line stream until the connection drops. func (f *Flex) reader(conn net.Conn) { sc := bufio.NewScanner(conn) sc.Buffer(make([]byte, 0, 64*1024), 1<<20) for sc.Scan() { line := strings.TrimRight(sc.Text(), "\r\n") if line == "" { continue } // Panadapter spot click → "…spot triggered…". Resolve the index // back to the callsign we stored at spot-add time and notify the host. if mm := flexTriggerRe.FindStringSubmatch(line); mm != nil { if idx, err := strconv.Atoi(mm[1]); err == nil { f.mu.Lock() call := f.spotCall[idx] handler := f.OnSpotClick f.mu.Unlock() if call != "" && handler != nil { debugLog.Printf("Flex: spot %d triggered → %s", idx, call) go handler(call, 0) } } } switch line[0] { case 'V': // version banner, e.g. "V1.4.0.0" debugLog.Printf("Flex: radio %s", line) case 'H': // our client handle f.mu.Lock() f.handle = line[1:] f.gotHandle = true f.mu.Unlock() debugLog.Printf("Flex: handshake ok, handle=%s", line[1:]) case 'S': // status push: S| if i := strings.IndexByte(line, '|'); i >= 0 { f.handleStatus(line[i+1:]) } case 'M': // message debugLog.Printf("Flex: msg %s", line) case 'R': // command response: R|| parts := strings.SplitN(line[1:], "|", 3) if len(parts) < 2 { break } seq, _ := strconv.Atoi(parts[0]) ok := parts[1] == "0" || parts[1] == "00000000" if !ok { debugLog.Printf("Flex: cmd error %s", line) } // A successful "spot add" returns the new spot's index in the message; // pair it with the callsign we stashed under this seq. f.mu.Lock() call, pending := f.pendingSpot[seq] if pending { delete(f.pendingSpot, seq) } if pending && ok && len(parts) >= 3 { if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil { f.spotCall[idx] = call f.spotIdx[idx] = true } } f.mu.Unlock() } } // Connection ended. f.mu.Lock() if f.conn == conn { f.conn = nil f.gotHandle = false } f.mu.Unlock() } // handleStatus parses one status payload, e.g. // "slice 0 in_use=1 RF_frequency=14.150000 mode=USB active=1 tx=1 …" func (f *Flex) handleStatus(payload string) { fields := strings.Fields(payload) if len(fields) < 2 || fields[0] != "slice" { // radio … model=FLEX-6400 — grab the model when present. if len(fields) >= 1 && fields[0] == "radio" { for _, kv := range fields[1:] { if strings.HasPrefix(kv, "model=") { f.mu.Lock() f.model = strings.TrimPrefix(kv, "model=") f.mu.Unlock() } } } if len(fields) >= 1 && fields[0] == "transmit" { debugLog.Printf("Flex: status %s", payload) } // Spot status: "spot …". Track the index so we can clear the // panadapter, and log it verbatim — a click on a panadapter spot pushes a // spot status, which we'll use to fill the callsign once we see its shape. if len(fields) >= 2 && fields[0] == "spot" { // The click ("spot N triggered") is handled in the reader; here we // just keep the set of live spot indices for ClearSpots. if idx, err := strconv.Atoi(fields[1]); err == nil { removed := false for _, kv := range fields[2:] { if kv == "removed" || kv == "in_use=0" { removed = true } } f.mu.Lock() if removed { delete(f.spotIdx, idx) delete(f.spotCall, idx) } else { f.spotIdx[idx] = true } f.mu.Unlock() } debugLog.Printf("Flex: status %s", payload) } return } // Slice status — log it so split/freq/mode issues are diagnosable. debugLog.Printf("Flex: status %s", payload) idx, err := strconv.Atoi(fields[1]) if err != nil { return } f.mu.Lock() s := f.slices[idx] if s == nil { s = &flexSlice{} f.slices[idx] = s } for _, kv := range fields[2:] { eq := strings.IndexByte(kv, '=') if eq <= 0 { continue } key, val := kv[:eq], kv[eq+1:] switch key { case "RF_frequency": if mhz, e := strconv.ParseFloat(val, 64); e == nil { s.freqHz = int64(math.Round(mhz * 1e6)) } case "mode": s.mode = val case "active": s.active = val == "1" case "tx": s.tx = val == "1" case "in_use": s.inUse = val == "1" } } f.mu.Unlock() } // ReadState returns the cached state derived from the radio's push messages — // no round-trip, so it's always current. func (f *Flex) ReadState() (RigState, error) { f.mu.Lock() defer f.mu.Unlock() if f.conn == nil { return RigState{}, fmt.Errorf("flex: not connected") } st := RigState{Connected: f.gotHandle, Rig: f.model} if !f.gotHandle { return st, nil // connected TCP but radio hasn't handshaked yet } rx, tx := f.pickSlicesLocked() if rx == nil && tx == nil { return st, nil } if tx == nil { tx = rx } if rx == nil { rx = tx } st.FreqHz = tx.freqHz st.Mode = flexModeToADIF(tx.mode) if rx.freqHz != tx.freqHz { st.Split = true st.RxFreqHz = rx.freqHz } sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode) if sig != f.lastStateSig { f.lastStateSig = sig debugLog.Printf("Flex: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode) } return st, nil } // pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the // slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice // (preferring the active/focused one), NOT simply the active slice: tuning the // TX slice makes it the active/focused slice, which would otherwise collapse RX // onto TX and hide the split. Caller holds f.mu. func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) { idxs := make([]int, 0, len(f.slices)) for i, s := range f.slices { if s.inUse { idxs = append(idxs, i) } } sort.Ints(idxs) var active, txS, nonTx, first *flexSlice for _, i := range idxs { s := f.slices[i] if first == nil { first = s } if s.active { active = s } if s.tx { txS = s } else if nonTx == nil { nonTx = s } } tx = txS if tx == nil { if active != nil { tx = active } else { tx = first } } // RX = the receive slice: the active one if it isn't the TX slice, else the // first non-TX slice; fall back to TX (simplex) when there's only one slice. switch { case active != nil && active != tx: rx = active case nonTx != nil: rx = nonTx default: rx = tx } return rx, tx } // activeSliceIndexLocked returns the slice index to send commands to (the active // slice, else the lowest in-use index, else 0). Caller holds f.mu. func (f *Flex) activeSliceIndexLocked() int { best, found := 1<<30, false for idx, s := range f.slices { if !s.inUse { continue } if s.active { return idx } if idx < best { best, found = idx, true } } if found { return best } return 0 } func (f *Flex) SetFrequency(hz int64) error { if hz <= 0 { return fmt.Errorf("flex: invalid frequency") } f.mu.Lock() idx := f.activeSliceIndexLocked() connected := f.conn != nil f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } // "slice t " — tune command per the SmartSDR API (MHz, 6 dp). f.send(fmt.Sprintf("slice t %d %.6f", idx, float64(hz)/1e6)) return nil } func (f *Flex) SetMode(mode string) error { f.mu.Lock() idx := f.activeSliceIndexLocked() var freq int64 if s := f.slices[idx]; s != nil { freq = s.freqHz } connected := f.conn != nil f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } fm := adifModeToFlex(mode, freq) if fm == "" { return fmt.Errorf("flex: unsupported mode %q", mode) } // "slice s mode=" — set command per the SmartSDR API. f.send(fmt.Sprintf("slice s %d mode=%s", idx, fm)) return nil } // SendSpot renders a cluster spot on the panadapter via "spot add". Spots carry // a lifetime so the radio expires them on its own (the API has no "spot clear"). // Per the SmartSDR API, spaces inside a field value are encoded as 0x7F. func (f *Flex) SendSpot(s SpotInfo) error { f.mu.Lock() connected := f.conn != nil && f.gotHandle f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } call := flexEncode(s.Callsign) if call == "" || s.FreqHz <= 0 { return nil } color := s.Color if color == "" { color = "#FFFFA500" // opaque orange default } cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=1800 trigger_action=Tune timestamp=%d", float64(s.FreqHz)/1e6, call, color, time.Now().Unix()) if m := flexEncode(s.Mode); m != "" { cmd += " mode=" + m } if c := flexEncode(s.Comment); c != "" { cmd += " comment=" + c } seq := f.send(cmd) if seq > 0 { // Remember which call this add was for; the R response carries the // radio-assigned spot index, which we map to the call so a later click // (trigger) can be resolved back to the callsign. f.mu.Lock() f.pendingSpot[seq] = s.Callsign f.mu.Unlock() } return nil } // clearSpotsOnConnect waits until the radio handshake completes (we're truly // connected), then sends "spot clear" so launching OpsLog — or enabling the // option — starts from a clean panadapter, including spots left by another // logger or a previous session. func (f *Flex) clearSpotsOnConnect(conn net.Conn) { for i := 0; i < 50; i++ { // up to ~5s for the handshake f.mu.Lock() ready := f.gotHandle && f.conn == conn gone := f.conn != conn f.mu.Unlock() if gone { return // reconnected/closed in the meantime } if ready { f.ClearSpots() return } time.Sleep(100 * time.Millisecond) } } // ClearSpots wipes ALL panadapter spots in one command ("spot clear") — removes // stale spots from a previous session or another logger, not just our own. func (f *Flex) ClearSpots() error { f.mu.Lock() f.spotIdx = map[int]bool{} f.spotCall = map[int]string{} connected := f.conn != nil f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } f.send("spot clear") debugLog.Printf("Flex: spot clear sent") return nil } // flexEncode prepares a value for the Flex command line: trimmed, with any // internal spaces replaced by 0x7F as the SmartSDR API requires. func flexEncode(s string) string { s = strings.TrimSpace(s) if s == "" { return "" } return strings.ReplaceAll(s, " ", "\x7f") } func (f *Flex) SetPTT(on bool) error { f.mu.Lock() connected := f.conn != nil f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } if on { f.send("xmit 1") } else { f.send("xmit 0") } return nil } // flexModeToADIF maps a Flex slice mode to a generic ADIF mode. func flexModeToADIF(m string) string { switch strings.ToUpper(strings.TrimSpace(m)) { case "USB", "LSB": return "SSB" case "CW": return "CW" case "AM", "SAM": return "AM" case "FM", "NFM", "DFM": return "FM" case "DIGU", "DIGL": return "DATA" case "RTTY": return "RTTY" case "FDV": return "DIGITALVOICE" case "": return "" default: return strings.ToUpper(m) } } // adifModeToFlex maps an ADIF mode to a Flex slice mode. SSB picks USB/LSB from // the frequency (LSB below 10 MHz, USB above) — the standard convention. func adifModeToFlex(mode string, freqHz int64) string { switch strings.ToUpper(strings.TrimSpace(mode)) { case "SSB": if freqHz > 0 && freqHz < 10_000_000 { return "LSB" } return "USB" case "USB": return "USB" case "LSB": return "LSB" case "CW": return "CW" case "AM": return "AM" case "FM": return "FM" case "RTTY", "FSK": return "RTTY" case "FT8", "FT4", "PSK31", "MFSK", "JS8", "JT65", "JT9", "OLIVIA", "DATA", "DIGITALVOICE": return "DIGU" default: return "" } }