Three things reported from the radio, all of them mine.
THE LEVELS. Two half-width sliders side by side left each of them a couple
of centimetres long, which is not enough to set 15% with. One level per
row now, full width, with the value typed in or stepped with ± — and in
the radio's own units, so the volume is dB and the squelch a dBm
threshold, matching what ExpertSDR3's own window shows.
THE FILTERS. The button said 250 and the radio was set to 300-550: 250 Hz
wide, but sitting where no CW note is. The edges are computed from the
width now, and a narrow filter is CENTRED ON THE CW NOTE — a 250 Hz filter
from 100 to 350 would put the note outside its own passband. A button also
lights on the WIDTH the radio reports rather than on an exact pair of
edges, so moving one edge on the radio no longer darkens every button.
MUTE. Read from one shape only, while this radio reports the other
('mute:0,false'), so the button showed the opposite of the truth. Both are
accepted now.
None of this should have reached main before somebody had a radio in front
of it.
328 lines
11 KiB
Go
328 lines
11 KiB
Go
//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":
|
|
// Both shapes. This radio reports "mute:0,false" and the reference shows
|
|
// "mute:true" elsewhere — reading only one of them left the button
|
|
// showing the opposite of the truth, which is worse than showing
|
|
// nothing.
|
|
if get(1) != "" {
|
|
p.Mute = yes(get(1))
|
|
} else {
|
|
p.Mute = yes(get(0))
|
|
}
|
|
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.
|
|
//
|
|
// THE TRX INDEX IS PART OF THE COMMAND — "drive:0,15;", not "drive:15;". Sent
|
|
// without it the radio simply ignores it: no error, no answer, the power
|
|
// unchanged. The rule is the one the radio's own reports follow, and it was
|
|
// there to read all along: this radio announces "drive:0,85" at connect.
|
|
func (t *TCI) SetDrive(v int) error { return t.send(fmt.Sprintf("drive:0,%d;", clampTCIPct(v))) }
|
|
|
|
// SetTuneDrive sets the drive used by TUNE, 0-100. Indexed, like drive.
|
|
func (t *TCI) SetTuneDrive(v int) error {
|
|
return t.send(fmt.Sprintf("tune_drive:0,%d;", clampTCIPct(v)))
|
|
}
|
|
|
|
// SetMicLevel sets the microphone gain, 0-100.
|
|
// Mic gain and volume are the two that are NOT indexed — the radio reports
|
|
// them as "mic_level:100" and "volume:-12", with no receiver in front. Sending
|
|
// the shape the radio speaks in is the whole rule here.
|
|
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. Indexed — the radio reports
|
|
// "mute:0,false", and a mute sent without the index goes nowhere.
|
|
func (t *TCI) SetMute(on bool) error { return t.send(fmt.Sprintf("mute:0,%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
|
|
}
|