Files
OpsLog/internal/cat/flex.go
T

1554 lines
44 KiB
Go

//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)
// 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
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<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)
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 <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{},
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.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.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<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.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"
// NOTE: speed/pitch/break_in_delay also appear in the transmit
// object, but they are STALE defaults there — the LIVE values come
// from the cwx object (see the cwx branch). Parsing them here too
// would let the stale value overwrite the correct one depending on
// status arrival order, so we deliberately ignore them here.
case "mic_level", "miclevel":
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
}
}
f.mu.Unlock()
}
// 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 <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{}
}
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)
}
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
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 <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 "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 <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)
}
// "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
}
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<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{}
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 <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 "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 ""
}
}