feat: spots to panadapter for TCI radio

This commit is contained in:
2026-07-03 15:30:53 +02:00
parent fa7df57435
commit 8ccad7ca65
4 changed files with 48 additions and 7 deletions
+11 -3
View File
@@ -91,6 +91,7 @@ const (
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)
keyCATTCISpots = "cat.tci.spots" // push cluster spots to the TCI panorama
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
// global (not per-profile) like CAT/rotator. Device fields store the
@@ -269,6 +270,7 @@ type CATSettings struct {
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)
TCISpots bool `json:"tci_spots"` // push cluster spots to the TCI panorama
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/…)
@@ -3856,7 +3858,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, keyCATTCIHost, keyCATTCIPort, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
m, err := a.settings.GetMany(a.ctx, keyCATEnabled, keyCATBackend, keyCATOmniRigNum, keyCATFlexHost, keyCATFlexPort, keyCATFlexSpots, keyCATIcomPort, keyCATIcomBaud, keyCATIcomAddr, keyCATTCIHost, keyCATTCIPort, keyCATTCISpots, keyCATPollMs, keyCATDelayMs, keyCATDigitalDefault)
if err != nil {
return CATSettings{}, err
}
@@ -3872,6 +3874,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
IcomAddr: 0x98, // IC-7610 default
TCIHost: m[keyCATTCIHost],
TCIPort: 40001,
TCISpots: m[keyCATTCISpots] == "1",
PollMs: 250,
DelayMs: 0,
DigitalDefault: m[keyCATDigitalDefault],
@@ -3940,6 +3943,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
if s.FlexSpots {
flexSpots = "1"
}
tciSpots := "0"
if s.TCISpots {
tciSpots = "1"
}
if s.DigitalDefault == "" {
s.DigitalDefault = "FT8"
}
@@ -3955,6 +3962,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
keyCATTCIPort: strconv.Itoa(s.TCIPort),
keyCATTCISpots: tciSpots,
keyCATPollMs: strconv.Itoa(s.PollMs),
keyCATDelayMs: strconv.Itoa(s.DelayMs),
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
@@ -7648,7 +7656,7 @@ func (a *App) reloadCAT() {
}
a.cat.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * time.Millisecond)
a.catFlexSpots = s.Enabled && s.Backend == "flex" && s.FlexSpots
a.catFlexSpots = s.Enabled && ((s.Backend == "flex" && s.FlexSpots) || (s.Backend == "tci" && s.TCISpots))
if !s.Enabled {
a.cat.Stop()
return
@@ -7678,7 +7686,7 @@ func (a *App) reloadCAT() {
case "tci":
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
// TCI-compatible server.
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault))
a.cat.Start(cat.NewTCI(s.TCIHost, s.TCIPort, s.DigitalDefault, s.TCISpots))
default:
// Unknown backend → stop and emit a dummy state so the UI shows it.
a.cat.Stop()
+5 -1
View File
@@ -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, tci_host: '', tci_port: 40001, poll_ms: 250, delay_ms: 0,
icom_port: '', icom_baud: 115200, icom_addr: 0x98, tci_host: '', tci_port: 40001, tci_spots: false, poll_ms: 250, delay_ms: 0,
digital_default: 'FT8',
});
const [rotator, setRotator] = useState<RotatorSettings>({
@@ -1979,6 +1979,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<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>
<label className="col-span-2 flex items-center gap-2 text-sm cursor-pointer">
<Checkbox checked={!!catCfg.tci_spots} onCheckedChange={(c) => setCatCfg((s) => ({ ...s, tci_spots: !!c }))} />
Show cluster spots on the panorama <span className="text-xs text-muted-foreground">(spots from OpsLog's DX cluster appear on the SDR panadapter)</span>
</label>
</>
)}
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
+2
View File
@@ -1178,6 +1178,7 @@ export namespace main {
icom_addr: number;
tci_host: string;
tci_port: number;
tci_spots: boolean;
poll_ms: number;
delay_ms: number;
digital_default: string;
@@ -1199,6 +1200,7 @@ export namespace main {
this.icom_addr = source["icom_addr"];
this.tci_host = source["tci_host"];
this.tci_port = source["tci_port"];
this.tci_spots = source["tci_spots"];
this.poll_ms = source["poll_ms"];
this.delay_ms = source["delay_ms"];
this.digital_default = source["digital_default"];
+30 -3
View File
@@ -25,6 +25,7 @@ type TCI struct {
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
@@ -44,12 +45,13 @@ type TCI struct {
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 {
// 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)}
return &TCI{host: strings.TrimSpace(host), port: port, digitalDefault: strings.TrimSpace(digitalDefault), spotsEnabled: spots}
}
func (t *TCI) Name() string { return "tci" }
@@ -79,9 +81,34 @@ func (t *TCI) Connect() error {
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()