feat: spots to panadapter for TCI radio
This commit is contained in:
@@ -91,6 +91,7 @@ const (
|
|||||||
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
keyCATIcomAddr = "cat.icom.addr" // Icom CI-V address, decimal (IC-7610 = 152 / 0x98)
|
||||||
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
keyCATTCIHost = "cat.tci.host" // TCI host (Expert Electronics SunSDR / ExpertSDR2)
|
||||||
keyCATTCIPort = "cat.tci.port" // TCI WebSocket port (default 40001)
|
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
|
// Audio (Digital Voice Keyer + QSO recorder). Machine-local hardware, so
|
||||||
// global (not per-profile) like CAT/rotator. Device fields store the
|
// 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)
|
IcomAddr int `json:"icom_addr"` // Icom CI-V address, decimal (IC-7610 = 152)
|
||||||
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
TCIHost string `json:"tci_host"` // TCI host (Expert Electronics SunSDR)
|
||||||
TCIPort int `json:"tci_port"` // TCI WebSocket port (default 40001)
|
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)
|
PollMs int `json:"poll_ms"` // poll interval in ms (default 250)
|
||||||
DelayMs int `json:"delay_ms"` // pause between commands (default 0)
|
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/…)
|
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 {
|
if a.settings == nil {
|
||||||
return CATSettings{Backend: "omnirig", OmniRigNum: 1, PollMs: 250}, fmt.Errorf("db not initialized")
|
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 {
|
if err != nil {
|
||||||
return CATSettings{}, err
|
return CATSettings{}, err
|
||||||
}
|
}
|
||||||
@@ -3872,6 +3874,7 @@ func (a *App) GetCATSettings() (CATSettings, error) {
|
|||||||
IcomAddr: 0x98, // IC-7610 default
|
IcomAddr: 0x98, // IC-7610 default
|
||||||
TCIHost: m[keyCATTCIHost],
|
TCIHost: m[keyCATTCIHost],
|
||||||
TCIPort: 40001,
|
TCIPort: 40001,
|
||||||
|
TCISpots: m[keyCATTCISpots] == "1",
|
||||||
PollMs: 250,
|
PollMs: 250,
|
||||||
DelayMs: 0,
|
DelayMs: 0,
|
||||||
DigitalDefault: m[keyCATDigitalDefault],
|
DigitalDefault: m[keyCATDigitalDefault],
|
||||||
@@ -3940,6 +3943,10 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
if s.FlexSpots {
|
if s.FlexSpots {
|
||||||
flexSpots = "1"
|
flexSpots = "1"
|
||||||
}
|
}
|
||||||
|
tciSpots := "0"
|
||||||
|
if s.TCISpots {
|
||||||
|
tciSpots = "1"
|
||||||
|
}
|
||||||
if s.DigitalDefault == "" {
|
if s.DigitalDefault == "" {
|
||||||
s.DigitalDefault = "FT8"
|
s.DigitalDefault = "FT8"
|
||||||
}
|
}
|
||||||
@@ -3955,6 +3962,7 @@ func (a *App) SaveCATSettings(s CATSettings) error {
|
|||||||
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
keyCATIcomAddr: strconv.Itoa(s.IcomAddr),
|
||||||
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
keyCATTCIHost: strings.TrimSpace(s.TCIHost),
|
||||||
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
keyCATTCIPort: strconv.Itoa(s.TCIPort),
|
||||||
|
keyCATTCISpots: tciSpots,
|
||||||
keyCATPollMs: strconv.Itoa(s.PollMs),
|
keyCATPollMs: strconv.Itoa(s.PollMs),
|
||||||
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
keyCATDelayMs: strconv.Itoa(s.DelayMs),
|
||||||
keyCATDigitalDefault: strings.ToUpper(strings.TrimSpace(s.DigitalDefault)),
|
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.SetPollInterval(time.Duration(s.PollMs) * time.Millisecond)
|
||||||
a.cat.SetCommandDelay(time.Duration(s.DelayMs) * 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 {
|
if !s.Enabled {
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
return
|
return
|
||||||
@@ -7678,7 +7686,7 @@ func (a *App) reloadCAT() {
|
|||||||
case "tci":
|
case "tci":
|
||||||
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
// Expert Electronics TCI (WebSocket) — SunSDR / ExpertSDR2, or any
|
||||||
// TCI-compatible server.
|
// 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:
|
default:
|
||||||
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
// Unknown backend → stop and emit a dummy state so the UI shows it.
|
||||||
a.cat.Stop()
|
a.cat.Stop()
|
||||||
|
|||||||
@@ -717,7 +717,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
|
|||||||
const [modeDraft, setModeDraft] = useState('');
|
const [modeDraft, setModeDraft] = useState('');
|
||||||
const [catCfg, setCatCfg] = useState<CATSettings>({
|
const [catCfg, setCatCfg] = useState<CATSettings>({
|
||||||
enabled: false, backend: 'omnirig', omnirig_rig: 1, flex_host: '', flex_port: 4992, flex_spots: false,
|
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',
|
digital_default: 'FT8',
|
||||||
});
|
});
|
||||||
const [rotator, setRotator] = useState<RotatorSettings>({
|
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">
|
<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.
|
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>
|
</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') && (
|
{(catCfg.backend === 'omnirig' || catCfg.backend === 'icom') && (
|
||||||
|
|||||||
@@ -1178,6 +1178,7 @@ export namespace main {
|
|||||||
icom_addr: number;
|
icom_addr: number;
|
||||||
tci_host: string;
|
tci_host: string;
|
||||||
tci_port: number;
|
tci_port: number;
|
||||||
|
tci_spots: boolean;
|
||||||
poll_ms: number;
|
poll_ms: number;
|
||||||
delay_ms: number;
|
delay_ms: number;
|
||||||
digital_default: string;
|
digital_default: string;
|
||||||
@@ -1199,6 +1200,7 @@ export namespace main {
|
|||||||
this.icom_addr = source["icom_addr"];
|
this.icom_addr = source["icom_addr"];
|
||||||
this.tci_host = source["tci_host"];
|
this.tci_host = source["tci_host"];
|
||||||
this.tci_port = source["tci_port"];
|
this.tci_port = source["tci_port"];
|
||||||
|
this.tci_spots = source["tci_spots"];
|
||||||
this.poll_ms = source["poll_ms"];
|
this.poll_ms = source["poll_ms"];
|
||||||
this.delay_ms = source["delay_ms"];
|
this.delay_ms = source["delay_ms"];
|
||||||
this.digital_default = source["digital_default"];
|
this.digital_default = source["digital_default"];
|
||||||
|
|||||||
+30
-3
@@ -25,6 +25,7 @@ type TCI struct {
|
|||||||
port int
|
port int
|
||||||
|
|
||||||
digitalDefault string // surfaced when the rig reports a digital mode (FT8/…)
|
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
|
mu sync.Mutex // guards conn + writes + state
|
||||||
conn *websocket.Conn
|
conn *websocket.Conn
|
||||||
@@ -44,12 +45,13 @@ type TCI struct {
|
|||||||
const tciDefaultPort = 40001
|
const tciDefaultPort = 40001
|
||||||
|
|
||||||
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
|
// NewTCI builds a TCI backend for the given host/port. digitalDefault is the
|
||||||
// mode surfaced when the radio reports a generic digital modulation.
|
// mode surfaced when the radio reports a generic digital modulation; spots turns
|
||||||
func NewTCI(host string, port int, digitalDefault string) *TCI {
|
// 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 {
|
if port <= 0 || port > 65535 {
|
||||||
port = tciDefaultPort
|
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" }
|
func (t *TCI) Name() string { return "tci" }
|
||||||
@@ -79,9 +81,34 @@ func (t *TCI) Connect() error {
|
|||||||
t.mu.Unlock()
|
t.mu.Unlock()
|
||||||
debugLog.Printf("TCI: connected to %s", url)
|
debugLog.Printf("TCI: connected to %s", url)
|
||||||
go t.reader(conn)
|
go t.reader(conn)
|
||||||
|
if t.spotsEnabled {
|
||||||
|
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
||||||
|
}
|
||||||
return nil
|
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.
|
// Disconnect closes the WebSocket; the reader goroutine then exits.
|
||||||
func (t *TCI) Disconnect() {
|
func (t *TCI) Disconnect() {
|
||||||
t.mu.Lock()
|
t.mu.Lock()
|
||||||
|
|||||||
Reference in New Issue
Block a user