//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 }