diff --git a/app.go b/app.go index eb9a9a9..cfc6810 100644 --- a/app.go +++ b/app.go @@ -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() diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index 596bad2..e541c8a 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, 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({ @@ -1979,6 +1979,10 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan

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 99d043a..b2d81b4 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -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"]; diff --git a/internal/cat/tci.go b/internal/cat/tci.go index 7ed4f6f..0a29f06 100644 --- a/internal/cat/tci.go +++ b/internal/cat/tci.go @@ -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()