601 lines
16 KiB
Go
601 lines
16 KiB
Go
//go:build windows
|
|
|
|
package cat
|
|
|
|
import (
|
|
"bufio"
|
|
"fmt"
|
|
"math"
|
|
"net"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
)
|
|
|
|
// Flex is a native FlexRadio (SmartSDR) CAT backend. It speaks the radio's TCP
|
|
// API on port 4992 — a line-based text protocol — and tracks slice state pushed
|
|
// by the radio in REAL TIME, so frequency/mode/split are always current (unlike
|
|
// the polled, lagging OmniRig path that needed a second click to fix a mode).
|
|
// Pure Go, no CGO, and no OmniRig install required for Flex users.
|
|
type Flex struct {
|
|
host string
|
|
port int
|
|
|
|
mu sync.Mutex
|
|
conn net.Conn
|
|
wmu sync.Mutex // serialises writes to conn
|
|
seq int
|
|
handle string
|
|
model string
|
|
gotHandle bool
|
|
|
|
slices map[int]*flexSlice
|
|
lastStateSig string // last logged derived-state signature (log only on change)
|
|
|
|
spotsEnabled bool // push cluster spots + manage the panadapter overlay
|
|
spotIdx map[int]bool // panadapter spot indices currently known to the radio
|
|
pendingSpot map[int]string // seq → callsign, awaiting the spot index in the R response
|
|
spotCall map[int]string // spot index → callsign (to fill the call on a panadapter click)
|
|
|
|
// OnSpotClick is called (off the reader goroutine's hot path) when the user
|
|
// clicks one of our spots on the panadapter, with the spot's callsign and
|
|
// frequency. The host wires this to fill the entry form. Set before Connect.
|
|
OnSpotClick func(callsign string, freqHz int64)
|
|
}
|
|
|
|
type flexSlice struct {
|
|
freqHz int64
|
|
mode string // raw Flex mode (USB/LSB/CW/DIGU/…)
|
|
active bool
|
|
tx bool
|
|
inUse bool
|
|
}
|
|
|
|
// flexTriggerRe matches the radio's "spot <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{},
|
|
}
|
|
}
|
|
|
|
func (f *Flex) Name() string { return "flex" }
|
|
|
|
// Connect dials the radio and subscribes to slice/radio status. The reader
|
|
// goroutine then keeps our cached state current from the radio's push messages.
|
|
func (f *Flex) Connect() error {
|
|
f.mu.Lock()
|
|
already := f.conn != nil
|
|
host := f.host
|
|
port := f.port
|
|
f.mu.Unlock()
|
|
if already {
|
|
return nil
|
|
}
|
|
if host == "" {
|
|
return fmt.Errorf("flex: no radio IP configured")
|
|
}
|
|
conn, err := net.DialTimeout("tcp", net.JoinHostPort(host, strconv.Itoa(port)), 5*time.Second)
|
|
if err != nil {
|
|
return fmt.Errorf("flex: connect %s:%d: %w", host, port, err)
|
|
}
|
|
f.mu.Lock()
|
|
f.conn = conn
|
|
f.gotHandle = false
|
|
f.slices = map[int]*flexSlice{}
|
|
f.mu.Unlock()
|
|
debugLog.Printf("Flex: connected to %s:%d", host, port)
|
|
|
|
go f.reader(conn)
|
|
// Identify ourselves in SmartSDR's client list, then stream slice + transmit
|
|
// (TX/split) status. Command names per the SmartSDR TCP/IP API docs.
|
|
f.send("client program=OpsLog")
|
|
f.send("sub slice all")
|
|
f.send("sub transmit all")
|
|
f.send("sub radio all")
|
|
if f.spotsEnabled {
|
|
// Subscribe so the radio pushes existing spots (we learn their indices),
|
|
// then wipe the panadapter so stale spots from a previous session or
|
|
// another logger are cleared before we start adding our own.
|
|
f.send("sub spot all")
|
|
go f.clearSpotsOnConnect(conn)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (f *Flex) Disconnect() {
|
|
f.mu.Lock()
|
|
c := f.conn
|
|
f.conn = nil
|
|
f.gotHandle = false
|
|
f.mu.Unlock()
|
|
if c != nil {
|
|
_ = c.Close()
|
|
debugLog.Printf("Flex: disconnected")
|
|
}
|
|
}
|
|
|
|
// send writes a sequenced command (C<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
|
|
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"
|
|
if !ok {
|
|
debugLog.Printf("Flex: cmd error %s", line)
|
|
}
|
|
// A successful "spot add" returns the new spot's index in the message;
|
|
// pair it with the callsign we stashed under this seq.
|
|
f.mu.Lock()
|
|
call, pending := f.pendingSpot[seq]
|
|
if pending {
|
|
delete(f.pendingSpot, seq)
|
|
}
|
|
if pending && ok && len(parts) >= 3 {
|
|
if idx, e := strconv.Atoi(strings.TrimSpace(parts[2])); e == nil {
|
|
f.spotCall[idx] = call
|
|
f.spotIdx[idx] = true
|
|
}
|
|
}
|
|
f.mu.Unlock()
|
|
}
|
|
}
|
|
// Connection ended.
|
|
f.mu.Lock()
|
|
if f.conn == conn {
|
|
f.conn = nil
|
|
f.gotHandle = false
|
|
}
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// handleStatus parses one status payload, e.g.
|
|
// "slice 0 in_use=1 RF_frequency=14.150000 mode=USB active=1 tx=1 …"
|
|
func (f *Flex) handleStatus(payload string) {
|
|
fields := strings.Fields(payload)
|
|
if len(fields) < 2 || fields[0] != "slice" {
|
|
// radio … model=FLEX-6400 — grab the model when present.
|
|
if len(fields) >= 1 && fields[0] == "radio" {
|
|
for _, kv := range fields[1:] {
|
|
if strings.HasPrefix(kv, "model=") {
|
|
f.mu.Lock()
|
|
f.model = strings.TrimPrefix(kv, "model=")
|
|
f.mu.Unlock()
|
|
}
|
|
}
|
|
}
|
|
if len(fields) >= 1 && fields[0] == "transmit" {
|
|
debugLog.Printf("Flex: status %s", payload)
|
|
}
|
|
// Spot status: "spot <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"
|
|
}
|
|
}
|
|
f.mu.Unlock()
|
|
}
|
|
|
|
// ReadState returns the cached state derived from the radio's push messages —
|
|
// no round-trip, so it's always current.
|
|
func (f *Flex) ReadState() (RigState, error) {
|
|
f.mu.Lock()
|
|
defer f.mu.Unlock()
|
|
if f.conn == nil {
|
|
return RigState{}, fmt.Errorf("flex: not connected")
|
|
}
|
|
st := RigState{Connected: f.gotHandle, Rig: f.model}
|
|
if !f.gotHandle {
|
|
return st, nil // connected TCP but radio hasn't handshaked yet
|
|
}
|
|
rx, tx := f.pickSlicesLocked()
|
|
if rx == nil && tx == nil {
|
|
return st, nil
|
|
}
|
|
if tx == nil {
|
|
tx = rx
|
|
}
|
|
if rx == nil {
|
|
rx = tx
|
|
}
|
|
st.FreqHz = tx.freqHz
|
|
st.Mode = flexModeToADIF(tx.mode)
|
|
if rx.freqHz != tx.freqHz {
|
|
st.Split = true
|
|
st.RxFreqHz = rx.freqHz
|
|
}
|
|
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
|
if sig != f.lastStateSig {
|
|
f.lastStateSig = sig
|
|
debugLog.Printf("Flex: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
|
|
}
|
|
return st, nil
|
|
}
|
|
|
|
// pickSlicesLocked chooses the TX and RX slices among in-use slices. TX is the
|
|
// slice flagged tx=1. RX is the slice you actually receive on — the NON-TX slice
|
|
// (preferring the active/focused one), NOT simply the active slice: tuning the
|
|
// TX slice makes it the active/focused slice, which would otherwise collapse RX
|
|
// onto TX and hide the split. Caller holds f.mu.
|
|
func (f *Flex) pickSlicesLocked() (rx, tx *flexSlice) {
|
|
idxs := make([]int, 0, len(f.slices))
|
|
for i, s := range f.slices {
|
|
if s.inUse {
|
|
idxs = append(idxs, i)
|
|
}
|
|
}
|
|
sort.Ints(idxs)
|
|
var active, txS, nonTx, first *flexSlice
|
|
for _, i := range idxs {
|
|
s := f.slices[i]
|
|
if first == nil {
|
|
first = s
|
|
}
|
|
if s.active {
|
|
active = s
|
|
}
|
|
if s.tx {
|
|
txS = s
|
|
} else if nonTx == nil {
|
|
nonTx = s
|
|
}
|
|
}
|
|
tx = txS
|
|
if tx == nil {
|
|
if active != nil {
|
|
tx = active
|
|
} else {
|
|
tx = first
|
|
}
|
|
}
|
|
// RX = the receive slice: the active one if it isn't the TX slice, else the
|
|
// first non-TX slice; fall back to TX (simplex) when there's only one slice.
|
|
switch {
|
|
case active != nil && active != tx:
|
|
rx = active
|
|
case nonTx != nil:
|
|
rx = nonTx
|
|
default:
|
|
rx = tx
|
|
}
|
|
return rx, tx
|
|
}
|
|
|
|
// activeSliceIndexLocked returns the slice index to send commands to (the active
|
|
// slice, else the lowest in-use index, else 0). Caller holds f.mu.
|
|
func (f *Flex) activeSliceIndexLocked() int {
|
|
best, found := 1<<30, false
|
|
for idx, s := range f.slices {
|
|
if !s.inUse {
|
|
continue
|
|
}
|
|
if s.active {
|
|
return idx
|
|
}
|
|
if idx < best {
|
|
best, found = idx, true
|
|
}
|
|
}
|
|
if found {
|
|
return best
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func (f *Flex) SetFrequency(hz int64) error {
|
|
if hz <= 0 {
|
|
return fmt.Errorf("flex: invalid frequency")
|
|
}
|
|
f.mu.Lock()
|
|
idx := f.activeSliceIndexLocked()
|
|
connected := f.conn != nil
|
|
f.mu.Unlock()
|
|
if !connected {
|
|
return fmt.Errorf("flex: not connected")
|
|
}
|
|
// "slice t <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
|
|
}
|
|
|
|
// 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 ""
|
|
}
|
|
}
|