feat: added versionning & About window
This commit is contained in:
+71
-19
@@ -184,6 +184,48 @@ func (m *Manager) SetPTT(on bool) error {
|
||||
return m.exec(func(b Backend) error { return b.SetPTT(on) })
|
||||
}
|
||||
|
||||
// SpotInfo is one cluster spot to render on a backend that supports a spot
|
||||
// overlay (the FlexRadio panadapter). Color is an optional "#AARRGGBB" string;
|
||||
// the backend picks a default when it's empty. (Status-based colouring can be
|
||||
// driven later by setting Color per spot.)
|
||||
type SpotInfo struct {
|
||||
FreqHz int64
|
||||
Callsign string
|
||||
Mode string
|
||||
Color string
|
||||
Comment string
|
||||
}
|
||||
|
||||
// Spotter is an OPTIONAL backend capability: show cluster spots on the radio
|
||||
// (FlexRadio panadapter). Backends that don't implement it are simply skipped.
|
||||
type Spotter interface {
|
||||
SendSpot(SpotInfo) error
|
||||
}
|
||||
|
||||
// SendSpot pushes a cluster spot to the backend if it supports spotting. Runs on
|
||||
// the CAT goroutine and is fire-and-forget (dropped if the queue is busy) — a
|
||||
// missed spot on the panadapter is harmless.
|
||||
func (m *Manager) SendSpot(s SpotInfo) {
|
||||
m.mu.RLock()
|
||||
cmds := m.cmdCh
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if cmds == nil || b == nil {
|
||||
return
|
||||
}
|
||||
if _, ok := b.(Spotter); !ok {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case cmds <- func() {
|
||||
if sp, ok := b.(Spotter); ok {
|
||||
_ = sp.SendSpot(s)
|
||||
}
|
||||
}:
|
||||
default: // queue busy → drop this spot
|
||||
}
|
||||
}
|
||||
|
||||
// exec marshals a backend operation onto the CAT goroutine. Returns the
|
||||
// operation's error or a "busy"/"not running" error if dispatch failed.
|
||||
func (m *Manager) exec(fn func(Backend) error) error {
|
||||
@@ -210,23 +252,27 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
defer runtime.UnlockOSThread()
|
||||
defer close(done)
|
||||
|
||||
if err := b.Connect(); err != nil {
|
||||
m.update(RigState{
|
||||
Enabled: true, Backend: b.Name(), Connected: false,
|
||||
Error: err.Error(), UpdatedAt: time.Now(),
|
||||
})
|
||||
// Stay idle until Stop is called — let the user fix config and re-Start.
|
||||
for {
|
||||
select {
|
||||
case <-stop:
|
||||
return
|
||||
case fn := <-cmds:
|
||||
fn()
|
||||
}
|
||||
}
|
||||
}
|
||||
defer b.Disconnect()
|
||||
|
||||
// Connection is (re)established lazily and retried with a backoff, so a rig
|
||||
// that's off at startup — or a FlexRadio that reboots/drops its TCP link —
|
||||
// reconnects on its own instead of staying dead until the user toggles CAT.
|
||||
const reconnectEvery = 5 * time.Second
|
||||
connected := false
|
||||
var lastAttempt time.Time
|
||||
tryConnect := func() {
|
||||
if connected || time.Since(lastAttempt) < reconnectEvery {
|
||||
return
|
||||
}
|
||||
lastAttempt = time.Now()
|
||||
if err := b.Connect(); err != nil {
|
||||
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||
return
|
||||
}
|
||||
connected = true
|
||||
}
|
||||
tryConnect()
|
||||
|
||||
ticker := time.NewTicker(pollEvery)
|
||||
defer ticker.Stop()
|
||||
|
||||
@@ -238,12 +284,18 @@ func (m *Manager) run(b Backend, stop, done chan struct{}, cmds chan func(), pol
|
||||
fn()
|
||||
m.applyCommandDelay()
|
||||
case <-ticker.C:
|
||||
if !connected {
|
||||
tryConnect()
|
||||
continue
|
||||
}
|
||||
ns, err := b.ReadState()
|
||||
if err != nil {
|
||||
m.update(RigState{
|
||||
Enabled: true, Backend: b.Name(), Connected: false,
|
||||
Error: err.Error(), UpdatedAt: time.Now(),
|
||||
})
|
||||
// Lost the rig — drop the backend so the next attempt reconnects
|
||||
// cleanly, then back off before retrying.
|
||||
connected = false
|
||||
lastAttempt = time.Now()
|
||||
b.Disconnect()
|
||||
m.update(RigState{Enabled: true, Backend: b.Name(), Connected: false, Error: err.Error(), UpdatedAt: time.Now()})
|
||||
continue
|
||||
}
|
||||
ns.Enabled = true
|
||||
|
||||
@@ -0,0 +1,600 @@
|
||||
//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 ""
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
import (
|
||||
"context"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/sys/windows"
|
||||
)
|
||||
|
||||
// FlexRadio is one radio found by discovery.
|
||||
type FlexRadio struct {
|
||||
IP string `json:"ip"`
|
||||
Port int `json:"port"`
|
||||
Model string `json:"model"`
|
||||
Nickname string `json:"nickname"`
|
||||
Serial string `json:"serial"`
|
||||
Callsign string `json:"callsign"`
|
||||
}
|
||||
|
||||
// FlexRadios on the LAN broadcast a discovery datagram to UDP :4992 about once a
|
||||
// second. DiscoverFlex listens for that broadcast for the given duration and
|
||||
// returns the unique radios seen. Best effort: if the port can't be bound
|
||||
// (SmartSDR running, firewall…), it returns what it has (often nothing) and the
|
||||
// user falls back to entering the IP by hand.
|
||||
func DiscoverFlex(timeout time.Duration) ([]FlexRadio, error) {
|
||||
if timeout <= 0 {
|
||||
timeout = 2 * time.Second
|
||||
}
|
||||
// Bind :4992 with SO_REUSEADDR so we coexist with SmartSDR, which also
|
||||
// listens for the same broadcast.
|
||||
lc := net.ListenConfig{
|
||||
Control: func(_, _ string, c syscall.RawConn) error {
|
||||
var serr error
|
||||
_ = c.Control(func(fd uintptr) {
|
||||
serr = windows.SetsockoptInt(windows.Handle(fd), windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
||||
})
|
||||
return serr
|
||||
},
|
||||
}
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
pc, err := lc.ListenPacket(ctx, "udp4", ":4992")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer pc.Close()
|
||||
|
||||
_ = pc.SetReadDeadline(time.Now().Add(timeout))
|
||||
found := map[string]FlexRadio{}
|
||||
buf := make([]byte, 2048)
|
||||
for {
|
||||
n, _, err := pc.ReadFrom(buf)
|
||||
if err != nil {
|
||||
break // deadline reached or socket closed
|
||||
}
|
||||
if r, ok := parseFlexDiscovery(buf[:n]); ok && r.IP != "" {
|
||||
if _, dup := found[r.IP]; !dup {
|
||||
found[r.IP] = r
|
||||
}
|
||||
}
|
||||
}
|
||||
out := make([]FlexRadio, 0, len(found))
|
||||
for _, r := range found {
|
||||
out = append(out, r)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
var (
|
||||
reFlexModel = regexp.MustCompile(`model=(\S+)`)
|
||||
reFlexIP = regexp.MustCompile(`ip=(\S+)`)
|
||||
reFlexPort = regexp.MustCompile(`port=(\d+)`)
|
||||
reFlexSerial = regexp.MustCompile(`serial=(\S+)`)
|
||||
reFlexNickname = regexp.MustCompile(`nickname=(\S+)`)
|
||||
reFlexCallsign = regexp.MustCompile(`callsign=(\S+)`)
|
||||
)
|
||||
|
||||
// parseFlexDiscovery extracts radio fields from a VITA-49 discovery datagram.
|
||||
// The payload carries a space-separated key=value ASCII blob after a binary
|
||||
// header, so we scan the whole packet text for the keys we need.
|
||||
func parseFlexDiscovery(pkt []byte) (FlexRadio, bool) {
|
||||
s := string(pkt)
|
||||
m := reFlexIP.FindStringSubmatch(s)
|
||||
if m == nil {
|
||||
return FlexRadio{}, false
|
||||
}
|
||||
r := FlexRadio{IP: m[1], Port: 4992}
|
||||
if mm := reFlexPort.FindStringSubmatch(s); mm != nil {
|
||||
if p, err := strconv.Atoi(mm[1]); err == nil && p > 0 {
|
||||
r.Port = p
|
||||
}
|
||||
}
|
||||
if mm := reFlexModel.FindStringSubmatch(s); mm != nil {
|
||||
r.Model = mm[1]
|
||||
}
|
||||
if mm := reFlexSerial.FindStringSubmatch(s); mm != nil {
|
||||
r.Serial = mm[1]
|
||||
}
|
||||
if mm := reFlexNickname.FindStringSubmatch(s); mm != nil {
|
||||
r.Nickname = mm[1]
|
||||
}
|
||||
if mm := reFlexCallsign.FindStringSubmatch(s); mm != nil {
|
||||
r.Callsign = mm[1]
|
||||
}
|
||||
return r, true
|
||||
}
|
||||
Reference in New Issue
Block a user