The transmit meters are asked for four times a second while keyed, so logging every arrival would fill an evening's log — and a diagnostic that fills a log is one that gets switched off instead of read. Twenty of each is enough to tell an answer from a silence, which is all it is for.
382 lines
13 KiB
Go
382 lines
13 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"`
|
|
|
|
// TXPowerW and TXSWR are the transmit meters. READ-ONLY in TCI, and only
|
|
// answered while transmitting — asked for on every poll of a keyed radio,
|
|
// see ReadState.
|
|
//
|
|
// There is no temperature in this protocol. The command list has TX_POWER
|
|
// and TX_SWR and nothing thermal at all, so a temperature reading here would
|
|
// have to be invented, and an invented temperature on a transmitter is the
|
|
// kind of number somebody trusts.
|
|
TXPowerW float64 `json:"tx_power_w"`
|
|
TXSWR float64 `json:"tx_swr"`
|
|
|
|
// 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
|
|
// logged counts what has been written per message type — see handlePanel.
|
|
logged map[string]int
|
|
}
|
|
|
|
// 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
|
|
// Mute and squelch are LOGGED as they change, because a report from a real
|
|
// radio says pressing MUTE lights the squelch and nothing here can explain
|
|
// it. What the radio actually announces after the command settles whether
|
|
// this is our reading or its doing, and no amount of reasoning will.
|
|
switch name {
|
|
case "mute", "sql_enable", "sql_level", "tx_power", "tx_swr", "tune":
|
|
// Logged on arrival so an ANSWER can be told from a SILENCE: the log
|
|
// showed the transmit meters being asked for and nothing coming back,
|
|
// which on its own proves nothing — a reply that arrived and failed to
|
|
// parse leaves exactly the same trace as one that never came.
|
|
//
|
|
// Capped per message type. The meters are asked for four times a second
|
|
// while transmitting, and a diagnostic that fills an evening's log is
|
|
// one that gets switched off instead of read.
|
|
if t.panel.logged == nil {
|
|
t.panel.logged = map[string]int{}
|
|
}
|
|
if n := t.panel.logged[name]; n < 20 {
|
|
t.panel.logged[name] = n + 1
|
|
debugLog.Printf("TCI: %s:%s", name, args)
|
|
}
|
|
}
|
|
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 "tx_power":
|
|
if v, err := strconv.ParseFloat(strings.TrimSpace(get(0)), 64); err == nil {
|
|
p.TXPowerW = v
|
|
}
|
|
case "tx_swr":
|
|
if v, err := strconv.ParseFloat(strings.TrimSpace(get(0)), 64); err == nil {
|
|
p.TXSWR = v
|
|
}
|
|
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.
|
|
//
|
|
// The state is recorded HERE rather than waited for. This radio does not echo
|
|
// "tune:0,true", so the panel had no way of knowing a tune was running: the
|
|
// button stayed on TUNE and every further press sent another START, which is
|
|
// why it could not be switched off again. Whatever the radio says afterwards
|
|
// still wins — it simply never says anything.
|
|
func (t *TCI) SetTune(on bool) error {
|
|
t.mu.Lock()
|
|
t.panel.st.Tuning = on
|
|
t.mu.Unlock()
|
|
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
|
|
}
|