Files
OpsLog/internal/cat/tci.go
T
rouggy 01a23ccb77 feat(tci): read the radio's declared format, and record a test WAV
The SunSDR announces its own stream at connect —
audio_stream_sample_type:float32 and audio_stream_channels:2 — and both
were being logged as unhandled while the code worked the format out from
frame arithmetic. The declaration is better evidence and arrives before
the first frame; the arithmetic stays as the check on it. The channel
count now drives the mix-down instead of an assumed stereo.

Adds a ten-second test recording, written as a WAV beside the QSO
recordings. Counting frames proves a socket is delivering bytes; it says
nothing about whether those bytes are the receiver's audio, at the right
rate, in the right order. A stream decoded with the width wrong or the
samples misaligned counts exactly as well as a correct one and sounds
like a fan — so the test is a file the operator can play, the same way
the CW decoder was settled on the air rather than on a spectrogram.

The file is written at the rate the RADIO reported, not a constant: a
recording at the wrong rate plays at the wrong speed, which is the one
fault that would be blamed on the decoding.
2026-08-25 08:25:29 +02:00

552 lines
18 KiB
Go

//go:build windows
package cat
import (
"context"
"fmt"
"net"
"strconv"
"strings"
"sync"
"time"
"github.com/gorilla/websocket"
)
// TCI is a native backend for Expert Electronics' TCI protocol (SunSDR2/MB1/
// ColibriNANO via ExpertSDR2/EESDR, and TCI-compatible apps). TCI is a text
// protocol over a WebSocket: the server streams state ("vfo:0,0,14100000;",
// "modulation:0,cw;", "trx:0,true;") and accepts the same commands to control
// the rig. We keep the pushed state cached so ReadState is instant, like Flex.
//
// Pure Go (gorilla/websocket, no CGO). Default port 40001.
type TCI struct {
host string
port int
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
spotsEnabled bool // mirror cluster spots onto the TCI panorama
// OnSpotClick is called when the user clicks one of our spots on the TCI
// panorama (callsign + freq), so the host can fill the entry form. Set before
// Connect. Mirrors the FlexRadio panadapter-click flow.
OnSpotClick func(callsign string, freqHz int64)
unhandledSeen map[string]bool // log each unknown TCI message type once
// 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.
audio tciAudio
mu sync.Mutex // guards conn + writes + state
conn *websocket.Conn
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
ready bool
// Cached state pushed by the radio.
device string
freqA int64 // VFO A (RX) frequency, Hz (vfo:0,0)
freqB int64 // VFO B (TX in split), Hz (vfo:0,1)
mode string
split bool
tx bool
// txAllowed is what the radio last said about TRANSMIT PERMISSION.
//
// TX_ENABLE is sent by ExpertSDR when a client connects and again whenever
// the band changes, "in case transmitter permission was changed" (§4.3). When
// it is false the radio silently ignores TRX — which is exactly what an
// operator sees as "PTT does nothing", with no error anywhere to explain it.
//
// txAllowedKnown keeps an OLDER ExpertSDR, or a TCI-compatible program that
// never sends TX_ENABLE at all, from being treated as refusing: without a
// word from the radio we key and let it decide.
txAllowed bool
txAllowedKnown bool
lastSig string // last logged state signature (log only on change)
// spotFreq is the frequency of the marker currently on the panorama for each
// callsign — the panadapter's own state, which TCI never reports back. It is
// what makes one spot per call possible: without it there is no way to know
// there is an older marker to delete.
spotFreq map[string]int64
}
func absInt64(v int64) int64 {
if v < 0 {
return -v
}
return v
}
const tciDefaultPort = 40001
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
// mode surfaced when the radio reports a generic digital modulation; spots turns
// on mirroring OpsLog's cluster spots onto the TCI panorama.
func NewTCI(host string, port int, digitalDefault string, spots bool) *TCI {
if port <= 0 || port > 65535 {
port = tciDefaultPort
}
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
}
func (t *TCI) Name() string { return "tci" }
// Connect opens the WebSocket and starts the reader goroutine. The reader keeps
// our cached state current from the radio's push messages.
func (t *TCI) Connect() error {
t.mu.Lock()
already := t.conn != nil
host, port := t.host, t.port
t.mu.Unlock()
if already {
return nil
}
if host == "" {
return fmt.Errorf("tci: no host configured")
}
url := fmt.Sprintf("ws://%s", net.JoinHostPort(host, strconv.Itoa(port)))
// Cancellable dial so Interrupt() (Stop / Settings "Save & Close") aborts it at
// once instead of waiting out a dead server's 5 s handshake timeout.
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
t.mu.Lock()
t.dialCancel = cancel
t.mu.Unlock()
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
conn, _, err := dialer.DialContext(ctx, url, nil)
cancel()
t.mu.Lock()
t.dialCancel = nil
t.mu.Unlock()
if err != nil {
return fmt.Errorf("tci: connect %s: %w", url, err)
}
t.mu.Lock()
t.conn = conn
t.ready = false
// Forget the previous session's transmit permission: the radio announces it
// again on connect, and a refusal remembered from a band we have since left
// would block PTT until it did.
t.txAllowed, t.txAllowedKnown = false, false
t.mu.Unlock()
debugLog.Printf("TCI: connected to %s", url)
go t.reader(conn)
if t.spotsEnabled {
// Forget what we thought was on the panorama at the same moment the radio
// is told to drop it. Kept, the memory would suppress the next spot for
// each of those calls as "already drawn" onto a panorama now empty.
t.mu.Lock()
t.spotFreq = map[string]int64{}
t.mu.Unlock()
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
}
return nil
}
// spotFreqTolHz is how far a re-spot of the same callsign may sit from the one
// already on the panorama before it is treated as a move rather than the same
// spot said again.
//
// Two spotters hearing the same CW station rarely agree to better than a couple
// of hundred hertz, and every one of them produces a cluster line. Below this
// they are the same spot and nothing is sent at all; above it the marker is
// deleted and redrawn where the station now is.
const spotFreqTolHz = 500
// noteSpot records what the panorama is about to hold for a callsign and says
// what has to be sent: whether to draw at all, and whether an older marker for
// the same call must be deleted first.
//
// Separate from SendSpot so the rule can be tested without a radio — and
// because the lock must be released before anything is sent: t.send takes t.mu
// itself, Go mutexes are not reentrant, and sending while holding it would
// deadlock the backend and take the rig offline.
func (t *TCI) noteSpot(call string, freqHz int64) (draw, deletePrev bool) {
key := strings.ToUpper(strings.TrimSpace(call))
t.mu.Lock()
defer t.mu.Unlock()
prev, had := t.spotFreq[key]
if had && absInt64(prev-freqHz) <= spotFreqTolHz {
return false, false
}
if t.spotFreq == nil {
t.spotFreq = map[string]int64{}
}
if len(t.spotFreq) > 4000 {
t.spotFreq = map[string]int64{} // bound memory on a long session
had = false // forgotten: nothing left to delete by name
}
t.spotFreq[key] = freqHz
return true, had
}
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
// No-op when spot mirroring is disabled.
//
// ONE MARKER PER CALLSIGN. This code assumed the radio replaced a spot carrying
// a callsign it already had; it does not. ExpertSDR keys a spot on its
// frequency too, so a DX station spotted by three operators — 14025.00,
// 14025.12, 14024.90, which is an ordinary minute on a cluster — was drawn
// three times, a few pixels apart, and stayed that way.
//
// So the previous spot for the call is deleted before the new one is sent,
// which is what the FlexRadio backend has always done (spot remove / spot add).
func (t *TCI) SendSpot(s SpotInfo) error {
if !t.spotsEnabled {
return nil
}
call := strings.TrimSpace(s.Callsign)
if call == "" || s.FreqHz <= 0 {
return nil
}
draw, deletePrev := t.noteSpot(call, s.FreqHz)
if !draw {
return nil // the same station said again by another spotter
}
if deletePrev {
// SPOT_DELETE takes the callsign alone. Not in the protocol PDF this
// backend was written from; confirmed against ars-ka0s/eesdr-tci, which
// lists SPOT (5 arguments), SPOT_DELETE (1) and SPOT_CLEAR (0) — the
// other two matching what already works here.
_ = t.send(fmt.Sprintf("spot_delete:%s;", call))
}
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
// can't parse as a number, which is why spots never showed on the panorama
// while tuning (a separate command) still worked.
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
if hex == "" {
hex = "FFFFA500" // opaque orange default
}
if len(hex) == 6 {
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
}
argb, err := strconv.ParseUint(hex, 16, 32)
if err != nil {
argb = 0xFFFFA500
}
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
// fall back to the raw label if we can't map it. The click-to-tune path already
// maps the mode separately, so this only affects the spot's displayed mode.
mode := adifToTCIMode(s.Mode, s.FreqHz)
if mode == "" {
mode = strings.ToLower(strings.TrimSpace(s.Mode))
}
// Commas/semicolons would break TCI's comma-separated argument parsing.
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
}
// Disconnect closes the WebSocket; the reader goroutine then exits.
func (t *TCI) Disconnect() {
t.mu.Lock()
c := t.conn
t.conn = nil
t.ready = false
t.mu.Unlock()
if c != nil {
_ = c.WriteMessage(websocket.CloseMessage, websocket.FormatCloseMessage(websocket.CloseNormalClosure, ""))
_ = c.Close()
}
}
// Interrupt aborts an in-flight Connect dial so Stop()/Start() don't block on a
// dead server's handshake timeout. Satisfies the Manager's interruptible
// interface. Safe from another goroutine; a no-op when not dialing.
func (t *TCI) Interrupt() {
t.mu.Lock()
cancel := t.dialCancel
c := t.conn
t.mu.Unlock()
if cancel != nil {
cancel()
}
if c != nil {
_ = c.Close()
}
}
// ReadState returns the cached state pushed by the radio.
func (t *TCI) ReadState() (RigState, error) {
t.mu.Lock()
defer t.mu.Unlock()
if t.conn == nil {
return RigState{}, fmt.Errorf("tci: not connected")
}
st := RigState{Connected: t.ready, Rig: t.device}
if !t.ready {
return st, nil
}
// ADIF convention: FreqHz is the TX freq. In split, TX is VFO B.
if t.split && t.freqB > 0 {
st.FreqHz = t.freqB
st.RxFreqHz = t.freqA
st.Split = true
} else {
st.FreqHz = t.freqA
}
st.Mode = tciModeToADIF(t.mode, t.digitalDefault)
if st.FreqHz > 0 {
st.Band = BandFromHz(st.FreqHz)
}
sig := fmt.Sprintf("%d/%d/%v/%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
if sig != t.lastSig {
t.lastSig = sig
debugLog.Printf("TCI: state tx=%d rx=%d split=%v mode=%s", st.FreqHz, st.RxFreqHz, st.Split, st.Mode)
}
return st, nil
}
// SetFrequency tunes VFO A (the main/RX VFO).
func (t *TCI) SetFrequency(hz int64) error {
return t.send(fmt.Sprintf("vfo:0,0,%d;", hz))
}
// SetMode maps an ADIF mode to a TCI modulation and sets it. USB vs LSB is
// chosen from the current VFO-A frequency (< 10 MHz → LSB).
func (t *TCI) SetMode(mode string) error {
t.mu.Lock()
freq := t.freqA
t.mu.Unlock()
m := adifToTCIMode(mode, freq)
if m == "" {
return nil
}
return t.send(fmt.Sprintf("modulation:0,%s;", m))
}
// SetPTT keys or unkeys the transmitter (VFO 0).
//
// A refusal by the radio is reported rather than swallowed. ExpertSDR announces
// transmit permission with TX_ENABLE and then simply IGNORES trx when it is
// false — out-of-band frequency, TX disabled in the program, no PA. The command
// went out, nothing happened, and nothing anywhere said why. Now the operator
// is told, and the message names the place to look.
func (t *TCI) SetPTT(on bool) error {
if on {
t.mu.Lock()
known, allowed := t.txAllowedKnown, t.txAllowed
t.mu.Unlock()
if known && !allowed {
return fmt.Errorf("the radio is refusing to transmit (TCI reports TX disabled) — " +
"check the frequency is inside a transmit band and that TX is enabled in ExpertSDR")
}
}
return t.send(fmt.Sprintf("trx:0,%t;", on))
}
// send writes a command to the WebSocket (one writer at a time).
func (t *TCI) send(cmd string) error {
t.mu.Lock()
c := t.conn
t.mu.Unlock()
if c == nil {
return fmt.Errorf("tci: not connected")
}
_ = c.SetWriteDeadline(time.Now().Add(3 * time.Second))
if err := c.WriteMessage(websocket.TextMessage, []byte(cmd)); err != nil {
debugLog.Printf("TCI: send %q failed: %v", cmd, err)
return err
}
debugLog.Printf("TCI: → %s", cmd)
return nil
}
// reader drains push messages and keeps the cached state current until the
// connection closes.
func (t *TCI) reader(conn *websocket.Conn) {
for {
mt, data, err := conn.ReadMessage()
if err != nil {
break
}
// TEXT frames are commands, BINARY frames are streams. The type used to
// be ignored and every frame split on ';' — harmless only for as long as
// no stream was ever opened, since audio bytes would then have been fed
// to the command parser a hundred times a second.
if wsMessageIsBinary(mt) {
t.handleBinary(data)
continue
}
// A frame may carry several ";"-terminated commands.
for _, cmd := range strings.Split(string(data), ";") {
t.handle(strings.TrimSpace(cmd))
}
}
t.mu.Lock()
if t.conn == conn {
t.conn = nil
t.ready = false
}
t.mu.Unlock()
debugLog.Printf("TCI: reader ended")
}
// handle parses one "command:args" message and updates the cache.
func (t *TCI) handle(msg string) {
if msg == "" {
return
}
name, args := msg, ""
if i := strings.IndexByte(msg, ':'); i >= 0 {
name, args = msg[:i], msg[i+1:]
}
f := strings.Split(args, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
t.mu.Lock()
defer t.mu.Unlock()
switch strings.ToLower(name) {
case "device":
t.device = strings.TrimSpace(args)
// The radio ANNOUNCES its audio format at connect —
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
// is better evidence than anything derived from a frame, and it arrives
// before the first frame does. Both were being logged as unhandled.
case "audio_stream_sample_type":
t.audio.declaredType = strings.TrimSpace(args)
case "audio_stream_channels":
if n, err := strconv.Atoi(strings.TrimSpace(args)); err == nil && n > 0 && n <= 8 {
t.audio.declaredChans = n
}
case "ready", "start":
t.ready = true
case "stop":
t.ready = false
case "vfo":
// vfo:<rx>,<channel>,<freq>
if get(0) == "0" {
hz, _ := strconv.ParseInt(get(2), 10, 64)
if hz > 0 {
t.ready = true // receiving live state → treat as ready even without an explicit "ready;"
switch get(1) {
case "0":
t.freqA = hz
case "1":
t.freqB = hz
}
}
}
case "modulation":
if get(0) == "0" {
t.mode = strings.ToLower(get(1))
}
case "split_enable":
if get(0) == "0" {
t.split = get(1) == "true"
}
case "trx":
if get(0) == "0" {
t.tx = get(1) == "true"
}
case "tx_enable":
if get(0) == "0" {
allowed := get(1) == "true"
if !t.txAllowedKnown || t.txAllowed != allowed {
debugLog.Printf("TCI: the radio %s transmitting", map[bool]string{true: "allows", false: "REFUSES"}[allowed])
}
t.txAllowed, t.txAllowedKnown = allowed, true
}
default:
lname := strings.ToLower(name)
// 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>
// Neither name starts with "spot", which is why the click was silently
// ignored before. Read the callsign (the one non-numeric field) and the
// frequency (the large numeric field) positionally-independently, so both
// shapes work without depending on the exact arg order.
if strings.Contains(lname, "spot") {
var call string
var hz int64
for _, raw := range f {
v := strings.TrimSpace(raw)
if v == "" {
continue
}
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
if n >= 10000 { // a real frequency, not an rx/channel index
hz = n
}
} else if call == "" {
call = strings.ToUpper(v) // callsigns always carry letters
}
}
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
if call != "" && t.OnSpotClick != nil {
cb := t.OnSpotClick
go cb(call, hz)
}
return
}
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
// spot-click notification named differently) is discoverable from the log
// without flooding it with the frequent streamed messages.
if t.unhandledSeen == nil {
t.unhandledSeen = map[string]bool{}
}
if !t.unhandledSeen[lname] {
t.unhandledSeen[lname] = true
debugLog.Printf("TCI: (unhandled once) %s", msg)
}
}
}
// tciModeToADIF converts a TCI modulation to an ADIF mode. Generic digital
// modulations surface the operator's chosen digital default (FT8/FT4/RTTY…).
func tciModeToADIF(m, digitalDefault string) string {
switch strings.ToLower(strings.TrimSpace(m)) {
case "usb", "lsb", "dsb":
return "SSB"
case "cw":
return "CW"
case "am", "sam":
return "AM"
case "nfm", "wfm", "fm":
return "FM"
case "digu", "digl":
if digitalDefault != "" {
return strings.ToUpper(digitalDefault)
}
return "DATA"
case "drm":
return "DIGITALVOICE"
case "":
return ""
default:
return strings.ToUpper(m)
}
}
// adifToTCIMode maps an ADIF mode to a TCI modulation. USB/LSB is chosen from
// the frequency (< 10 MHz → LSB) as usual. Digital modes → digu.
func adifToTCIMode(mode string, freqHz int64) string {
switch strings.ToUpper(strings.TrimSpace(mode)) {
case "SSB", "USB", "LSB":
if freqHz > 0 && freqHz < 10_000_000 {
return "lsb"
}
return "usb"
case "CW", "CWR", "CW-R":
return "cw"
case "AM":
return "am"
case "FM", "NFM":
return "nfm"
case "RTTY":
return "digl"
case "":
return ""
default:
// FT8/FT4/PSK/DATA/JT… → upper-sideband digital.
return "digu"
}
}