1554 lines
44 KiB
Go
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 ""
|
|
}
|
|
}
|