feat: TCI implementation for CAT Control of SunSDR
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -717,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
const [modeDraft, setModeDraft] = useState('');
|
||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||
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<RotatorSettings>({
|
||||
@@ -1894,6 +1894,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
<SelectItem value="omnirig">OmniRig (any rig, Windows COM)</SelectItem>
|
||||
<SelectItem value="flex">FlexRadio / SmartSDR (native)</SelectItem>
|
||||
<SelectItem value="icom">Icom CI-V (USB serial)</SelectItem>
|
||||
<SelectItem value="tci">TCI (Expert Electronics / SunSDR)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -1963,6 +1964,23 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{catCfg.backend === 'tci' && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
<Label>TCI host</Label>
|
||||
<Input placeholder="127.0.0.1" value={catCfg.tci_host ?? ''}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, tci_host: e.target.value }))} />
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<Label>Port</Label>
|
||||
<Input type="number" value={catCfg.tci_port || 40001}
|
||||
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} />
|
||||
</div>
|
||||
<p className="col-span-2 text-xs text-muted-foreground">
|
||||
Enable the TCI server in ExpertSDR2/EESDR (Options → TCI). Default port 40001. Use 127.0.0.1 when OpsLog runs on the same PC.
|
||||
</p>
|
||||
</>
|
||||
)}
|
||||
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
||||
<>
|
||||
<div className="space-y-1">
|
||||
|
||||
@@ -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"];
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:<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"
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user