feat: added FlexRadio support (meters & basic functions)

This commit is contained in:
2026-06-17 18:29:35 +02:00
parent abdab22010
commit bde1195b34
9 changed files with 1808 additions and 13 deletions
+109
View File
@@ -226,6 +226,115 @@ func (m *Manager) SendSpot(s SpotInfo) {
}
}
// FlexTXState is the FlexRadio transmit/ATU state surfaced to the dedicated
// FlexRadio control tab. Levels are 0-100. (Phase 1: controls + state pushed by
// the radio over TCP; live meters arrive over a separate UDP stream later.)
type FlexTXState struct {
Available bool `json:"available"` // backend is Flex and handshaked
Model string `json:"model,omitempty"`
RFPower int `json:"rf_power"`
TunePower int `json:"tune_power"`
Tune bool `json:"tune"` // tune carrier active
Transmitting bool `json:"transmitting"` // interlock state = TRANSMITTING
VoxEnable bool `json:"vox_enable"`
VoxLevel int `json:"vox_level"`
VoxDelay int `json:"vox_delay"`
ProcEnable bool `json:"proc_enable"`
ProcLevel int `json:"proc_level"`
Mon bool `json:"mon"`
MonLevel int `json:"mon_level"`
MicLevel int `json:"mic_level"`
ATUStatus string `json:"atu_status,omitempty"`
ATUMemories bool `json:"atu_memories"`
// Active RX slice DSP controls.
RXAvail bool `json:"rx_avail"` // an RX slice exists
AGCMode string `json:"agc_mode,omitempty"`
AGCThreshold int `json:"agc_threshold"`
AudioLevel int `json:"audio_level"`
NB bool `json:"nb"`
NBLevel int `json:"nb_level"`
NR bool `json:"nr"`
NRLevel int `json:"nr_level"`
ANF bool `json:"anf"`
ANFLevel int `json:"anf_level"`
// External amplifier (PowerGenius XL).
AmpAvailable bool `json:"amp_available"`
AmpModel string `json:"amp_model,omitempty"`
AmpOperate bool `json:"amp_operate"`
AmpFault string `json:"amp_fault,omitempty"`
// Live meters streamed over UDP (S-meter, PWR, SWR, temp, voltage…).
Meters []FlexMeter `json:"meters,omitempty"`
}
// FlexMeter is one live meter value (already scaled to real units).
type FlexMeter struct {
ID int `json:"id"`
Src string `json:"src,omitempty"` // SLC / TX- / RAD / AMP…
Name string `json:"name,omitempty"` // FWDPWR, SWR, LEVEL, PATEMP…
Unit string `json:"unit,omitempty"`
Value float64 `json:"value"`
Lo float64 `json:"lo"`
Hi float64 `json:"hi"`
}
// FlexController is an OPTIONAL backend capability (the FlexRadio backend): the
// SmartSDR-style transmit controls. Backends that don't implement it are skipped
// by the FlexRadio tab. FlexState() is mutex-guarded in the backend so it's safe
// to read off the CAT goroutine; the setters are dispatched onto it via FlexDo.
type FlexController interface {
FlexState() FlexTXState
SetRFPower(int) error
SetTunePower(int) error
SetTune(bool) error
SetVOX(bool) error
SetVOXLevel(int) error
SetVOXDelay(int) error
SetProcessor(bool) error
SetProcessorLevel(int) error
SetMon(bool) error
SetMonLevel(int) error
SetMic(int) error
ATUStart() error
ATUBypass() error
SetATUMemories(bool) error
// RX slice DSP controls (target the active receive slice).
SetAGCMode(string) error
SetAGCThreshold(int) error
SetAudioLevel(int) error
SetNB(bool) error
SetNBLevel(int) error
SetNR(bool) error
SetNRLevel(int) error
SetANF(bool) error
SetANFLevel(int) error
// External amplifier (PowerGenius XL) operate/standby.
SetAmpOperate(bool) error
}
// FlexState returns the current FlexRadio transmit state, or (zero, false) when
// the active backend isn't a Flex. Safe to call from any goroutine.
func (m *Manager) FlexState() (FlexTXState, bool) {
m.mu.RLock()
b := m.backend
m.mu.RUnlock()
if fc, ok := b.(FlexController); ok {
return fc.FlexState(), true
}
return FlexTXState{}, false
}
// FlexDo dispatches a FlexRadio control onto the CAT goroutine. Errors if the
// active backend isn't a Flex.
func (m *Manager) FlexDo(fn func(FlexController) error) error {
return m.exec(func(b Backend) error {
fc, ok := b.(FlexController)
if !ok {
return fmt.Errorf("active CAT backend is not a FlexRadio")
}
return fn(fc)
})
}
// 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 {
+776 -5
View File
@@ -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)) {