//go:build windows package cat import ( "bufio" "encoding/binary" "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 tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab) amp flexAmp // external amplifier (PowerGenius XL) state txSetAt map[string]time.Time // status field → when WE last set it (ignore the radio's lagging echo briefly) lastStateSig string // last logged derived-state signature (log only on change) boundClientID string // GUI client (SmartSDR) we bound to; "" until bound. Binding lets this non-GUI client receive GUI-tied data (CW pitch/speed, break-in delay, RF power). // Live meters streamed over UDP (VITA-49). meterMeta is the definitions // pushed over TCP; meterVal the latest scaled values keyed by meter id. udpConn *net.UDPConn meterMeta map[int]meterInfo meterVal map[int]float64 meterSub map[int]bool // ids we've already sent "sub meter " for meterLogAt time.Time // throttle for value logging vitaSeen int // count of UDP datagrams (first few logged for diag) meterRawLogged bool // log the first raw meter-definition status once txRawLogged bool // log the first raw transmit status once (field-name audit) 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) sentCmds map[int]string // seq → command text, so an R error names the command // 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 // RX DSP controls (SmartSDR slice object). agcMode string // off | slow | med | fast agcThreshold int // 0-100 audioLevel int // 0-100 (RX volume) nb bool // noise blanker nbLevel int nr bool // noise reduction nrLevel int anf bool // auto notch filter anfLevel int apf bool // CW audio peaking filter apfLevel int filterLo int // slice filter low cut (Hz) filterHi int // slice filter high cut (Hz) } // flexTX mirrors the radio's transmit/ATU/interlock objects (the SmartSDR-style // controls). Populated from status pushes in handleStatus; read by FlexState(). type flexTX struct { rfPower int tunePower int tune bool transmitting bool // interlock state == TRANSMITTING voxEnable bool voxLevel int voxDelay int procEnable bool procLevel int mon bool monLevel int micLevel int atuStatus string atuMemories bool // CW keyer params (set via the top-level "cw" commands). cwSpeed int // WPM cwPitch int // Hz cwBreakInDelay int // ms (QSK delay) cwSidetone bool // sidetone (audible monitor) enable cwMonLevel int // sidetone level (mon_gain_cw) } // flexAmp mirrors the external amplifier object (PowerGenius XL). handle is the // hex id used to address SET commands; operate=true means OPERATE (vs STANDBY). type flexAmp struct { handle string model string operate bool fault string } // meterInfo is a meter definition pushed by the radio over TCP. unit drives the // raw-int16 → real-value scaling; src/name identify what it measures. type meterInfo struct { src string // SLC (slice), TX-, COD, RAD, AMP… name string // FWDPWR, SWR, LEVEL, PATEMP, +13.8B… unit string // dbm, dbfs, swr, volts, degc, watts… lo float64 hi float64 } // 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{}, meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{}, sentCmds: map[int]string{}, txSetAt: map[string]time.Time{}, } } 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.meterVal = map[int]float64{} f.meterSub = map[int]bool{} f.boundClientID = "" // re-bind to the GUI client on each (re)connect 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") // slice/receiver: freq, mode, AGC, NB/NR/ANF, audio… f.send("sub tx all") // transmit: rfpower, tunepower, vox, processor, mon, mic f.send("sub atu all") // antenna-tuner status + memories f.send("sub amplifier all") // external amplifier (PowerGenius XL) operate/standby f.send("sub radio all") // radio-wide incl. interlock (TX/RX state) f.send("sub cwx all") // CWX: the LIVE CW speed/pitch/break-in (transmit holds only a static default) f.send("sub client all") // learn the GUI client (SmartSDR) so we can bind to it (below) f.startMeters(conn) // open the UDP VITA-49 stream for live meters 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 uc := f.udpConn f.conn = nil f.udpConn = nil f.gotHandle = false f.mu.Unlock() if uc != nil { _ = uc.Close() // unblocks udpReader } 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 if f.sentCmds != nil { f.sentCmds[seq] = cmd } 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" f.mu.Lock() cmdText := f.sentCmds[seq] delete(f.sentCmds, seq) f.mu.Unlock() if !ok { debugLog.Printf("Flex: cmd error R%d code=%s cmd=%q", seq, parts[1], cmdText) } // 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() } } } // Transmit object — RF/tune power, VOX, speech processor, monitor, mic, // tune carrier. Field names per the SmartSDR API (logged so the exact set // is auditable against a real radio). if len(fields) >= 1 && fields[0] == "transmit" { if !f.txRawLogged { f.txRawLogged = true debugLog.Printf("Flex: FIRST transmit status: %s", payload) } debugLog.Printf("Flex: status %s", payload) f.mu.Lock() for _, kv := range fields[1:] { key, val, ok := splitKV(kv) if !ok { continue } // Ignore the radio's echo of a field we just set ourselves (it // often re-pushes the OLD value for ~1s, which snapped the slider // back). Our optimistic value stands until the guard expires. if t, ok := f.txSetAt[key]; ok && time.Since(t) < 1200*time.Millisecond { continue } switch key { case "rfpower": f.tx.rfPower = atoiDefault(val, f.tx.rfPower) case "tunepower": f.tx.tunePower = atoiDefault(val, f.tx.tunePower) case "tune": f.tx.tune = val == "1" case "vox_enable": f.tx.voxEnable = val == "1" case "vox_level": f.tx.voxLevel = atoiDefault(val, f.tx.voxLevel) case "vox_delay": f.tx.voxDelay = atoiDefault(val, f.tx.voxDelay) case "speech_processor_enable": f.tx.procEnable = val == "1" case "speech_processor_level": f.tx.procLevel = atoiDefault(val, f.tx.procLevel) case "mon", "sb_monitor": f.tx.mon = val == "1" case "mon_gain_sb": f.tx.monLevel = atoiDefault(val, f.tx.monLevel) case "mon_gain_cw": f.tx.cwMonLevel = atoiDefault(val, f.tx.cwMonLevel) case "sidetone", "cw_sidetone": f.tx.cwSidetone = val == "1" // Once bound to the GUI client (see the client branch) the transmit // object carries the GUI client's LIVE CW values, so read them here // (and from cwx). Before binding these are the radio's static // defaults — that was the "always 600 / 5" bug. case "speed", "cwl_speed", "cw_speed", "wpm", "cw_wpm": f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed) case "pitch", "cwl_pitch", "cw_pitch": f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch) case "break_in_delay", "cwl_delay", "cw_break_in_delay", "delay": f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay) case "mic_level", "miclevel": f.tx.micLevel = atoiDefault(val, f.tx.micLevel) } } f.mu.Unlock() } // Client object — list of connected clients. GUI clients (SmartSDR / // Maestro) carry a client_id; non-GUI clients don't. We bind to the GUI // client so the radio routes GUI-tied data (CW pitch/speed, break-in // delay, RF power) to us. Logged so the exact field names are confirmable. if len(fields) >= 1 && fields[0] == "client" { debugLog.Printf("Flex: status %s", payload) var clientID, program string disconnected := false for _, kv := range fields[1:] { if kv == "disconnected" { disconnected = true continue } key, val, ok := splitKV(kv) if !ok { continue } switch key { case "client_id": clientID = val case "program": program = val } } f.mu.Lock() alreadyBound := f.boundClientID != "" f.mu.Unlock() lp := strings.ToLower(program) isGUI := program == "" || strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro") if !disconnected && clientID != "" && !alreadyBound && isGUI { f.mu.Lock() f.boundClientID = clientID f.mu.Unlock() f.send("client bind client_id=" + clientID) debugLog.Printf("Flex: bound to GUI client %s (program=%q)", clientID, program) } } // CWX object — the LIVE CW keyer values (speed/pitch/break-in delay). // SmartSDR reads these here; the transmit object only carries a static // default. Logged in full so we can confirm the exact field names. if len(fields) >= 1 && fields[0] == "cwx" { debugLog.Printf("Flex: status %s", payload) f.mu.Lock() for _, kv := range fields[1:] { key, val, ok := splitKV(kv) if !ok { continue } switch key { case "wpm", "speed", "cw_speed": f.tx.cwSpeed = atoiDefault(val, f.tx.cwSpeed) case "pitch", "cw_pitch": f.tx.cwPitch = atoiDefault(val, f.tx.cwPitch) case "delay", "break_in_delay", "cw_break_in_delay": f.tx.cwBreakInDelay = atoiDefault(val, f.tx.cwBreakInDelay) } } f.mu.Unlock() } // ATU object — auto-tuner status + memories. if len(fields) >= 1 && fields[0] == "atu" { debugLog.Printf("Flex: status %s", payload) f.mu.Lock() for _, kv := range fields[1:] { key, val, ok := splitKV(kv) if !ok { continue } switch key { case "status": f.tx.atuStatus = val case "memories_enabled": f.tx.atuMemories = val == "1" } } f.mu.Unlock() } // Interlock object — transmit state (RECEIVE / TRANSMITTING / …). if len(fields) >= 1 && fields[0] == "interlock" { f.mu.Lock() for _, kv := range fields[1:] { if key, val, ok := splitKV(kv); ok && key == "state" { f.tx.transmitting = strings.EqualFold(val, "TRANSMITTING") } } f.mu.Unlock() } // Amplifier object — "amplifier model=… operate=… …" (PowerGenius // XL). The handle (hex) addresses the operate/standby SET command. if len(fields) >= 2 && fields[0] == "amplifier" { debugLog.Printf("Flex: status %s", payload) f.mu.Lock() if strings.HasPrefix(fields[1], "0x") { f.amp.handle = fields[1] } removed := false for _, kv := range fields[2:] { if kv == "removed" || kv == "in_use=0" { removed = true continue } key, val, ok := splitKV(kv) if !ok { continue } switch key { case "handle": f.amp.handle = val case "model": f.amp.model = val case "operate": f.amp.operate = val == "1" || strings.EqualFold(val, "OPERATE") case "mode": f.amp.operate = strings.EqualFold(val, "OPERATE") case "state": // The PowerGenius XL reports its live state here (the status // push has no operate= field). Anything but STANDBY/OFF means // the amp is IN LINE (OPERATE) — IDLE = operate, not keyed. switch strings.ToUpper(val) { case "STANDBY", "OFF", "POWERED_OFF", "DISCONNECTED": f.amp.operate = false case "OPERATE", "IDLE", "TRANSMIT", "TX", "RECEIVE", "RX", "KEYED", "OPERATING": f.amp.operate = true } case "fault": f.amp.fault = val } } if removed { f.amp = flexAmp{} } f.mu.Unlock() } // Meter definitions — "meter .src=… .nam=… .unit=… …". // The unit scales the UDP values, the name labels them; subscribe to each // new id so the radio streams it. if len(fields) >= 2 && fields[0] == "meter" { if !f.meterRawLogged { f.meterRawLogged = true debugLog.Printf("Flex: meter status raw: %s", payload) } var newIDs []int f.mu.Lock() for _, tok := range fields[1:] { // One meter per token; its fields are '#'-separated: // ".src=…#.num=…#.nam=…#.low=…#.hi=…#.unit=…". num := -1 var mi meterInfo for _, sub := range strings.Split(tok, "#") { key, val, ok := splitKV(sub) if !ok { continue } dot := strings.IndexByte(key, '.') if dot <= 0 { continue } n, err := strconv.Atoi(key[:dot]) if err != nil { continue } num = n switch key[dot+1:] { case "src": mi.src = val case "nam": mi.name = val case "unit", "units": mi.unit = val case "low", "lo": mi.lo = parseFloatDefault(val, mi.lo) case "hi": mi.hi = parseFloatDefault(val, mi.hi) } } if num < 0 { continue } old, seen := f.meterMeta[num] if !seen { newIDs = append(newIDs, num) } if mi.src != "" { old.src = mi.src } if mi.name != "" { old.name = mi.name } if mi.unit != "" { old.unit = mi.unit } if mi.lo != 0 { old.lo = mi.lo } if mi.hi != 0 { old.hi = mi.hi } f.meterMeta[num] = old } f.mu.Unlock() for _, id := range newIDs { mi := f.meterMeta[id] debugLog.Printf("Flex: meter def #%d %s/%s unit=%s → sub", id, mi.src, mi.name, mi.unit) f.subscribeMeter(id) } } // 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" case "agc_mode": s.agcMode = val case "agc_threshold": s.agcThreshold = atoiDefault(val, s.agcThreshold) case "audio_level": s.audioLevel = atoiDefault(val, s.audioLevel) case "nb": s.nb = val == "1" case "nb_level": s.nbLevel = atoiDefault(val, s.nbLevel) case "nr": s.nr = val == "1" case "nr_level": s.nrLevel = atoiDefault(val, s.nrLevel) case "anf": s.anf = val == "1" case "anf_level": s.anfLevel = atoiDefault(val, s.anfLevel) case "apf": s.apf = val == "1" case "apf_level": s.apfLevel = atoiDefault(val, s.apfLevel) case "filter_lo": s.filterLo = atoiDefault(val, s.filterLo) case "filter_hi": s.filterHi = atoiDefault(val, s.filterHi) } } f.mu.Unlock() } // defInt returns v, or def when v is zero (so sliders show sane defaults before // the radio has pushed the real value). func defInt(v, def int) int { if v == 0 { return def } return v } // 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 } // splitKV splits a "key=value" token. ok is false when there's no '='. func splitKV(kv string) (key, val string, ok bool) { eq := strings.IndexByte(kv, '=') if eq <= 0 { return "", "", false } return kv[:eq], kv[eq+1:], true } // atoiDefault parses an int (or a float like "20.0", truncated), else def. func atoiDefault(s string, def int) int { s = strings.TrimSpace(s) if n, err := strconv.Atoi(s); err == nil { return n } if fl, err := strconv.ParseFloat(s, 64); err == nil { return int(fl) } return def } func clampLevel(v int) int { if v < 0 { return 0 } if v > 100 { return 100 } return v } // rxSliceLocked returns the active RX slice and its index (-1 when none), using // the same RX-selection rule as ReadState. Caller holds f.mu. func (f *Flex) rxSliceLocked() (int, *flexSlice) { rx, _ := f.pickSlicesLocked() if rx == nil { return -1, nil } for i, s := range f.slices { if s == rx { return i, rx } } return -1, rx } // FlexState returns a snapshot of the radio's transmit/ATU state plus the active // RX slice's DSP controls, for the FlexRadio control tab. Available is true once // the handshake has completed. func (f *Flex) FlexState() FlexTXState { f.mu.Lock() defer f.mu.Unlock() st := FlexTXState{ Available: f.gotHandle && f.conn != nil, Model: f.model, RFPower: f.tx.rfPower, TunePower: f.tx.tunePower, Tune: f.tx.tune, Transmitting: f.tx.transmitting, VoxEnable: f.tx.voxEnable, VoxLevel: f.tx.voxLevel, VoxDelay: f.tx.voxDelay, ProcEnable: f.tx.procEnable, ProcLevel: f.tx.procLevel, Mon: f.tx.mon, MonLevel: f.tx.monLevel, MicLevel: f.tx.micLevel, ATUStatus: f.tx.atuStatus, ATUMemories: f.tx.atuMemories, // CW keyer (defaults applied so the sliders show sane values pre-read). CWSpeed: defInt(f.tx.cwSpeed, 25), CWPitch: defInt(f.tx.cwPitch, 600), CWBreakInDelay: defInt(f.tx.cwBreakInDelay, 30), CWSidetone: f.tx.cwSidetone, CWMonLevel: f.tx.cwMonLevel, } if _, rx := f.rxSliceLocked(); rx != nil { st.RXAvail = true st.Mode = strings.ToUpper(rx.mode) st.AGCMode = rx.agcMode st.AGCThreshold = rx.agcThreshold st.AudioLevel = rx.audioLevel st.NB = rx.nb st.NBLevel = rx.nbLevel st.NR = rx.nr st.NRLevel = rx.nrLevel st.ANF = rx.anf st.ANFLevel = rx.anfLevel st.APF = rx.apf st.APFLevel = rx.apfLevel st.FilterLo = rx.filterLo st.FilterHi = rx.filterHi } if f.amp.handle != "" { st.AmpAvailable = true st.AmpModel = f.amp.model st.AmpOperate = f.amp.operate st.AmpFault = f.amp.fault } if len(f.meterVal) > 0 { ids := make([]int, 0, len(f.meterVal)) for id := range f.meterVal { ids = append(ids, id) } sort.Ints(ids) // stable order so the UI doesn't reshuffle each poll for _, id := range ids { mi := f.meterMeta[id] st.Meters = append(st.Meters, FlexMeter{ID: id, Src: mi.src, Name: mi.name, Unit: mi.unit, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi}) } } return st } // sendSlice sends a "slice s =" to the active RX slice, and // optimistically updates our cached slice state — the radio doesn't reliably // echo every field back to the client that changed it (e.g. agc_mode), so // without this the UI would snap back to the stale value. func (f *Flex) sendSlice(param string, val any) error { f.mu.Lock() idx, rx := f.rxSliceLocked() connected := f.conn != nil if rx != nil { switch param { case "agc_mode": rx.agcMode = fmt.Sprint(val) case "agc_threshold": rx.agcThreshold = toInt(val) case "audio_level": rx.audioLevel = toInt(val) case "nb": rx.nb = val == "1" case "nb_level": rx.nbLevel = toInt(val) case "nr": rx.nr = val == "1" case "nr_level": rx.nrLevel = toInt(val) case "anf": rx.anf = val == "1" case "anf_level": rx.anfLevel = toInt(val) case "apf": rx.apf = val == "1" case "apf_level": rx.apfLevel = toInt(val) } } f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } if rx == nil || idx < 0 { return fmt.Errorf("flex: no receive slice") } f.send(fmt.Sprintf("slice s %d %s=%v", idx, param, val)) return nil } // toInt coerces an int or numeric string to int (for the optimistic cache). func toInt(v any) int { switch t := v.(type) { case int: return t case string: return atoiDefault(t, 0) } return 0 } func (f *Flex) SetAGCMode(m string) error { switch m { case "off", "slow", "med", "fast": default: return fmt.Errorf("flex: invalid agc mode %q", m) } return f.sendSlice("agc_mode", m) } func (f *Flex) SetAGCThreshold(l int) error { return f.sendSlice("agc_threshold", clampLevel(l)) } func (f *Flex) SetAudioLevel(l int) error { return f.sendSlice("audio_level", clampLevel(l)) } func (f *Flex) SetNB(on bool) error { return f.sendSlice("nb", boolFlex(on)) } func (f *Flex) SetNBLevel(l int) error { return f.sendSlice("nb_level", clampLevel(l)) } func (f *Flex) SetNR(on bool) error { return f.sendSlice("nr", boolFlex(on)) } func (f *Flex) SetNRLevel(l int) error { return f.sendSlice("nr_level", clampLevel(l)) } func (f *Flex) SetANF(on bool) error { return f.sendSlice("anf", boolFlex(on)) } func (f *Flex) SetANFLevel(l int) error { return f.sendSlice("anf_level", clampLevel(l)) } func (f *Flex) SetAPF(on bool) error { return f.sendSlice("apf", boolFlex(on)) } func (f *Flex) SetAPFLevel(l int) error { return f.sendSlice("apf_level", clampLevel(l)) } // ── CW keyer controls (top-level "cw" commands) ── func (f *Flex) SetCWSpeed(wpm int) error { if wpm < 5 { wpm = 5 } else if wpm > 100 { wpm = 100 } f.mu.Lock() f.tx.cwSpeed = wpm f.mu.Unlock() f.send(fmt.Sprintf("cw wpm %d", wpm)) return nil } func (f *Flex) SetCWPitch(hz int) error { if hz < 100 { hz = 100 } else if hz > 6000 { hz = 6000 } f.mu.Lock() f.tx.cwPitch = hz f.mu.Unlock() f.send(fmt.Sprintf("cw pitch %d", hz)) return nil } func (f *Flex) SetCWBreakInDelay(ms int) error { if ms < 0 { ms = 0 } else if ms > 2000 { ms = 2000 } f.mu.Lock() f.tx.cwBreakInDelay = ms f.mu.Unlock() f.send(fmt.Sprintf("cw break_in_delay %d", ms)) return nil } func (f *Flex) SetCWSidetone(on bool) error { f.mu.Lock() f.tx.cwSidetone = on f.mu.Unlock() f.send("cw sidetone " + boolWord(on)) return nil } // SetSidetoneLevel sets the CW sidetone (audible monitor) gain via mon_gain_cw. func (f *Flex) SetSidetoneLevel(l int) error { l = clampLevel(l) return f.txSet(fmt.Sprintf("transmit set mon_gain_cw=%d", l), "mon_gain_cw", func(t *flexTX) { t.cwMonLevel = l }) } // SetCWFilter changes the CW passband WIDTH to bw Hz while keeping the current // filter CENTER fixed — so the frequency never shifts. The new low/high cuts are // center ± bw/2, where center is the midpoint of the slice's current filter // (falling back to the CW pitch only if the filter isn't known yet). func (f *Flex) SetCWFilter(bw int) error { if bw < 50 { bw = 50 } f.mu.Lock() idx, rx := f.rxSliceLocked() connected := f.conn != nil center := 0 if rx != nil && (rx.filterLo != 0 || rx.filterHi != 0) { center = (rx.filterLo + rx.filterHi) / 2 } else { center = defInt(f.tx.cwPitch, 600) } lo := center - bw/2 hi := center + bw/2 if rx != nil { rx.filterLo, rx.filterHi = lo, hi } f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } if rx == nil || idx < 0 { return fmt.Errorf("flex: no receive slice") } f.send(fmt.Sprintf("filt %d %d %d", idx, lo, hi)) return nil } // boolWord renders a Flex on/off boolean as the word form some commands want. func boolWord(on bool) string { if on { return "on" } return "off" } // connected reports whether the TCP link is up (commands are no-ops otherwise). func (f *Flex) connected() bool { f.mu.Lock() defer f.mu.Unlock() return f.conn != nil } // --- FlexController controls (SmartSDR transmit object). --- // // txSet sends a command AND optimistically updates our cached transmit state. // The radio doesn't reliably echo a changed field back to the client that set // it, so without the optimistic update the UI would snap back to the stale // cached value (a real echo, e.g. a change from SmartSDR, still overrides it). // txSet sends a command, optimistically updates our cached transmit state, and // records `field` (the STATUS field name) so the radio's lagging echo of the old // value is ignored for a moment (see handleStatus) — otherwise the slider snaps // back. `field` may be "" for non-guarded commands. func (f *Flex) txSet(cmd, field string, apply func(*flexTX)) error { f.mu.Lock() connected := f.conn != nil if connected && apply != nil { apply(&f.tx) if field != "" { f.txSetAt[field] = time.Now() } } f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } f.send(cmd) return nil } func (f *Flex) SetRFPower(p int) error { p = clampLevel(p) return f.txSet(fmt.Sprintf("transmit set rfpower=%d", p), "rfpower", func(t *flexTX) { t.rfPower = p }) } func (f *Flex) SetTunePower(p int) error { p = clampLevel(p) return f.txSet(fmt.Sprintf("transmit set tunepower=%d", p), "tunepower", func(t *flexTX) { t.tunePower = p }) } func (f *Flex) SetTune(on bool) error { cmd := "transmit tune off" if on { cmd = "transmit tune on" } return f.txSet(cmd, "tune", func(t *flexTX) { t.tune = on }) } func (f *Flex) SetVOX(on bool) error { return f.txSet("transmit set vox_enable="+boolFlex(on), "vox_enable", func(t *flexTX) { t.voxEnable = on }) } func (f *Flex) SetVOXLevel(l int) error { l = clampLevel(l) return f.txSet(fmt.Sprintf("transmit set vox_level=%d", l), "vox_level", func(t *flexTX) { t.voxLevel = l }) } // SetVOXDelay sets the VOX hang time (0-100, a percentage scale in SmartSDR). func (f *Flex) SetVOXDelay(l int) error { l = clampLevel(l) return f.txSet(fmt.Sprintf("transmit set vox_delay=%d", l), "vox_delay", func(t *flexTX) { t.voxDelay = l }) } func (f *Flex) SetProcessor(on bool) error { return f.txSet("transmit set speech_processor_enable="+boolFlex(on), "speech_processor_enable", func(t *flexTX) { t.procEnable = on }) } // SetProcessorLevel sets the speech-processor preset: 0=NOR, 1=DX, 2=DX+ (NOT a // 0-100 level — per the SmartSDR transmit API). func (f *Flex) SetProcessorLevel(l int) error { if l < 0 { l = 0 } if l > 2 { l = 2 } return f.txSet(fmt.Sprintf("transmit set speech_processor_level=%d", l), "speech_processor_level", func(t *flexTX) { t.procLevel = l }) } func (f *Flex) SetMon(on bool) error { return f.txSet("transmit set mon="+boolFlex(on), "mon", func(t *flexTX) { t.mon = on }) } func (f *Flex) SetMonLevel(l int) error { l = clampLevel(l) return f.txSet(fmt.Sprintf("transmit set mon_gain_sb=%d", l), "mon_gain_sb", func(t *flexTX) { t.monLevel = l }) } // SetMic sets the mic gain. The SET token is "miclevel" (one word) even though // the radio reports it back as "mic_level" in the transmit status. func (f *Flex) SetMic(l int) error { l = clampLevel(l) return f.txSet(fmt.Sprintf("transmit set miclevel=%d", l), "mic_level", func(t *flexTX) { t.micLevel = l }) } func (f *Flex) ATUStart() error { if !f.connected() { return fmt.Errorf("flex: not connected") } f.send("atu start") return nil } func (f *Flex) ATUBypass() error { if !f.connected() { return fmt.Errorf("flex: not connected") } f.send("atu bypass") return nil } func (f *Flex) SetATUMemories(on bool) error { return f.txSet("atu set memories_enabled="+boolFlex(on), "", func(t *flexTX) { t.atuMemories = on }) } // SetAmpOperate switches the external amplifier between OPERATE (on=true) and // STANDBY. Needs the amplifier handle learned from its status push. func (f *Flex) SetAmpOperate(on bool) error { f.mu.Lock() handle := f.amp.handle connected := f.conn != nil if handle != "" { f.amp.operate = on // optimistic (radio may not echo to us) } f.mu.Unlock() if !connected { return fmt.Errorf("flex: not connected") } if handle == "" { return fmt.Errorf("flex: no amplifier detected") } f.send(fmt.Sprintf("amplifier set %s operate=%s", handle, boolFlex(on))) return nil } func boolFlex(b bool) string { if b { return "1" } return "0" } // --- Live meters over UDP (VITA-49) --- // flexMeterClass is the VITA-49 packet class code FlexRadio uses for meter // extension packets. The payload is 32-bit words: upper 16 bits = meter id, // lower 16 bits = signed value (scaled per the meter's unit). const flexMeterClass = 0x8002 // startMeters opens a UDP socket for the radio's VITA-49 realtime stream (sent // from the radio's :4991), tells the radio which local port to stream to, and // starts the reader. The socket is DIALED to radio:4991 and we send a "punch" // datagram + periodic keepalives so Windows Firewall accepts the inbound stream // (an unsolicited inbound UDP to our ephemeral port would otherwise be dropped). func (f *Flex) startMeters(conn net.Conn) { raddr, err := net.ResolveUDPAddr("udp4", net.JoinHostPort(f.host, "4991")) if err != nil { debugLog.Printf("Flex: meters resolve %s:4991: %v", f.host, err) return } // Unconnected socket: accept the stream from ANY source (the radio's source // port can change across NAT), while we still punch/keepalive toward :4991. uc, err := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0}) if err != nil { debugLog.Printf("Flex: meters UDP listen failed: %v", err) return } port := uc.LocalAddr().(*net.UDPAddr).Port f.mu.Lock() f.udpConn = uc f.vitaSeen = 0 f.mu.Unlock() f.send(fmt.Sprintf("client udpport %d", port)) // route VITA-49 to our port f.send("sub meter all") // stream all meter values _, _ = uc.WriteToUDP([]byte{0}, raddr) // firewall/NAT punch debugLog.Printf("Flex: meters UDP local=:%d punch→%s", port, raddr) go f.udpReader(uc) go f.udpKeepalive(uc, raddr) } func (f *Flex) udpReader(uc *net.UDPConn) { buf := make([]byte, 16*1024) for { n, src, err := uc.ReadFromUDP(buf) if err != nil { return // socket closed on disconnect } f.mu.Lock() f.vitaSeen++ seen := f.vitaSeen f.mu.Unlock() if seen <= 3 { debugLog.Printf("Flex: UDP datagram #%d %d bytes from %s", seen, n, src) } f.parseVita(buf[:n], seen) } } // udpKeepalive keeps the firewall/NAT mapping open by pinging the radio's :4991. func (f *Flex) udpKeepalive(uc *net.UDPConn, raddr *net.UDPAddr) { t := time.NewTicker(10 * time.Second) defer t.Stop() for range t.C { f.mu.Lock() cur := f.udpConn f.mu.Unlock() if cur != uc { return } if _, err := uc.WriteToUDP([]byte{0}, raddr); err != nil { return } } } // parseVita decodes a VITA-49 datagram and, if it's a meter packet, updates the // cached meter values. Header flags are honoured so the payload offset is right. func (f *Flex) parseVita(p []byte, seen int) { if len(p) < 4 { return } w0 := binary.BigEndian.Uint32(p[0:4]) off := 4 pktType := (w0 >> 28) & 0xF hasClass := (w0>>27)&0x1 == 1 tsi := (w0 >> 22) & 0x3 tsf := (w0 >> 20) & 0x3 if pktType == 0x1 || pktType == 0x3 { // packet types carrying a Stream ID off += 4 } var packetClass uint16 if hasClass { if off+8 > len(p) { return } packetClass = uint16(binary.BigEndian.Uint32(p[off+4 : off+8])) off += 8 } if tsi != 0 { off += 4 } if tsf != 0 { off += 8 } // Diagnostics: log the first few datagrams's parsed header so we can confirm // the class code (in case 0x8002 / offsets differ on a real radio). if seen <= 3 { debugLog.Printf("Flex: VITA #%d len=%d type=%d class=0x%04x off=%d", seen, len(p), pktType, packetClass, off) } if packetClass != flexMeterClass || off > len(p) { return } payload := p[off:] f.mu.Lock() for i := 0; i+4 <= len(payload); i += 4 { id := int(binary.BigEndian.Uint16(payload[i : i+2])) raw := int16(binary.BigEndian.Uint16(payload[i+2 : i+4])) f.meterVal[id] = scaleMeter(raw, f.meterMeta[id].unit) } if time.Since(f.meterLogAt) > 5*time.Second { // throttled dump to validate names f.meterLogAt = time.Now() var b strings.Builder for id, v := range f.meterVal { mi := f.meterMeta[id] fmt.Fprintf(&b, "%s=%.1f%s ", nonEmpty(mi.name, strconv.Itoa(id)), v, mi.unit) } debugLog.Printf("Flex: meters %s", strings.TrimSpace(b.String())) } f.mu.Unlock() } // scaleMeter converts the raw int16 to its real value per the meter's unit. func scaleMeter(raw int16, unit string) float64 { switch strings.ToUpper(unit) { case "DB", "DBM", "DBFS": return float64(raw) / 128.0 case "VOLTS", "AMPS": return float64(raw) / 256.0 case "DEGC", "DEGF", "TEMPC", "TEMPF": return float64(raw) / 64.0 case "SWR": return float64(raw) / 128.0 // raw 128 = SWR 1.0 at idle default: return float64(raw) } } // subscribeMeter asks the radio to stream a meter's values (once per id). func (f *Flex) subscribeMeter(id int) { f.mu.Lock() if f.meterSub[id] || f.conn == nil { f.mu.Unlock() return } f.meterSub[id] = true f.mu.Unlock() f.send(fmt.Sprintf("sub meter %d", id)) } func nonEmpty(s, def string) string { if s == "" { return def } return s } func parseFloatDefault(s string, def float64) float64 { if v, err := strconv.ParseFloat(strings.TrimSpace(s), 64); err == nil { return v } return def } // 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 "" } }