2 Commits
Author SHA1 Message Date
rouggy 8740a4ba66 chore: release v0.16.1 2026-07-03 15:31:29 +02:00
rouggy 8ccad7ca65 feat: spots to panadapter for TCI radio 2026-07-03 15:30:53 +02:00
6 changed files with 50 additions and 9 deletions
+11 -3
View File
@@ -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()
+5 -1
View File
@@ -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') && (
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.16'; export const APP_VERSION = '0.16.1';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -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
View File
@@ -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()
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.16" appVersion = "0.16.1"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.