feat(tci): a control console for the SunSDR
TCI already carries the frequency, the mode, the meters and now the audio. It also carries everything else about the radio — and OpsLog was logging most of it once as '(unhandled once)' and throwing it away. The console is mostly a place to put what was already arriving. That makes it the cheapest panel here, and it is worth saying why. A K3 console costs a command and a reply for every value it shows, which is why it reads its settings in a rotation and its meters only while on screen. TCI PUSHES: the radio announces its drive, its filters, its noise blanker and the rest on connect, and again whenever any of them changes — including when the operator changes them in ExpertSDR3's own window, which this panel therefore follows without asking anything. What it drives: drive and tune drive, mic gain, TUNE, volume, mute, squelch and its threshold, NB, NR, ANF, APF, AGC speed, the passband, RIT and XIT with their offsets, and the VFO lock. The S-meter is a real dBm reading, so its S units are arithmetic rather than the calibration guess a K3's meter needs. Setters never update the cached state. The radio answers with the new value, and taking its word is what keeps the panel honest when a setting is refused, clamped, or changed at the radio a second later — the one exception being a slider mid-drag, held for 900 ms so it is not dragged back by its own echo. Capped width and centred, like the other consoles. Also offered as a docked pane — and the Elecraft console is offered there too now: App has always had that pane, Settings simply never listed it.
This commit is contained in:
+19
-2
@@ -34,6 +34,10 @@ type TCI struct {
|
||||
OnSpotClick func(callsign string, freqHz int64)
|
||||
unhandledSeen map[string]bool // log each unknown TCI message type once
|
||||
|
||||
// panel is the control-console state — everything the radio announces about
|
||||
// itself that is not frequency or mode. See tci_panel.go.
|
||||
panel tciPanel
|
||||
|
||||
// audio holds the receive-audio stream — see tci_audio.go. TCI carries it
|
||||
// on this same WebSocket, which is what lets a SunSDR record and decode
|
||||
// without a virtual audio cable in the way.
|
||||
@@ -457,7 +461,20 @@ func (t *TCI) handle(msg string) {
|
||||
}
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
switch strings.ToLower(name) {
|
||||
lower := strings.ToLower(name)
|
||||
// The console's own messages first. Most of them were being logged once as
|
||||
// unhandled and thrown away — the radio has been announcing its drive, its
|
||||
// filters and its noise blanker since the first connection.
|
||||
if t.handlePanel(lower, get, args) {
|
||||
// Still falls through for the few the rig state also needs (split, tune),
|
||||
// which is why this does not return.
|
||||
switch lower {
|
||||
case "split_enable", "trx", "modulation", "vfo":
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
switch lower {
|
||||
case "device":
|
||||
t.device = strings.TrimSpace(args)
|
||||
// The radio ANNOUNCES its audio format at connect —
|
||||
@@ -524,7 +541,7 @@ func (t *TCI) handle(msg string) {
|
||||
t.txAllowed, t.txAllowedKnown = allowed, true
|
||||
}
|
||||
default:
|
||||
lname := strings.ToLower(name)
|
||||
lname := lower
|
||||
// A click on one of our panorama spots comes back as
|
||||
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
||||
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
||||
|
||||
@@ -37,3 +37,62 @@ func (m *Manager) TCIAudioDo(fn func(TCIAudioController) error) error {
|
||||
return fn(tc)
|
||||
})
|
||||
}
|
||||
|
||||
// TCIPanelController is the control console of a TCI radio — everything the
|
||||
// panel reads and everything it sets.
|
||||
//
|
||||
// Listed one by one rather than accepted as *TCI, for the same reason the audio
|
||||
// controller is: the manager hands out capabilities, not backends, and a
|
||||
// station on OmniRig asking for the TCI console gets a sentence instead of a
|
||||
// crash.
|
||||
type TCIPanelController interface {
|
||||
TCIPanel() TCIPanelState
|
||||
SetDrive(v int) error
|
||||
SetTuneDrive(v int) error
|
||||
SetMicLevel(v int) error
|
||||
SetVolume(db int) error
|
||||
SetMute(on bool) error
|
||||
SetAGC(mode string) error
|
||||
SetSquelch(on bool) error
|
||||
SetSquelchLevel(v int) error
|
||||
SetNB(on bool) error
|
||||
SetNR(on bool) error
|
||||
SetANF(on bool) error
|
||||
SetAPF(on bool) error
|
||||
SetFilter(lo, hi int) error
|
||||
SetRIT(on bool) error
|
||||
SetXIT(on bool) error
|
||||
SetRITOffset(hz int) error
|
||||
SetXITOffset(hz int) error
|
||||
SetLock(on bool) error
|
||||
SetTune(on bool) error
|
||||
}
|
||||
|
||||
// TCIPanelState returns the console snapshot, or (zero, false) when the active
|
||||
// backend is not a TCI radio.
|
||||
//
|
||||
// Read WITHOUT going through the CAT goroutine: the state is a cached copy of
|
||||
// what the radio pushed, guarded by its own lock, and the panel polls it several
|
||||
// times a second. Queueing that behind whatever the poll loop is doing would put
|
||||
// the console's smoothness at the mercy of a rig command's timeout.
|
||||
func (m *Manager) TCIPanelState() (TCIPanelState, bool) {
|
||||
m.mu.RLock()
|
||||
b := m.backend
|
||||
m.mu.RUnlock()
|
||||
if tc, ok := b.(TCIPanelController); ok {
|
||||
return tc.TCIPanel(), true
|
||||
}
|
||||
return TCIPanelState{}, false
|
||||
}
|
||||
|
||||
// TCIPanelDo dispatches one console command onto the CAT goroutine, where every
|
||||
// other write to the radio goes.
|
||||
func (m *Manager) TCIPanelDo(fn func(TCIPanelController) error) error {
|
||||
return m.exec(func(b Backend) error {
|
||||
tc, ok := b.(TCIPanelController)
|
||||
if !ok {
|
||||
return fmt.Errorf("the active CAT backend is not a TCI radio")
|
||||
}
|
||||
return fn(tc)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,308 @@
|
||||
//go:build windows
|
||||
|
||||
package cat
|
||||
|
||||
// The TCI control panel: what the radio already tells us, gathered up.
|
||||
//
|
||||
// This is the cheapest panel in OpsLog, and the reason is worth saying. A K3 is
|
||||
// asked — every value on its console costs a command and a reply on a serial
|
||||
// line, which is why that panel reads its settings in a rotation and its meters
|
||||
// only while it is on screen. TCI PUSHES: the radio announces its drive, its
|
||||
// volume, its filters, its noise blanker and everything else when a client
|
||||
// connects, and again whenever any of them changes, whoever changed it. There
|
||||
// is nothing to poll.
|
||||
//
|
||||
// So this file is mostly a place to PUT what was already arriving and being
|
||||
// logged as "(unhandled once)". The setters are the same names sent back the
|
||||
// other way, which is how TCI works throughout: one vocabulary, both directions.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// TCIPanelState is the whole console in one snapshot, polled by the frontend.
|
||||
//
|
||||
// Values the radio has not mentioned keep their zero, which is why the
|
||||
// "Known" flags exist for the ones where zero is a real setting: a squelch at 0
|
||||
// and a squelch never reported are different, and a panel that cannot tell them
|
||||
// apart draws a control that lies until the operator touches it.
|
||||
type TCIPanelState struct {
|
||||
Connected bool `json:"connected"`
|
||||
Device string `json:"device,omitempty"` // what the radio calls itself
|
||||
Protocol string `json:"protocol,omitempty"` // "ExpertSDR3,1.5"
|
||||
|
||||
// Transmit.
|
||||
Drive int `json:"drive"` // 0-100
|
||||
TuneDrive int `json:"tune_drive"` // 0-100, used by TUNE
|
||||
MicLevel int `json:"mic_level"` // 0-100
|
||||
TXEnabled bool `json:"tx_enabled"` // the radio's own permission (tx_enable)
|
||||
TX bool `json:"tx"`
|
||||
Tuning bool `json:"tuning"`
|
||||
|
||||
// Receive.
|
||||
Volume int `json:"volume"` // dB, negative — TCI's own scale
|
||||
Mute bool `json:"mute"`
|
||||
AGC string `json:"agc,omitempty"` // off/long/slow/med/fast
|
||||
SquelchOn bool `json:"squelch_on"`
|
||||
Squelch int `json:"squelch"` // dBm threshold
|
||||
NB bool `json:"nb"`
|
||||
NR bool `json:"nr"`
|
||||
ANF bool `json:"anf"`
|
||||
APF bool `json:"apf"`
|
||||
|
||||
// Filter edges in Hz, relative to the carrier (TCI's own convention).
|
||||
FilterLo int `json:"filter_lo"`
|
||||
FilterHi int `json:"filter_hi"`
|
||||
|
||||
// Tuning aids.
|
||||
RIT bool `json:"rit"`
|
||||
RITOffset int `json:"rit_offset"`
|
||||
XIT bool `json:"xit"`
|
||||
XITOffset int `json:"xit_offset"`
|
||||
Lock bool `json:"lock"`
|
||||
Split bool `json:"split"`
|
||||
|
||||
// SMeter is the last reported signal level in dBm — the radio pushes it
|
||||
// several times a second while receiving.
|
||||
SMeter int `json:"smeter"`
|
||||
|
||||
// Modulations is what this radio will accept, straight from its own
|
||||
// announcement, so the mode buttons are the radio's and not a guess.
|
||||
Modulations []string `json:"modulations,omitempty"`
|
||||
}
|
||||
|
||||
// tciPanel is the backing state. Guarded by TCI.mu with everything else it
|
||||
// arrives alongside.
|
||||
type tciPanel struct {
|
||||
st TCIPanelState
|
||||
}
|
||||
|
||||
// handlePanel takes the messages the console cares about.
|
||||
//
|
||||
// Returns false when the message is none of its business, so the caller can go
|
||||
// on to its own cases and to the unknown-message log. Called with t.mu held.
|
||||
func (t *TCI) handlePanel(name string, get func(int) string, args string) bool {
|
||||
// Most of these are per-receiver ("sql_level:0,20"), and OpsLog follows
|
||||
// receiver 0 throughout. A message for another receiver is accepted as
|
||||
// handled and dropped: it is understood, it is simply not ours.
|
||||
forRX0 := func() bool { return get(0) == "0" || get(0) == "" }
|
||||
num := func(s string) (int, bool) {
|
||||
n, err := strconv.Atoi(strings.TrimSpace(s))
|
||||
return n, err == nil
|
||||
}
|
||||
yes := func(s string) bool { return strings.EqualFold(strings.TrimSpace(s), "true") }
|
||||
|
||||
p := &t.panel.st
|
||||
switch name {
|
||||
case "protocol":
|
||||
p.Protocol = strings.TrimSpace(args)
|
||||
case "drive":
|
||||
if n, ok := num(get(1)); ok && forRX0() {
|
||||
p.Drive = n
|
||||
} else if n, ok := num(get(0)); ok && get(1) == "" {
|
||||
// Some firmware sends "drive:85" with no receiver index.
|
||||
p.Drive = n
|
||||
}
|
||||
case "tune_drive":
|
||||
if n, ok := num(get(1)); ok && forRX0() {
|
||||
p.TuneDrive = n
|
||||
} else if n, ok := num(get(0)); ok && get(1) == "" {
|
||||
p.TuneDrive = n
|
||||
}
|
||||
case "mic_level":
|
||||
if n, ok := num(get(0)); ok {
|
||||
p.MicLevel = n
|
||||
}
|
||||
case "volume":
|
||||
if n, ok := num(get(0)); ok {
|
||||
p.Volume = n
|
||||
}
|
||||
case "mute":
|
||||
p.Mute = yes(get(1))
|
||||
case "agc_mode":
|
||||
if forRX0() {
|
||||
p.AGC = strings.ToLower(strings.TrimSpace(get(1)))
|
||||
}
|
||||
case "sql_enable":
|
||||
if forRX0() {
|
||||
p.SquelchOn = yes(get(1))
|
||||
}
|
||||
case "sql_level":
|
||||
if n, ok := num(get(1)); ok && forRX0() {
|
||||
p.Squelch = n
|
||||
}
|
||||
case "rx_nb_enable":
|
||||
if forRX0() {
|
||||
p.NB = yes(get(1))
|
||||
}
|
||||
case "rx_nr_enable":
|
||||
if forRX0() {
|
||||
p.NR = yes(get(1))
|
||||
}
|
||||
case "rx_anf_enable":
|
||||
if forRX0() {
|
||||
p.ANF = yes(get(1))
|
||||
}
|
||||
case "rx_apf_enable":
|
||||
if forRX0() {
|
||||
p.APF = yes(get(1))
|
||||
}
|
||||
case "rx_filter_band":
|
||||
if forRX0() {
|
||||
if lo, ok := num(get(1)); ok {
|
||||
p.FilterLo = lo
|
||||
}
|
||||
if hi, ok := num(get(2)); ok {
|
||||
p.FilterHi = hi
|
||||
}
|
||||
}
|
||||
case "rit_enable":
|
||||
if forRX0() {
|
||||
p.RIT = yes(get(1))
|
||||
}
|
||||
case "xit_enable":
|
||||
if forRX0() {
|
||||
p.XIT = yes(get(1))
|
||||
}
|
||||
case "rit_offset":
|
||||
if n, ok := num(get(1)); ok && forRX0() {
|
||||
p.RITOffset = n
|
||||
}
|
||||
case "xit_offset":
|
||||
if n, ok := num(get(1)); ok && forRX0() {
|
||||
p.XITOffset = n
|
||||
}
|
||||
case "lock":
|
||||
if forRX0() {
|
||||
p.Lock = yes(get(1))
|
||||
}
|
||||
case "rx_smeter":
|
||||
if n, ok := num(get(1)); ok && forRX0() {
|
||||
p.SMeter = n
|
||||
}
|
||||
case "tune":
|
||||
if forRX0() {
|
||||
p.Tuning = yes(get(1))
|
||||
}
|
||||
case "modulations_list":
|
||||
p.Modulations = splitAndTrim(args)
|
||||
default:
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// splitAndTrim turns "usb,lsb,cw" into a slice, upper-cased for display.
|
||||
func splitAndTrim(s string) []string {
|
||||
parts := strings.Split(s, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, p := range parts {
|
||||
if v := strings.ToUpper(strings.TrimSpace(p)); v != "" {
|
||||
out = append(out, v)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// TCIPanel returns the console snapshot.
|
||||
func (t *TCI) TCIPanel() TCIPanelState {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
st := t.panel.st
|
||||
st.Connected = t.conn != nil
|
||||
st.Device = t.device
|
||||
st.TX = t.tx
|
||||
st.Split = t.split
|
||||
st.TXEnabled = t.txAllowed || !t.txAllowedKnown
|
||||
return st
|
||||
}
|
||||
|
||||
// ── Setters ───────────────────────────────────────────────────────────────
|
||||
//
|
||||
// Every one of them is a SET in the same vocabulary the radio reports in, and
|
||||
// none of them updates the cached state: the radio answers with the new value,
|
||||
// and taking its word rather than our own is what keeps the panel honest when a
|
||||
// setting is refused, clamped, or changed from the radio's own window a second
|
||||
// later.
|
||||
|
||||
// SetDrive sets the transmit drive, 0-100.
|
||||
func (t *TCI) SetDrive(v int) error { return t.send(fmt.Sprintf("drive:%d;", clampTCIPct(v))) }
|
||||
|
||||
// SetTuneDrive sets the drive used by TUNE, 0-100.
|
||||
func (t *TCI) SetTuneDrive(v int) error { return t.send(fmt.Sprintf("tune_drive:%d;", clampTCIPct(v))) }
|
||||
|
||||
// SetMicLevel sets the microphone gain, 0-100.
|
||||
func (t *TCI) SetMicLevel(v int) error { return t.send(fmt.Sprintf("mic_level:%d;", clampTCIPct(v))) }
|
||||
|
||||
// SetVolume sets the receive volume in dB. TCI's scale is negative — 0 is full
|
||||
// and -60 is inaudible — so this is NOT clamped to a percentage.
|
||||
func (t *TCI) SetVolume(db int) error {
|
||||
if db > 0 {
|
||||
db = 0
|
||||
}
|
||||
if db < -60 {
|
||||
db = -60
|
||||
}
|
||||
return t.send(fmt.Sprintf("volume:%d;", db))
|
||||
}
|
||||
|
||||
// SetMute mutes or unmutes the receiver.
|
||||
func (t *TCI) SetMute(on bool) error { return t.send(fmt.Sprintf("mute:%t;", on)) }
|
||||
|
||||
// SetAGC picks the AGC speed: off, long, slow, med, fast.
|
||||
func (t *TCI) SetAGC(mode string) error {
|
||||
m := strings.ToLower(strings.TrimSpace(mode))
|
||||
switch m {
|
||||
case "off", "long", "slow", "med", "fast":
|
||||
default:
|
||||
return fmt.Errorf("unknown AGC mode %q", mode)
|
||||
}
|
||||
return t.send(fmt.Sprintf("agc_mode:0,%s;", m))
|
||||
}
|
||||
|
||||
// SetSquelch turns the squelch on or off.
|
||||
func (t *TCI) SetSquelch(on bool) error { return t.send(fmt.Sprintf("sql_enable:0,%t;", on)) }
|
||||
|
||||
// SetSquelchLevel sets the threshold in dBm.
|
||||
func (t *TCI) SetSquelchLevel(v int) error { return t.send(fmt.Sprintf("sql_level:0,%d;", v)) }
|
||||
|
||||
// SetNB, SetNR, SetANF, SetAPF switch the receive processing.
|
||||
func (t *TCI) SetNB(on bool) error { return t.send(fmt.Sprintf("rx_nb_enable:0,%t;", on)) }
|
||||
func (t *TCI) SetNR(on bool) error { return t.send(fmt.Sprintf("rx_nr_enable:0,%t;", on)) }
|
||||
func (t *TCI) SetANF(on bool) error { return t.send(fmt.Sprintf("rx_anf_enable:0,%t;", on)) }
|
||||
func (t *TCI) SetAPF(on bool) error { return t.send(fmt.Sprintf("rx_apf_enable:0,%t;", on)) }
|
||||
|
||||
// SetFilter sets the passband edges in Hz.
|
||||
func (t *TCI) SetFilter(lo, hi int) error {
|
||||
if lo > hi {
|
||||
lo, hi = hi, lo
|
||||
}
|
||||
return t.send(fmt.Sprintf("rx_filter_band:0,%d,%d;", lo, hi))
|
||||
}
|
||||
|
||||
// SetRIT / SetXIT switch the offsets on, SetRITOffset / SetXITOffset move them.
|
||||
func (t *TCI) SetRIT(on bool) error { return t.send(fmt.Sprintf("rit_enable:0,%t;", on)) }
|
||||
func (t *TCI) SetXIT(on bool) error { return t.send(fmt.Sprintf("xit_enable:0,%t;", on)) }
|
||||
func (t *TCI) SetRITOffset(hz int) error { return t.send(fmt.Sprintf("rit_offset:0,%d;", hz)) }
|
||||
func (t *TCI) SetXITOffset(hz int) error { return t.send(fmt.Sprintf("xit_offset:0,%d;", hz)) }
|
||||
|
||||
// SetLock locks the VFO knob on the radio.
|
||||
func (t *TCI) SetLock(on bool) error { return t.send(fmt.Sprintf("lock:0,%t;", on)) }
|
||||
|
||||
// SetTune starts or stops the tune carrier.
|
||||
//
|
||||
// It TRANSMITS, at tune_drive rather than at drive — which is the setting to
|
||||
// check before pressing it, and why the panel shows the two side by side.
|
||||
func (t *TCI) SetTune(on bool) error { return t.send(fmt.Sprintf("tune:0,%t;", on)) }
|
||||
|
||||
func clampTCIPct(v int) int {
|
||||
if v < 0 {
|
||||
return 0
|
||||
}
|
||||
if v > 100 {
|
||||
return 100
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user