From 812e4f05e5ae765eb4ddcb5d0fe604280e1506fc Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 3 Jul 2026 15:11:32 +0200 Subject: [PATCH] feat: TCI implementation for CAT Control of SunSDR --- app.go | 17 +- frontend/src/components/SettingsModal.tsx | 20 +- frontend/wailsjs/go/models.ts | 4 + go.mod | 2 +- internal/cat/tci.go | 294 ++++++++++++++++++++++ 5 files changed, 334 insertions(+), 3 deletions(-) create mode 100644 internal/cat/tci.go diff --git a/app.go b/app.go index 4bafcfb..eb9a9a9 100644 --- a/app.go +++ b/app.go @@ -89,6 +89,8 @@ const ( keyCATIcomPort = "cat.icom.port" // Icom USB CI-V serial port (e.g. COM5) keyCATIcomBaud = "cat.icom.baud" // Icom CI-V baud (default 115200) keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98) + keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2) + keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001) // Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so // global (not per-profile) like CAT/rotator. Device fields store the @@ -265,6 +267,8 @@ type CATSettings struct { IcomPort string `json:"icom_port"` // Icom USB CI-V serial port (e.g. COM5) IcomBaud int `json:"icom_baud"` // Icom CI-V baud (default 115200) IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152) + TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR) + TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001) PollMs int `json:"poll_ms"` // poll interval in ms (default 250) DelayMs int `json:"delay_ms"` // pause between commands (default 0) DigitalDefault string `json:"digital_default"` // when CAT says DATA, surface this mode (FT8/FT4/RTTY/…) @@ -3852,7 +3856,7 @@ func (a *App) GetCATSettings() (CATSettings, error) { if a.settings == nil { return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized") } - m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) + m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault) if err != nil { return CATSettings{}, err } @@ -3866,6 +3870,8 @@ func (a *App) GetCATSettings() (CATSettings, error) { IcomPort: m[keyCATIcomPort], IcomBaud: 115200, IcomAddr: 0x98, // IC-7610 default + TCIHost: m[keyCATTCIHost], + TCIPort: 40001, PollMs: 250, DelayMs: 0, DigitalDefault: m[keyCATDigitalDefault], @@ -3873,6 +3879,9 @@ func (a *App) GetCATSettings() (CATSettings, error) { if n, _ := strconv.Atoi(m[keyCATFlexPort]); n > 0 && n <= 65535 { out.FlexPort = n } + if n, _ := strconv.Atoi(m[keyCATTCIPort]); n > 0 && n <= 65535 { + out.TCIPort = n + } if n, _ := strconv.Atoi(m[keyCATIcomBaud]); n > 0 { out.IcomBaud = n } @@ -3944,6 +3953,8 @@ func (a *App) SaveCATSettings(s CATSettings) error { keyCATIcomPort: strings.TrimSpace(s.IcomPort), keyCATIcomBaud: strconv.Itoa(s.IcomBaud), keyCATIcomAddr: strconv.Itoa(s.IcomAddr), + keyCATTCIHost: strings.TrimSpace(s.TCIHost), + keyCATTCIPort: strconv.Itoa(s.TCIPort), keyCATPollMs: strconv.Itoa(s.PollMs), keyCATDelayMs: strconv.Itoa(s.DelayMs), keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)), @@ -7664,6 +7675,10 @@ func (a *App) reloadCAT() { // Native Icom CI-V over the radio's USB serial port (local control). // Same civ protocol a future network backend will reuse for remote. a.cat.Start(cat.NewIcomSerial(s.IcomPort, s.IcomBaud, s.IcomAddr, s.DigitalDefault)) + case "tci": + // Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any + // TCI-compatible server. + a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault)) default: // Unknown backend → stop and emit a dummy state so the UI shows it. a.cat.Stop() diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 724adc0..596bad2 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -717,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan const [modeDraft, setModeDraft] = useState(''); const [catCfg, setCatCfg] = useState({ enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false, - icom_port: '', icom_baud: 115200, icom_addr: 0x98, poll_ms: 250, delay_ms: 0, + icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, poll_ms: 250, delay_ms: 0, digital_default: 'FT8', }); const [rotator, setRotator] = useState({ @@ -1894,6 +1894,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan OmniRig (any rig, Windows COM) FlexRadio / SmartSDR (native) Icom CI-V (USB serial) + TCI (Expert Electronics / SunSDR) @@ -1963,6 +1964,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan )} + {catCfg.backend === 'tci' && ( + <> +
+ + setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} /> +
+
+ + setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} /> +
+

+ Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC. +

+ + )} {(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && ( <>
diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 6066881..99d043a 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1176,6 +1176,8 @@ export namespace main { icom_port: string; icom_baud: number; icom_addr: number; + tci_host: string; + tci_port: number; poll_ms: number; delay_ms: number; digital_default: string; @@ -1195,6 +1197,8 @@ export namespace main { this.icom_port = source["icom_port"]; this.icom_baud = source["icom_baud"]; this.icom_addr = source["icom_addr"]; + this.tci_host = source["tci_host"]; + this.tci_port = source["tci_port"]; this.poll_ms = source["poll_ms"]; this.delay_ms = source["delay_ms"]; this.digital_default = source["digital_default"]; diff --git a/go.mod b/go.mod index b73988e..b5c8bff 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/braheezy/shine-mp3 v0.1.0 github.com/go-ole/go-ole v1.3.0 github.com/go-sql-driver/mysql v1.10.0 + github.com/gorilla/websocket v1.5.3 github.com/moutend/go-wca v0.3.0 github.com/wailsapp/wails/v2 v2.11.0 github.com/wneessen/go-mail v0.7.3 @@ -22,7 +23,6 @@ require ( github.com/dustin/go-humanize v1.0.1 // indirect github.com/godbus/dbus/v5 v5.1.0 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/websocket v1.5.3 // indirect github.com/jchv/go-winloader v0.0.0-20210711035445-715c2860da7e // indirect github.com/labstack/echo/v4 v4.13.3 // indirect github.com/labstack/gommon v0.4.2 // indirect diff --git a/internal/cat/tci.go b/internal/cat/tci.go new file mode 100644 index 0000000..7ed4f6f --- /dev/null +++ b/internal/cat/tci.go @@ -0,0 +1,294 @@ +//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/…) + + 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. +func NewTCI(host string, port int, digitalDefault string) *TCI { + if port <= 0 || port > 65535 { + port = tciDefaultPort + } + return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault)} +} + +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) + return nil +} + +// 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" + } +}