feat: added versionning & About window

This commit is contained in:
2026-06-16 19:36:56 +02:00
parent 33af122964
commit 69d0780bac
16 changed files with 1398 additions and 56 deletions
+53 -6
View File
@@ -68,6 +68,12 @@ type Def struct {
Dynamic bool `json:"dynamic,omitempty"` // references not predefined (any value counts)
AddPrefixes []string `json:"add_prefixes,omitempty"` // possible reference additional prefixes
// OrRules are ADDITIONAL searches OR'd with the primary one above: a QSO
// earns a reference if the primary match OR any of these match. Lets a
// French department (DDFM) be found from "D74" in the note AND from a postal
// code "74140" in the address (pattern captures "74", Prefix "D" → "D74").
OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
@@ -84,6 +90,20 @@ type Def struct {
Builtin bool `json:"builtin"` // shipped default (informational)
}
// OrRule is one additional search OR'd with the award's primary matching rule.
// Same knobs as the primary (field + how to match), plus Prefix which is
// prepended to each reference it finds so a captured value can be normalised to
// the award's reference codes (e.g. postal "74" + Prefix "D" → "D74").
type OrRule struct {
Field string `json:"field"` // QSO field to scan
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
Pattern string `json:"pattern,omitempty"` // Go regexp; group 1 = reference
LeadingStr string `json:"leading_str,omitempty"` // strip this prefix before matching
TrailingStr string `json:"trailing_str,omitempty"` // strip this suffix before matching
Prefix string `json:"prefix,omitempty"` // prepended to each found reference
}
// Defaults are the built-in awards seeded on first run (then user-editable).
func Defaults() []Def {
// Confirmed = any confirmation (LoTW or paper QSL). Validated = the stricter
@@ -511,13 +531,15 @@ func labelRef(rf *Ref, d *Def, code string, rl refList, hasList bool, nameOf Nam
// candidates extracts the reference(s) a QSO contributes to an award, enforcing
// a predefined list when one applies.
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(d.Field, q), d.LeadingStr, d.TrailingStr))
// searchOne runs one matching rule (the primary or an OR rule) over a QSO and
// returns the reference codes it finds, each prefixed with `prefix` (so a
// captured "74" becomes "D74"). predefined enables list-aware matching.
func searchOne(field, matchBy string, re *regexp.Regexp, exact bool, leading, trailing, prefix string, q *qso.QSO, rl refList, predefined bool) []string {
raw := strings.TrimSpace(stripAffix(fieldRaw(field, q), leading, trailing))
if raw == "" {
return nil
}
predefined := hasList && !d.Dynamic
byDesc := predefined && strings.EqualFold(strings.TrimSpace(d.MatchBy), "description")
byDesc := predefined && strings.EqualFold(strings.TrimSpace(matchBy), "description")
var found []string
switch {
@@ -530,7 +552,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// the field equals the name; otherwise the name is a substring of it.
up := strings.ToUpper(raw)
for _, nc := range rl.names {
if d.ExactMatch {
if exact {
if up == nc.name {
found = append(found, nc.code)
}
@@ -538,7 +560,7 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
found = append(found, nc.code)
}
}
case predefined && !d.ExactMatch:
case predefined && !exact:
// "Search reference inside the field": look up each token of the field in
// the list — O(tokens), not O(all references) — plus test the few
// references that declare a regex.
@@ -558,6 +580,31 @@ func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool)
// counts each reference separately.
found = splitRefs(raw)
}
if prefix != "" {
for i := range found {
found[i] = prefix + found[i]
}
}
return found
}
func candidates(d *Def, re *regexp.Regexp, q *qso.QSO, rl refList, hasList bool) []string {
predefined := hasList && !d.Dynamic
// Primary search, then each OR rule — a QSO earns a reference if any matches.
found := searchOne(d.Field, d.MatchBy, re, d.ExactMatch, d.LeadingStr, d.TrailingStr, "", q, rl, predefined)
for i := range d.OrRules {
r := &d.OrRules[i]
var rre *regexp.Regexp
if p := strings.TrimSpace(r.Pattern); p != "" {
c, err := regexp.Compile(p)
if err != nil {
continue // skip a rule with a bad regex rather than failing the award
}
rre = c
}
found = append(found, searchOne(r.Field, r.MatchBy, rre, r.ExactMatch, r.LeadingStr, r.TrailingStr, r.Prefix, q, rl, predefined)...)
}
if !predefined {
return dedupe(found)
+71 -19
View File
@@ -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
+600
View File
@@ -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 ""
}
}
+112
View File
@@ -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
}