//go:build windows package cat import ( "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 mu sync.Mutex // guards conn + writes + state conn *websocket.Conn 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 lastSig string // last logged state signature (log only on change) } 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))) dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second} conn, _, err := dialer.Dial(url, nil) if err != nil { return fmt.Errorf("tci: connect %s: %w", url, err) } t.mu.Lock() t.conn = conn t.ready = false t.mu.Unlock() debugLog.Printf("TCI: connected to %s", url) go t.reader(conn) if t.spotsEnabled { _ = t.send("spot_clear;") // drop any leftover spots from a previous session } return nil } // SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter). // The radio replaces a spot that has the same callsign, so re-spotting updates // it in place. No-op when spot mirroring is disabled. func (t *TCI) SendSpot(s SpotInfo) error { if !t.spotsEnabled { return nil } call := strings.TrimSpace(s.Callsign) if call == "" || s.FreqHz <= 0 { return nil } color := strings.TrimSpace(s.Color) if color == "" { color = "#FFFFA500" // opaque orange default } color = "0x" + strings.TrimPrefix(color, "#") 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,%s,%s;", call, mode, s.FreqHz, color, 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() } } // 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). func (t *TCI) SetPTT(on bool) error { 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 { _, data, err := conn.ReadMessage() if err != nil { break } // 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) case "ready", "start": t.ready = true case "stop": t.ready = false case "vfo": // vfo:,, 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" } } } // 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" } }