feat: added FlexRadio support (meters & basic functions)
This commit is contained in:
+776
-5
@@ -4,6 +4,7 @@ package cat
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
@@ -33,12 +34,27 @@ type Flex struct {
|
||||
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
|
||||
@@ -52,6 +68,54 @@ type flexSlice struct {
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
// 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
|
||||
@@ -69,6 +133,8 @@ func NewFlex(host string, port int, spotsEnabled bool) *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{},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -96,16 +162,21 @@ func (f *Flex) Connect() error {
|
||||
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")
|
||||
f.send("sub transmit all")
|
||||
f.send("sub radio all")
|
||||
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.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
|
||||
@@ -119,9 +190,14 @@ func (f *Flex) Connect() error {
|
||||
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")
|
||||
@@ -136,6 +212,9 @@ func (f *Flex) send(cmd string) int {
|
||||
c := f.conn
|
||||
f.seq++
|
||||
seq := f.seq
|
||||
if f.sentCmds != nil {
|
||||
f.sentCmds[seq] = cmd
|
||||
}
|
||||
f.mu.Unlock()
|
||||
if c == nil {
|
||||
return 0
|
||||
@@ -196,8 +275,12 @@ func (f *Flex) reader(conn net.Conn) {
|
||||
}
|
||||
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 %s", line)
|
||||
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.
|
||||
@@ -239,8 +322,190 @@ func (f *Flex) handleStatus(payload string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
// 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 "mic_level", "miclevel":
|
||||
f.tx.micLevel = atoiDefault(val, f.tx.micLevel)
|
||||
}
|
||||
}
|
||||
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 "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
|
||||
@@ -299,6 +564,24 @@ func (f *Flex) handleStatus(payload string) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
f.mu.Unlock()
|
||||
@@ -547,6 +830,494 @@ func (f *Flex) SetPTT(on bool) error {
|
||||
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,
|
||||
}
|
||||
if _, rx := f.rxSliceLocked(); rx != nil {
|
||||
st.RXAvail = true
|
||||
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
|
||||
}
|
||||
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)
|
||||
}
|
||||
}
|
||||
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)) }
|
||||
|
||||
// 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)) {
|
||||
|
||||
Reference in New Issue
Block a user