Files
OpsLog/internal/cat/flex.go
T
rouggy 961357474d feat: Flex stays on the RX slice in split
SmartSDR moves its active flag onto the TX slice as soon as the transmit
frequency is touched, and OpsLog followed it. But the operator is listening to
the DX on the other slice: the S-meter, audio level, filter and DSP they are
adjusting all belong there, so following the transmitter handed them the
controls of a receiver they were not using.

In split the receive slice now wins. Simplex is unchanged, and two same-band
slices in different classes (SSB alongside FT8) are still not a split.

A slice clicked IN OPSLOG is remembered and overrides both the split rule and
the radio's focus — that is the operator speaking. Activating a slice on the
radio or in SmartSDR deliberately does not move OpsLog. The pin is dropped when
that slice stops being in use, so a closed receiver cannot strand the display.

The panel now highlights the slice OpsLog is working with rather than the radio's
focused one; reporting the radio's flag would light up one slice while every
control acted on another.
2026-07-30 23:03:29 +02:00

2346 lines
74 KiB
Go

//go:build windows
package cat
import (
"bufio"
"context"
"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
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop); nil when not dialing
wmu sync.Mutex // serialises writes to conn
seq int
handle string
model string
gotHandle bool
slices map[int]*flexSlice
// pinnedSlice is the slice the operator chose IN OPSLOG (-1 = none). It
// overrides the radio's own "active" flag, and only an OpsLog click changes
// it — activating a slice on the radio's front panel or in SmartSDR does
// not, by design (see mainSliceLocked).
pinnedSlice int
tx flexTX // transmit/ATU state pushed by the radio (FlexRadio tab)
amp flexAmp // external amplifier (PowerGenius XL) state
micProfiles []string // available mic profiles (SmartSDR "profile mic list")
micProfile string // currently loaded mic profile
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 <id>" for
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
pendingSplit map[int]bool // seq → awaiting the new TX slice's index (split create)
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
spotByCall map[string]int // callsign → live spot index, so re-spotting a call replaces its old spot (WSJT decodes re-fire every cycle)
sentCmds map[int]string // seq → command text, so an R<seq> 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)
mute bool // RX audio muted
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
wnb bool // wideband noise blanker
wnbLevel int
// SmartSDR v4 DSP (8000/Aurora series). Key names per the FlexLib slice
// docs: lms_nr(NRL) / lms_anf(ANFL) / speex_nr(NRS) / rnnoise(RNN) /
// anft(ANFT) / nrf(NRF). Older radios never report them — dspV4 flips true
// the first time any of these keys appears, and gates the extra UI rows.
lmsNR bool // NRL — legacy LMS noise reduction
lmsNRLevel int
lmsANF bool // ANFL — legacy LMS auto-notch
lmsANFLevel int
speexNR bool // NRS — spectral subtraction NR
speexNRLevel int
rnnoise bool // RNN — AI noise reduction (on/off only)
anft bool // ANFT — FFT-based auto-notch (on/off only)
nrf bool // NRF — noise reduction w/ filter
nrfLevel int
dspV4 bool
daxCh int // DAX audio channel for this slice (0 = off, 1-8)
rit bool // receive incremental tuning enabled
ritFreq int // RIT offset in Hz (negative = down)
xit bool // transmit incremental tuning enabled
xitFreq int // XIT offset in Hz
filterLo int // slice filter low cut (Hz)
filterHi int // slice filter high cut (Hz)
rxAnt string // selected RX antenna (e.g. ANT1, ANT2, RX_A)
txAnt string // selected TX antenna
antList []string // antennas valid for this slice (RX side)
txAntList []string // antennas valid for TX
}
// 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
inhibit bool // transmit inhibited (e.g. while a motorized antenna moves)
voxEnable bool
voxLevel int
voxDelay int
procEnable bool
procLevel int
mon bool
monLevel int
micLevel int
filterLow int // TX filter low cut (Hz)
filterHigh int // TX filter high cut (Hz)
dax bool // TX audio comes from DAX (SmartSDR's transmit-bar DAX button)
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…
slc int // for src=SLC meters, the slice index (the ".num" field); -1 otherwise
lo float64
hi float64
}
// flexTriggerRe matches the radio's "spot <index> 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{}, spotByCall: map[string]int{}, pendingSplit: map[int]bool{},
meterMeta: map[int]meterInfo{}, meterVal: map[int]float64{}, meterSub: map[int]bool{},
sentCmds: map[int]string{}, txSetAt: map[string]time.Time{},
pinnedSlice: -1,
}
}
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")
}
// Cancellable dial: Interrupt() (called by Stop/Start) aborts it at once so a
// dead radio's 5 s dial timeout doesn't make Stop / Settings "Save & Close"
// wait several seconds for the poll goroutine to give up.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
f.mu.Lock()
f.dialCancel = cancel
f.mu.Unlock()
var d net.Dialer
conn, err := d.DialContext(ctx, "tcp", net.JoinHostPort(host, strconv.Itoa(port)))
cancel()
f.mu.Lock()
f.dialCancel = nil
f.mu.Unlock()
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 profile all") // mic/global/tx profiles (for the mic-profile dropdown)
f.send("profile mic info") // request the current mic profile list + selection
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")
}
}
// Interrupt aborts an in-flight Connect dial so Stop()/Start() (Settings
// "Save & Close", CAT backend switch) don't block on a dead radio's 5 s dial
// timeout. Satisfies the Manager's optional interruptible interface. Safe to call
// anytime and from another goroutine; a no-op when not dialing.
func (f *Flex) Interrupt() {
f.mu.Lock()
cancel := f.dialCancel
c := f.conn
f.mu.Unlock()
if cancel != nil {
cancel()
}
if c != nil {
_ = c.Close() // unblock the reader if we're already past the dial
}
}
// send writes a sequenced command (C<seq>|<cmd>) to the radio and returns the
// sequence number (so the caller can match the R<seq> 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 <index> 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<handle>|<object ...>
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<seq>|<hex>|<message>
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.spotByCall[strings.ToUpper(call)] = idx
}
}
// A successful "slice create" for split returns the new slice's index;
// make that slice the transmitter so RX/TX are on separate slices.
splitSeq := f.pendingSplit[seq]
if splitSeq {
delete(f.pendingSplit, seq)
}
f.mu.Unlock()
if splitSeq && ok && len(parts) >= 3 {
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
f.send(fmt.Sprintf("slice s %d tx=1", idx))
}
}
}
}
// 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).
// "transmit band <N> band_name=… rfpower=…" lines are PER-BAND power
// presets, not the current TX state — ignore them, otherwise the last
// band's rfpower (e.g. 630m=100) clobbers the real current value.
if len(fields) >= 1 && fields[0] == "transmit" && !(len(fields) >= 2 && fields[1] == "band") {
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 "dax":
f.tx.dax = 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)
// TX filter: the transmit STATUS reports the passband as lo/hi (the
// SET command is filter_low/filter_high — a SmartSDR quirk).
case "lo", "filter_low":
f.tx.filterLow = atoiDefault(val, f.tx.filterLow)
case "hi", "filter_high":
f.tx.filterHigh = atoiDefault(val, f.tx.filterHigh)
}
}
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)
// The real GUI client is SmartSDR (Windows) or Maestro. Its non-GUI
// helpers "SmartSDR CAT" and "SmartSDR DAX" also carry "smartsdr" in the
// name, so exclude them — and require an explicit GUI name (dropping the
// old program=="" fallback that could match CAT before its program field
// arrived). Binding to CAT/DAX is invalid and the radio was seen to drop
// SmartSDR CAT (connect/disconnect loop) when a logger did this.
isGUI := (strings.Contains(lp, "smartsdr") || strings.Contains(lp, "maestro")) &&
!strings.Contains(lp, "cat") && !strings.Contains(lp, "dax")
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()
}
// Mic-profile object — "profile mic list=A^B^C" (available profiles) and
// "profile mic current=<name>" (loaded one). Names can contain spaces, so
// values are taken from the raw payload after the key. Logged once so the
// exact field names are confirmable on real hardware.
if len(fields) >= 2 && fields[0] == "profile" && fields[1] == "mic" {
debugLog.Printf("Flex: profile status: %s", payload)
if i := strings.Index(payload, "list="); i >= 0 {
var profs []string
for _, p := range strings.Split(payload[i+len("list="):], "^") {
if p = strings.TrimSpace(p); p != "" {
profs = append(profs, p)
}
}
f.mu.Lock()
f.micProfiles = profs
f.mu.Unlock()
}
// The loaded profile arrives as current=<name> (some firmwares:
// selection=<name>); accept either.
for _, key := range []string{"current=", "selection="} {
if i := strings.Index(payload, key); i >= 0 {
cur := strings.TrimSpace(payload[i+len(key):])
f.mu.Lock()
f.micProfile = cur
f.mu.Unlock()
break
}
}
}
// 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 <handle> 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{}
// The amp's meters (FWD / ID / TEMP …) don't always get an individual
// "meter removed" push, so power-cycling the amp left the OLD meter
// ids in the maps while the re-registered amp added NEW ones — the
// panel then showed every amp meter twice. Drop every AMP-sourced
// meter here so only the fresh set survives.
for id, mi := range f.meterMeta {
if strings.Contains(strings.ToUpper(mi.src), "AMP") {
delete(f.meterMeta, id)
delete(f.meterVal, id)
delete(f.meterSub, id)
}
}
}
f.mu.Unlock()
}
// Meter definitions — "meter <num>.src=… <num>.nam=… <num>.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)
}
// Meter removal — "meter <id> removed": drop it so a stale value can't
// linger (e.g. after an amp/slice is torn down).
removedMeter := false
for _, tok := range fields[1:] {
if tok == "removed" || tok == "in_use=0" {
removedMeter = true
}
}
if removedMeter {
f.mu.Lock()
for _, tok := range fields[1:] {
if n, err := strconv.Atoi(strings.TrimSpace(tok)); err == nil {
delete(f.meterMeta, n)
delete(f.meterVal, n)
delete(f.meterSub, n)
}
}
f.mu.Unlock()
return
}
var newIDs []int
f.mu.Lock()
for _, tok := range fields[1:] {
// One meter per token; its fields are '#'-separated:
// "<n>.src=…#<n>.num=…#<n>.nam=…#<n>.low=…#<n>.hi=…#<n>.unit=…".
num := -1
mi := meterInfo{slc: -1}
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 "num":
// For a slice (SLC) meter this field is the slice index —
// how we tell slice A's S-meter from slice B's.
if v, e := strconv.Atoi(strings.TrimSpace(val)); e == nil {
mi.slc = v
}
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)
old.slc = -1
}
if mi.src != "" {
old.src = mi.src
}
if mi.name != "" {
old.name = mi.name
}
if mi.unit != "" {
old.unit = mi.unit
}
if mi.slc >= 0 {
old.slc = mi.slc
}
if mi.lo != 0 {
old.lo = mi.lo
}
if mi.hi != 0 {
old.hi = mi.hi
}
f.meterMeta[num] = old
}
f.mu.Unlock()
// One line for the whole batch, not one per meter: a Flex announces
// dozens at connect and that was a wall of text every time.
var names []string
for _, id := range newIDs {
mi := f.meterMeta[id]
names = append(names, nonEmpty(mi.name, strconv.Itoa(id)))
f.subscribeMeter(id)
}
if len(names) > 0 {
debugLog.Printf("Flex: subscribed to %d meter(s): %s", len(names), strings.Join(names, " "))
}
}
// Spot status: "spot <index> …". 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 "audio_mute", "mute":
s.mute = val == "1"
case "rxant":
s.rxAnt = val
case "txant":
s.txAnt = val
case "ant_list":
s.antList = splitCSV(val)
case "tx_ant_list":
s.txAntList = splitCSV(val)
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 "wnb":
s.wnb = val == "1"
case "wnb_level":
s.wnbLevel = atoiDefault(val, s.wnbLevel)
case "lms_nr":
s.lmsNR, s.dspV4 = val == "1", true
case "lms_nr_level":
s.lmsNRLevel, s.dspV4 = atoiDefault(val, s.lmsNRLevel), true
case "lms_anf":
s.lmsANF, s.dspV4 = val == "1", true
case "lms_anf_level":
s.lmsANFLevel, s.dspV4 = atoiDefault(val, s.lmsANFLevel), true
case "speex_nr":
s.speexNR, s.dspV4 = val == "1", true
case "speex_nr_level":
s.speexNRLevel, s.dspV4 = atoiDefault(val, s.speexNRLevel), true
case "rnnoise":
s.rnnoise, s.dspV4 = val == "1", true
case "anft":
s.anft, s.dspV4 = val == "1", true
case "nrf":
s.nrf, s.dspV4 = val == "1", true
case "nrf_level":
s.nrfLevel, s.dspV4 = atoiDefault(val, s.nrfLevel), true
case "dax":
s.daxCh = atoiDefault(val, s.daxCh)
case "rit_on":
s.rit = val == "1"
case "rit_freq":
s.ritFreq = atoiDefault(val, s.ritFreq)
case "xit_on":
s.xit = val == "1"
case "xit_freq":
s.xitFreq = atoiDefault(val, s.xitFreq)
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
}
main, rxS, txSplit := f.operatingLocked()
if main == nil {
return st, nil
}
// Main frequency/mode = the ACTIVE slice (what the operator is on). Only a
// genuine same-band split adds a separate TX freq; then ADIF convention wants
// FreqHz = TX and RxFreqHz = RX.
st.FreqHz = main.freqHz
st.Mode = flexModeToADIF(main.mode)
if rxS != nil && txSplit != nil {
st.Split = true
st.RxFreqHz = rxS.freqHz
st.FreqHz = txSplit.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
}
// mainSliceLocked is the operator's slice: the ACTIVE (focused) in-use slice, or
// the lowest-indexed in-use slice when none is flagged active. EVERYTHING the
// user does — freq/mode display, RX DSP, tuning, mode changes, spot clicks —
// follows this slice, so a second independent slice (e.g. monitoring another
// band) never hijacks the main frequency. Returns (-1, nil) when no slice is in
// use. Caller holds f.mu.
func (f *Flex) mainSliceLocked() (int, *flexSlice) {
// Iterate in ASCENDING index order — NEVER map-iteration order, which Go
// randomises. When two slices transiently BOTH report active=1 (e.g. an
// external controller like DXHunter activates a slice on another band while
// ours still holds active, before SmartSDR sends active=0 to the old one),
// map order returned a RANDOM active slice each call → the operating frequency
// flip-flopped 40m/20m every poll and the Ultrabeam motors chased it forever.
// Deterministic order = the lowest-indexed active slice wins, stably.
// An explicit choice made in OpsLog wins over everything, including the
// radio's active flag. It is dropped only when that slice stops being in
// use — a slice the operator closed is not a choice any more.
if f.pinnedSlice >= 0 {
if s := f.slices[f.pinnedSlice]; s != nil && s.inUse {
return f.pinnedSlice, s
}
}
firstInUse := -1
chosen := -1
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if !s.inUse {
continue
}
if firstInUse < 0 {
firstInUse = idx
}
if s.active {
chosen = idx
break
}
}
if chosen < 0 {
chosen = firstInUse
}
if chosen < 0 {
return -1, nil
}
// In SPLIT, stay on the RECEIVE slice.
//
// SmartSDR moves its "active" flag to the TX slice as soon as the transmit
// frequency is touched, so OpsLog followed the transmitter. But the operator
// is LISTENING to the DX on the other slice: the S-meter, the audio level,
// the filter and the DSP they are adjusting all belong there, and following
// the TX slice hands them the controls for a receiver they are not using.
//
// Only in split, and only when nothing was pinned above — clicking slice B
// in OpsLog still selects it and keeps it.
if txS := f.txSliceLocked(); txS != nil && f.slices[chosen] == txS {
if rxIdx, rxS := f.splitPartnerLocked(txS); rxS != nil {
return rxIdx, rxS
}
}
return chosen, f.slices[chosen]
}
// splitPartnerLocked returns the slice that FORMS A SPLIT with txS: in use, on
// the same band, at a different frequency, in the same class (phone with phone,
// CW with CW — so SSB alongside FT8 on one band is not a split).
//
// Lowest index first, so the answer is stable; map order is randomised in Go
// and gave a different partner on each poll. Caller holds f.mu.
func (f *Flex) splitPartnerLocked(txS *flexSlice) (int, *flexSlice) {
if txS == nil {
return -1, nil
}
bt := BandFromHz(txS.freqHz)
ct := flexSplitClass(txS.mode)
if bt == "" || ct == "" {
return -1, nil
}
for _, idx := range f.sortedSliceIdxLocked() {
s := f.slices[idx]
if s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct {
return idx, s
}
}
return -1, nil
}
// sortedSliceIdxLocked returns the slice indices in ascending order so every
// slice-selection helper is deterministic (map iteration is randomised). Caller
// holds f.mu.
func (f *Flex) sortedSliceIdxLocked() []int {
idxs := make([]int, 0, len(f.slices))
for idx := range f.slices {
idxs = append(idxs, idx)
}
sort.Ints(idxs)
return idxs
}
// activeSliceIndexLocked returns the slice index to send commands to (the main
// slice, else 0). Caller holds f.mu.
func (f *Flex) activeSliceIndexLocked() int {
if idx, _ := f.mainSliceLocked(); idx >= 0 {
return idx
}
return 0
}
// sliceLetter maps a slice index to its SmartSDR letter (0→A, 1→B, …).
func sliceLetter(idx int) string {
if idx < 0 || idx > 25 {
return fmt.Sprintf("%d", idx)
}
return string(rune('A' + idx))
}
// txSliceLocked returns the slice flagged as the transmitter (tx=1), or nil.
// Caller holds f.mu.
func (f *Flex) txSliceLocked() *flexSlice {
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; s.inUse && s.tx {
return s
}
}
return nil
}
// operatingLocked resolves the operator's slices: the MAIN (active) slice for the
// mode/display, and — ONLY for a GENUINE split — the RX and TX slices. Split is
// the tx-flagged slice PLUS a distinct in-use slice on the SAME band (different
// freq) AND the SAME split class (both PHONE, or both CW) — detected from the
// pair itself, NOT from which slice is active (the TX slice often steals focus
// right after "slice create", which must NOT read as "no split"). A slice on
// another band is an independent receiver, ignored; so is a same-band slice in a
// different mode (SSB + FT8) or a data mode (FT8/FT4/RTTY never split).
// Caller holds f.mu.
func (f *Flex) operatingLocked() (main, rx, tx *flexSlice) {
_, main = f.mainSliceLocked()
txS := f.txSliceLocked()
if txS == nil {
return main, nil, nil
}
bt := BandFromHz(txS.freqHz)
if bt == "" {
return main, nil, nil
}
// Split only applies to PHONE/CW; a data-mode TX slice never splits.
ct := flexSplitClass(txS.mode)
if ct == "" {
return main, nil, nil
}
// A split partner is an in-use, same-band slice at a different freq in the
// SAME split class (so SSB + FT8 on one band is NOT a split).
sameSplit := func(s *flexSlice) bool {
return s != nil && s.inUse && s != txS && s.freqHz != txS.freqHz &&
BandFromHz(s.freqHz) == bt && flexSplitClass(s.mode) == ct
}
// RX = the active slice when it qualifies, else the first other qualifying
// same-band slice.
if sameSplit(main) {
rx = main
} else {
for _, idx := range f.sortedSliceIdxLocked() {
if s := f.slices[idx]; sameSplit(s) {
rx = s
break
}
}
}
if rx == nil {
return main, nil, nil // tx slice alone (simplex) or no same-mode partner → not split
}
return main, rx, txS
}
// SetActiveSlice focuses slice idx on the radio so every subsequent command
// (freq / mode / DSP / spot click) targets it. Lets the operator pick the
// operating slice from OpsLog (like SliceLogger's A/B selector).
func (f *Flex) SetActiveSlice(idx int) error {
f.mu.Lock()
_, exists := f.slices[idx]
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !exists {
return fmt.Errorf("flex: no slice %d", idx)
}
// Remember it: this is the operator speaking, and it must survive the radio
// moving its own active flag to the transmitter during split.
f.mu.Lock()
f.pinnedSlice = idx
f.mu.Unlock()
f.send(fmt.Sprintf("slice s %d active=1", idx))
return nil
}
// SetTXSlice makes slice idx the transmitter (tx=1) — e.g. "put TX on the active
// slice" so you transmit where you're listening. Only one slice can be TX; the
// radio clears the flag on the others.
func (f *Flex) SetTXSlice(idx int) error {
f.mu.Lock()
_, exists := f.slices[idx]
connected := f.conn != nil
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
if !exists {
return fmt.Errorf("flex: no slice %d", idx)
}
f.send(fmt.Sprintf("slice s %d tx=1", idx))
return nil
}
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
// Optimistically update the active slice's cached freq NOW, before the radio
// echoes the slice status back. Otherwise ReadState/FlexState keep reporting
// the OLD freq for the round-trip: the top display (optimistic liveFreqHz)
// jumped to the new band while the slice cache — which the FlexPanel and the
// Ultrabeam follow loop read — still showed the old one, so the antenna chased
// the stale value. The real echo confirms/corrects this a moment later.
if s := f.slices[idx]; s != nil {
s.freqHz = hz
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
// "slice t <rx> <freq_MHz>" — 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)
}
// Optimistically cache the new mode too (same reasoning as SetFrequency) so the
// panel reflects it immediately instead of lagging the radio's echo.
f.mu.Lock()
if s := f.slices[idx]; s != nil {
s.mode = fm
}
f.mu.Unlock()
// "slice s <rx> mode=<m>" — 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
}
life := s.LifetimeSec
if life <= 0 {
life = 1800 // default 30 min (cluster spots)
}
// De-dupe by callsign: WSJT decodes re-fire every cycle, so a station already
// spotted gets its previous spot removed first — one live spot per call,
// refreshed, instead of a pile that all expire independently. NB: capture the
// old index under the lock but send OUTSIDE it — f.send() takes f.mu itself,
// and Go mutexes aren't reentrant (calling send while locked deadlocks the
// whole Flex goroutine → the radio drops OFFLINE).
upperCall := strings.ToUpper(s.Callsign)
f.mu.Lock()
old, hadOld := f.spotByCall[upperCall]
if hadOld {
delete(f.spotByCall, upperCall)
delete(f.spotCall, old)
delete(f.spotIdx, old)
}
f.mu.Unlock()
if hadOld {
f.send(fmt.Sprintf("spot remove %d", old))
}
cmd := fmt.Sprintf("spot add rx_freq=%.6f callsign=%s color=%s source=OpsLog lifetime_seconds=%d trigger_action=Tune timestamp=%d",
float64(s.FreqHz)/1e6, call, color, life, time.Now().Unix())
// Convert to a real Flex mode (USB/LSB/CW/DIGU/…): SmartSDR only switches the
// slice mode on a spot click when mode= is one of ITS mode names — "SSB" or a
// bare cluster label is ignored, so the mode wouldn't change. adifModeToFlex
// also resolves SSB→USB/LSB from the spot frequency.
if m := flexEncode(adifModeToFlex(s.Mode, s.FreqHz)); 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<seq> 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{}
f.spotByCall = map[string]int{}
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.
// splitCSV splits a comma-separated antenna list (e.g. "ANT1,ANT2,RX_A") into a
// trimmed slice, dropping empties.
func splitCSV(s string) []string {
out := []string{}
for _, p := range strings.Split(s, ",") {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
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 operator's (main/active) slice and its index — the
// slice every RX-DSP control and read targets. Caller holds f.mu.
func (f *Flex) rxSliceLocked() (int, *flexSlice) {
return f.mainSliceLocked()
}
// 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,
TXFilterLow: f.tx.filterLow,
TXFilterHigh: f.tx.filterHigh,
TXDAX: f.tx.dax,
MicProfile: f.micProfile,
MicProfiles: f.micProfiles,
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 _, rxS, txSplit := f.operatingLocked(); rxS != nil && txSplit != nil {
st.Split = true
st.RXFreqHz = rxS.freqHz
st.TXFreqHz = txSplit.freqHz
}
// Every in-use slice (A/B/C/D…) so the panel shows them all and highlights the
// active/TX one — the active slice drives everything the operator does.
sidx := make([]int, 0, len(f.slices))
for i, s := range f.slices {
if s.inUse {
sidx = append(sidx, i)
}
}
sort.Ints(sidx)
mainIdx, _ := f.mainSliceLocked()
for _, i := range sidx {
s := f.slices[i]
st.Slices = append(st.Slices, FlexSliceInfo{
Index: i,
Letter: sliceLetter(i),
FreqHz: s.freqHz,
Mode: flexModeToADIF(s.mode),
Band: BandFromHz(s.freqHz),
// The slice OpsLog is working with, which is not always the one the
// radio has focused — in split OpsLog stays on RX. Reporting the
// radio's flag here would highlight one slice while every control
// acted on another.
Active: i == mainIdx,
TX: s.tx,
})
}
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.Mute = rx.mute
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.WNB = rx.wnb
st.WNBLevel = rx.wnbLevel
st.LMSNR = rx.lmsNR
st.LMSNRLevel = rx.lmsNRLevel
st.LMSANF = rx.lmsANF
st.LMSANFLevel = rx.lmsANFLevel
st.SpeexNR = rx.speexNR
st.SpeexNRLevel = rx.speexNRLevel
st.RNN = rx.rnnoise
st.ANFT = rx.anft
st.NRF = rx.nrf
st.NRFLevel = rx.nrfLevel
st.DSPV4 = rx.dspV4
st.DAXCh = rx.daxCh
st.RIT = rx.rit
st.RITFreq = rx.ritFreq
st.XIT = rx.xit
st.XITFreq = rx.xitFreq
st.FilterLo = rx.filterLo
st.FilterHi = rx.filterHi
st.RXAnt = rx.rxAnt
st.TXAnt = rx.txAnt
st.AntList = rx.antList
st.TXAntList = rx.txAntList
if len(st.TXAntList) == 0 {
st.TXAntList = rx.antList // many configs share one antenna list
}
}
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, Slice: mi.slc, Value: f.meterVal[id], Lo: mi.lo, Hi: mi.hi})
}
}
return st
}
// sendSlice sends a "slice s <rxIdx> <param>=<val>" 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 "audio_mute", "mute":
rx.mute = fmt.Sprint(val) == "1"
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)
case "lms_nr":
rx.lmsNR = val == "1"
case "lms_nr_level":
rx.lmsNRLevel = toInt(val)
case "lms_anf":
rx.lmsANF = val == "1"
case "lms_anf_level":
rx.lmsANFLevel = toInt(val)
case "speex_nr":
rx.speexNR = val == "1"
case "speex_nr_level":
rx.speexNRLevel = toInt(val)
case "rnnoise":
rx.rnnoise = val == "1"
case "anft":
rx.anft = val == "1"
case "nrf":
rx.nrf = val == "1"
case "nrf_level":
rx.nrfLevel = toInt(val)
case "dax":
rx.daxCh = toInt(val)
case "rxant":
rx.rxAnt = fmt.Sprint(val)
case "txant":
rx.txAnt = fmt.Sprint(val)
case "rit_on":
rx.rit = val == "1"
case "rit_freq":
rx.ritFreq = toInt(val)
case "xit_on":
rx.xit = val == "1"
case "xit_freq":
rx.xitFreq = 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) SetMute(on bool) error { return f.sendSlice("audio_mute", boolFlex(on)) }
func (f *Flex) SetRXAntenna(a string) error { return f.sendSlice("rxant", a) }
func (f *Flex) SetTXAntenna(a string) error { return f.sendSlice("txant", a) }
// SetSplit toggles 2-slice split like SmartSDR's SPLIT button. ON creates a TX
// slice at RX freq + offset (+1 kHz on CW, +5 kHz otherwise) and makes it the
// transmitter; OFF removes the extra TX slice and returns to simplex (RX slice
// transmits again).
func (f *Flex) SetSplit(on bool) error {
f.mu.Lock()
if f.conn == nil {
f.mu.Unlock()
return fmt.Errorf("flex: not connected")
}
// Split is built AROUND THE ACTIVE slice, and "already split" uses the SAME
// same-band-pair rule as the button state (operatingLocked) — otherwise two
// INDEPENDENT slices on different bands look "already split" and SPLIT ON does
// nothing (the bug the user hit: A on 20m + B on 80m).
main, rxS, txS := f.operatingLocked()
rxIdx, txIdx := -1, -1
for i, s := range f.slices {
if s == rxS {
rxIdx = i
}
if s == txS {
txIdx = i
}
}
alreadySplit := rxS != nil && txS != nil
var baseFreq int64
var baseMode, baseAnt string
if main != nil {
baseFreq, baseMode, baseAnt = main.freqHz, main.mode, main.rxAnt
}
f.mu.Unlock()
if on {
if alreadySplit || main == nil {
return nil // already split, or no active slice
}
offset := int64(5000)
if strings.HasPrefix(strings.ToUpper(baseMode), "CW") {
offset = 1000
}
// Create the TX slice at the ACTIVE slice's freq + offset (same band).
cmd := fmt.Sprintf("slice create freq=%.6f mode=%s", float64(baseFreq+offset)/1e6, baseMode)
if baseAnt != "" {
cmd += " ant=" + baseAnt
}
if seq := f.send(cmd); seq > 0 {
f.mu.Lock()
f.pendingSplit[seq] = true // mark the new slice TX once its index returns
f.mu.Unlock()
}
return nil
}
if !alreadySplit {
return nil
}
if txIdx >= 0 {
f.send(fmt.Sprintf("slice remove %d", txIdx)) // drop the extra TX slice
}
if rxIdx >= 0 {
f.send(fmt.Sprintf("slice s %d tx=1", rxIdx)) // RX slice transmits again
}
return nil
}
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)) }
// RIT/XIT — an offset applied to the RX (RIT) or TX (XIT) frequency of the active
// slice, without moving the slice itself. SmartSDR keeps the offset even while the
// switch is off, so turning RIT back on restores the last offset — same as the
// radio's own knob.
func (f *Flex) SetRIT(on bool) error { return f.sendSlice("rit_on", boolFlex(on)) }
func (f *Flex) SetRITFreq(hz int) error { return f.sendSlice("rit_freq", clampOffset(hz)) }
func (f *Flex) SetXIT(on bool) error { return f.sendSlice("xit_on", boolFlex(on)) }
func (f *Flex) SetXITFreq(hz int) error { return f.sendSlice("xit_freq", clampOffset(hz)) }
// clampOffset keeps a RIT/XIT offset inside what SmartSDR accepts (±99 999 Hz).
func clampOffset(hz int) int {
if hz > 99999 {
return 99999
}
if hz < -99999 {
return -99999
}
return hz
}
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)) }
// SmartSDR v4 DSP (8000/Aurora series) — the extra noise tools SmartSDR ships
// beyond nb/nr/anf/wnb. Key names per the FlexLib slice command docs.
func (f *Flex) SetLMSNR(on bool) error { return f.sendSlice("lms_nr", boolFlex(on)) }
func (f *Flex) SetLMSNRLevel(l int) error { return f.sendSlice("lms_nr_level", clampLevel(l)) }
func (f *Flex) SetLMSANF(on bool) error { return f.sendSlice("lms_anf", boolFlex(on)) }
func (f *Flex) SetLMSANFLevel(l int) error { return f.sendSlice("lms_anf_level", clampLevel(l)) }
func (f *Flex) SetSpeexNR(on bool) error { return f.sendSlice("speex_nr", boolFlex(on)) }
func (f *Flex) SetSpeexNRLevel(l int) error { return f.sendSlice("speex_nr_level", clampLevel(l)) }
func (f *Flex) SetRNN(on bool) error { return f.sendSlice("rnnoise", boolFlex(on)) }
func (f *Flex) SetANFT(on bool) error { return f.sendSlice("anft", boolFlex(on)) }
func (f *Flex) SetNRF(on bool) error { return f.sendSlice("nrf", boolFlex(on)) }
func (f *Flex) SetNRFLevel(l int) error { return f.sendSlice("nrf_level", clampLevel(l)) }
// SetDAX routes the active slice's RX audio to a DAX channel (0 = off, 1-8) —
// the SmartSDR slice "DAX" selector, e.g. to feed WSJT-X via DAX audio.
func (f *Flex) SetDAX(ch int) error {
if ch < 0 {
ch = 0
}
if ch > 8 {
ch = 8
}
return f.sendSlice("dax", ch)
}
// SetTXDAX toggles DAX as the TRANSMIT audio source — SmartSDR's transmit-bar
// DAX button ("transmit set dax="), the one used to send WSJT-X audio.
func (f *Flex) SetTXDAX(on bool) error {
return f.txSet("transmit set dax="+boolFlex(on), "dax", func(t *flexTX) { t.dax = on })
}
func (f *Flex) SetWNB(on bool) error { return f.sendSlice("wnb", boolFlex(on)) }
func (f *Flex) SetWNBLevel(l int) error { return f.sendSlice("wnb_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
}
// SendCW queues text on the radio's CWX keyer. SmartSDR buffers and keys it, so
// no WinKeyer / SmartCAT is needed — and because the radio owns the buffer,
// characters fed while it's already sending append and key in order (the basis
// for type-ahead). Quotes/backslashes are escaped for the "cwx send \"…\"" form.
func (f *Flex) SendCW(text string) error {
text = strings.TrimRight(text, "\r\n")
if strings.TrimSpace(text) == "" {
return nil
}
esc := strings.NewReplacer(`\`, `\\`, `"`, `\"`).Replace(text)
f.send(`cwx send "` + esc + `"`)
return nil
}
// StopCW clears the CWX buffer, aborting whatever is currently being keyed.
func (f *Flex) StopCW() error {
f.send("cwx clear")
return nil
}
// BackspaceCW removes the last n not-yet-keyed characters from the CWX buffer —
// the "un-send while sending" a serial WinKeyer can't do. Used for type-ahead
// corrections (backspacing a mistyped call before the radio has keyed it).
func (f *Flex) BackspaceCW(n int) error {
if n < 1 {
n = 1
}
f.send(fmt.Sprintf("cwx erase %d", n))
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
}
// SetFilter sets the active RX slice's passband to an explicit low/high cut (Hz,
// audio offsets; negative for LSB). Used by the SSB width presets, where the
// frontend keeps the carrier-side edge and extends the far edge.
func (f *Flex) SetFilter(lo, hi int) error {
f.mu.Lock()
idx, rx := f.rxSliceLocked()
connected := f.conn != nil
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 })
}
// SetTXInhibit blocks (on=true) or allows transmission at the radio. Used to keep
// the operator from keying while a motorized antenna's elements are moving —
// SmartSDR refuses to transmit while inhibit is set, so it holds even against a
// footswitch or an external keyer, which a software PTT block could not.
//
// `transmit set inhibit=` is the whole mechanism. We used to also send
// `interlock set reason=OpsLog` to label WHY TX was blocked, but `reason` is a
// read-only status field on the interlock object — writing it is rejected
// (cmd error 5000002D on SmartSDR v1.4/v3) and does nothing, so it's dropped.
func (f *Flex) SetTXInhibit(on bool) error {
return f.txSet("transmit set inhibit="+boolFlex(on), "inhibit", func(t *flexTX) { t.inhibit = 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 })
}
// SetTXFilter sets the transmit-audio bandwidth (low + high cut, in Hz). SmartSDR
// clamps to legal values per mode; we just pass them through in one command so
// both edges move together.
func (f *Flex) SetTXFilter(low, high int) error {
if low < 0 {
low = 0
}
if high < low {
high = low
}
// Guard both status field names (the echo comes back as lo/hi, not
// filter_low/high) so the radio's lagging echo doesn't snap the inputs back.
f.mu.Lock()
f.txSetAt["hi"] = time.Now()
f.mu.Unlock()
return f.txSet(fmt.Sprintf("transmit set filter_low=%d filter_high=%d", low, high), "lo",
func(t *flexTX) { t.filterLow, t.filterHigh = low, high })
}
// SetMicProfile loads a SmartSDR mic profile by name (quoted, as names may hold
// spaces). Optimistically caches the selection.
func (f *Flex) SetMicProfile(name string) error {
name = strings.TrimSpace(name)
if name == "" {
return fmt.Errorf("flex: empty mic profile")
}
f.mu.Lock()
connected := f.conn != nil
if connected {
f.micProfile = name
}
f.mu.Unlock()
if !connected {
return fmt.Errorf("flex: not connected")
}
f.send(fmt.Sprintf("profile mic load \"%s\"", name))
return nil
}
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)
}
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.
// flexSplitClass groups a raw Flex slice mode into a split-capable class. A
// genuine split (one RX freq, one TX freq, SAME mode) only makes sense for PHONE
// and CW pileups. Data modes (FT8/FT4/RTTY all report as DIGU/DIGL/RTTY on a
// Flex) and digital voice never operate split, so they return "" (not
// split-capable). Two same-band slices whose classes differ (e.g. SSB + FT8) are
// independent operations, not a split.
func flexSplitClass(rawMode string) string {
switch flexModeToADIF(rawMode) {
case "SSB", "AM", "FM":
return "PHONE"
case "CW":
return "CW"
default: // DATA, RTTY, DIGITALVOICE, "" → never split
return ""
}
}
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 ""
}
}