350 lines
9.1 KiB
Go
350 lines
9.1 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
|
|
|
|
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
|
|
|
|
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)))
|
|
// 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
|
|
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()
|
|
}
|
|
}
|
|
|
|
// 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).
|
|
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:<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"
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|