Measured rather than guessed: the whole repository was cross-compiled for
linux/amd64 and the gaps closed one by one. There were fewer than expected.
Flex and TCI were never Windows-specific — they carried //go:build windows by
inheritance and import nothing but net and gorilla/websocket. Untagged, no code
change. The two backends a Linux operator is most likely to own were already
portable.
Audio was 560 lines, not 2287: only devices.go and engine.go touch WASAPI, while
manager.go, recorder.go, wav.go and mp3.go were pure Go wearing the tag by
association. The whole platform surface is seven functions, now implemented a
second time on PulseAudio through github.com/jfreymuth/pulse — pure Go over the
server socket, so the no-cgo rule survives, and PipeWire answers the same
protocol. The fixed 16 kHz mono format and the server-side resampling mirror
what AUTOCONVERTPCM does on Windows, for the same reason.
OmniRig is the only real loss, and its backend still EXISTS off Windows rather
than being compiled out of app.go: a settings database is portable, so an
operator moving a profile across keeps "omnirig" saved and must be told to pick
a native backend instead of meeting a nil one.
The parts where Linux is not Windows, and where a compile-only stub would have
been a silent bug:
- data dir: still beside the binary, but ~/.local/share/OpsLog/data when that
folder belongs to the system — decided by trying the write, because /opt and
/usr/local are writable on some stations and not others.
- single instance: an flock, not a pid file. The kernel drops it however the
process dies, so a crash leaves nothing to delete by hand. This is the guard
that stops two instances fighting over the rig frequency.
- update: simpler here. Unix renames over a running binary, so the deferred
swap the Windows path needs a detached helper for is unreachable.
- tasklist/taskkill become /proc and SIGTERM; the boot log moves out of /tmp,
which is wiped exactly when the evidence is wanted.
- serial ports sorted naturally: /dev/ttyUSB10 was landing between USB1 and
USB2, the same trap COM10 fell into.
release.ps1 now cross-builds and vets for linux before it builds the exe, and
refuses the release if that fails — a port rots one unguarded x/sys/windows call
at a time.
Nothing has been executed on Linux yet: Wails needs webkit2gtk and cgo there, so
the binary must be built on Linux. scripts/linux-setup.sh checks the machine and
does it; BUILDING-LINUX.md is the manual version.
Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
757 lines
27 KiB
Go
757 lines
27 KiB
Go
package cat
|
|
|
|
import (
|
|
"context"
|
|
"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/…)
|
|
spotsEnabled bool // mirror cluster spots onto the TCI panorama
|
|
// wantFreq is the frequency last COMMANDED and not yet echoed back, used to
|
|
// pick the sideband before the radio has confirmed the move.
|
|
wantFreq int64
|
|
// What the server said it is, from its "protocol:" announcement.
|
|
serverName string
|
|
serverVersion string
|
|
// How many spots have been logged verbatim (the first few only).
|
|
spotsSent int
|
|
|
|
// OnSpotClick is called when the user clicks one of our spots on the TCI
|
|
// panorama (callsign + freq), so the host can fill the entry form. Set before
|
|
// Connect. Mirrors the FlexRadio panadapter-click flow.
|
|
OnSpotClick func(callsign string, freqHz int64)
|
|
unhandledSeen map[string]bool // log each unknown TCI message type once
|
|
|
|
// panel is the control-console state — everything the radio announces about
|
|
// itself that is not frequency or mode. See tci_panel.go.
|
|
panel tciPanel
|
|
|
|
// audio holds the receive-audio stream — see tci_audio.go. TCI carries it
|
|
// on this same WebSocket, which is what lets a SunSDR record and decode
|
|
// without a virtual audio cable in the way.
|
|
audio tciAudio
|
|
|
|
// One writer at a time. send() held the lock only long enough to READ conn,
|
|
// which was enough while every command came from the poll loop — a stream of
|
|
// audio frames from a second goroutine is not, and gorilla panics on a
|
|
// concurrent write rather than corrupting the socket quietly.
|
|
wmu sync.Mutex // serialises writes to the socket (text AND binary)
|
|
mu sync.Mutex // guards conn + writes + state
|
|
conn *websocket.Conn
|
|
dialCancel context.CancelFunc // cancels an in-flight Connect dial (Interrupt/Stop)
|
|
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
|
|
// txAllowed is what the radio last said about TRANSMIT PERMISSION.
|
|
//
|
|
// TX_ENABLE is sent by ExpertSDR when a client connects and again whenever
|
|
// the band changes, "in case transmitter permission was changed" (§4.3). When
|
|
// it is false the radio silently ignores TRX — which is exactly what an
|
|
// operator sees as "PTT does nothing", with no error anywhere to explain it.
|
|
//
|
|
// txAllowedKnown keeps an OLDER ExpertSDR, or a TCI-compatible program that
|
|
// never sends TX_ENABLE at all, from being treated as refusing: without a
|
|
// word from the radio we key and let it decide.
|
|
txAllowed bool
|
|
// txSource is the TRX third argument: "tci" while OpsLog has audio to send,
|
|
// empty for the operator's microphone. See SetPTT.
|
|
txSource string
|
|
// drive is the radio's transmit drive, 0-100. Kept because a quiet
|
|
// transmission has two possible causes — our level or the radio's — and a
|
|
// log that names both settles it in one line instead of an evening.
|
|
drive int
|
|
txAllowedKnown bool
|
|
|
|
lastSig string // last logged state signature (log only on change)
|
|
|
|
// spotFreq is the frequency of the marker currently on the panorama for each
|
|
// callsign — the panadapter's own state, which TCI never reports back. It is
|
|
// what makes one spot per call possible: without it there is no way to know
|
|
// there is an older marker to delete.
|
|
spotFreq map[string]int64
|
|
}
|
|
|
|
func absInt64(v int64) int64 {
|
|
if v < 0 {
|
|
return -v
|
|
}
|
|
return v
|
|
}
|
|
|
|
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; 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), spotsEnabled: spots}
|
|
}
|
|
|
|
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)))
|
|
// Cancellable dial so Interrupt() (Stop / Settings "Save & Close") aborts it at
|
|
// once instead of waiting out a dead server's 5 s handshake timeout.
|
|
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
|
t.mu.Lock()
|
|
t.dialCancel = cancel
|
|
t.mu.Unlock()
|
|
dialer := websocket.Dialer{HandshakeTimeout: 5 * time.Second}
|
|
conn, _, err := dialer.DialContext(ctx, url, nil)
|
|
cancel()
|
|
t.mu.Lock()
|
|
t.dialCancel = nil
|
|
t.mu.Unlock()
|
|
if err != nil {
|
|
return fmt.Errorf("tci: connect %s: %w", url, err)
|
|
}
|
|
t.mu.Lock()
|
|
t.conn = conn
|
|
t.ready = false
|
|
// Forget the previous session's transmit permission: the radio announces it
|
|
// again on connect, and a refusal remembered from a band we have since left
|
|
// would block PTT until it did.
|
|
t.txAllowed, t.txAllowedKnown = false, false
|
|
t.mu.Unlock()
|
|
debugLog.Printf("TCI: connected to %s", url)
|
|
go t.reader(conn)
|
|
// Ask for the meters. Nothing measures anything until this goes out: the
|
|
// S-meter, the transmit power and the SWR are all pushed by the radio, and
|
|
// only to a client that has subscribed. 200 ms is the rate the protocol's own
|
|
// examples use — fast enough for a needle, slow enough not to flood a socket
|
|
// that also carries audio.
|
|
if t.spotsEnabled {
|
|
debugLog.Printf("TCI: panorama spots are ON — spots will be sent to the radio")
|
|
}
|
|
t.subscribeSensors("connect")
|
|
if t.spotsEnabled {
|
|
// Forget what we thought was on the panorama at the same moment the radio
|
|
// is told to drop it. Kept, the memory would suppress the next spot for
|
|
// each of those calls as "already drawn" onto a panorama now empty.
|
|
t.mu.Lock()
|
|
t.spotFreq = map[string]int64{}
|
|
t.mu.Unlock()
|
|
_ = t.send("spot_clear;") // drop any leftover spots from a previous session
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// spotFreqTolHz is how far a re-spot of the same callsign may sit from the one
|
|
// already on the panorama before it is treated as a move rather than the same
|
|
// spot said again.
|
|
//
|
|
// Two spotters hearing the same CW station rarely agree to better than a couple
|
|
// of hundred hertz, and every one of them produces a cluster line. Below this
|
|
// they are the same spot and nothing is sent at all; above it the marker is
|
|
// deleted and redrawn where the station now is.
|
|
const spotFreqTolHz = 500
|
|
|
|
// noteSpot records what the panorama is about to hold for a callsign and says
|
|
// what has to be sent: whether to draw at all, and whether an older marker for
|
|
// the same call must be deleted first.
|
|
//
|
|
// Separate from SendSpot so the rule can be tested without a radio — and
|
|
// because the lock must be released before anything is sent: t.send takes t.mu
|
|
// itself, Go mutexes are not reentrant, and sending while holding it would
|
|
// deadlock the backend and take the rig offline.
|
|
func (t *TCI) noteSpot(call string, freqHz int64) (draw, deletePrev bool) {
|
|
key := strings.ToUpper(strings.TrimSpace(call))
|
|
t.mu.Lock()
|
|
defer t.mu.Unlock()
|
|
prev, had := t.spotFreq[key]
|
|
if had && absInt64(prev-freqHz) <= spotFreqTolHz {
|
|
return false, false
|
|
}
|
|
if t.spotFreq == nil {
|
|
t.spotFreq = map[string]int64{}
|
|
}
|
|
if len(t.spotFreq) > 4000 {
|
|
t.spotFreq = map[string]int64{} // bound memory on a long session
|
|
had = false // forgotten: nothing left to delete by name
|
|
}
|
|
t.spotFreq[key] = freqHz
|
|
return true, had
|
|
}
|
|
|
|
// SendSpot mirrors a cluster spot onto the TCI panorama (implements Spotter).
|
|
// No-op when spot mirroring is disabled.
|
|
//
|
|
// ONE MARKER PER CALLSIGN. This code assumed the radio replaced a spot carrying
|
|
// a callsign it already had; it does not. ExpertSDR keys a spot on its
|
|
// frequency too, so a DX station spotted by three operators — 14025.00,
|
|
// 14025.12, 14024.90, which is an ordinary minute on a cluster — was drawn
|
|
// three times, a few pixels apart, and stayed that way.
|
|
//
|
|
// So the previous spot for the call is deleted before the new one is sent,
|
|
// which is what the FlexRadio backend has always done (spot remove / spot add).
|
|
func (t *TCI) SendSpot(s SpotInfo) error {
|
|
if !t.spotsEnabled {
|
|
return nil
|
|
}
|
|
call := strings.TrimSpace(s.Callsign)
|
|
if call == "" || s.FreqHz <= 0 {
|
|
return nil
|
|
}
|
|
draw, deletePrev := t.noteSpot(call, s.FreqHz)
|
|
if !draw {
|
|
return nil // the same station said again by another spotter
|
|
}
|
|
if deletePrev {
|
|
// SPOT_DELETE takes the callsign alone. Not in the protocol PDF this
|
|
// backend was written from; confirmed against ars-ka0s/eesdr-tci, which
|
|
// lists SPOT (5 arguments), SPOT_DELETE (1) and SPOT_CLEAR (0) — the
|
|
// other two matching what already works here.
|
|
_ = t.send(fmt.Sprintf("spot_delete:%s;", call))
|
|
}
|
|
// The colour is a DECIMAL ARGB integer, and an UNSIGNED one.
|
|
//
|
|
// Expert Electronics' own protocol document gives the whole command:
|
|
//
|
|
// SPOT:RN6LHF,CW,7100000,16711680,ANY_TEXT;
|
|
//
|
|
// 16711680 is 0x00FF0000 — positive, alpha zero. This backend was sending
|
|
// the same number as a SIGNED 32-bit value, taken from a third-party
|
|
// example: with the alpha byte set to FF for opacity, 0xFFFFA500 becomes
|
|
// -22336, and a spot whose colour field ExpertSDR cannot read is dropped in
|
|
// silence. Reported on ExpertSDR3 1.3 (which speaks TCI 2.x, so the version
|
|
// was never the problem): everything else worked and the panorama stayed
|
|
// empty.
|
|
hex := strings.TrimPrefix(strings.TrimPrefix(strings.TrimSpace(s.Color), "#"), "0x")
|
|
if hex == "" {
|
|
hex = "FFFFA500" // opaque orange default
|
|
}
|
|
if len(hex) == 6 {
|
|
hex = "FF" + hex // add full-opacity alpha when only RGB was supplied
|
|
}
|
|
argb, err := strconv.ParseUint(hex, 16, 32)
|
|
if err != nil {
|
|
argb = 0xFFFFA500
|
|
}
|
|
// Use a valid TCI modulation (usb/lsb/cw/digl…) so ExpertSDR accepts the spot;
|
|
// fall back to the raw label if we can't map it. The click-to-tune path already
|
|
// maps the mode separately, so this only affects the spot's displayed mode.
|
|
mode := adifToTCIMode(s.Mode, s.FreqHz)
|
|
if mode == "" {
|
|
mode = strings.ToLower(strings.TrimSpace(s.Mode))
|
|
}
|
|
// Commas/semicolons would break TCI's comma-separated argument parsing.
|
|
text := strings.NewReplacer(",", " ", ";", " ").Replace(s.Comment)
|
|
cmd := fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, argb, text)
|
|
// The first few, verbatim. A spot that the radio ignores leaves no trace at
|
|
// all — no reply, no error — so the only evidence that OpsLog sent one, and
|
|
// in what shape, is this line.
|
|
if n := t.spotsSent; n < 3 {
|
|
t.spotsSent = n + 1
|
|
debugLog.Printf("TCI: sending spot #%d: %s", n+1, strings.TrimSuffix(cmd, ";"))
|
|
}
|
|
return t.send(cmd)
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
}
|
|
|
|
// Interrupt aborts an in-flight Connect dial so Stop()/Start() don't block on a
|
|
// dead server's handshake timeout. Satisfies the Manager's interruptible
|
|
// interface. Safe from another goroutine; a no-op when not dialing.
|
|
func (t *TCI) Interrupt() {
|
|
t.mu.Lock()
|
|
cancel := t.dialCancel
|
|
c := t.conn
|
|
t.mu.Unlock()
|
|
if cancel != nil {
|
|
cancel()
|
|
}
|
|
if c != nil {
|
|
_ = 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
|
|
}
|
|
// The transmit meters are asked for, not pushed: TX_POWER and TX_SWR are
|
|
// read-only commands the radio answers when asked, and asking is only worth
|
|
// anything while it is keyed. Fired and forgotten from here — the answers
|
|
// arrive on the reader like everything else — and only while transmitting,
|
|
// so a receiving station pays nothing for a meter nobody is watching.
|
|
// Keyed by PTT **or** by TUNE. A tune carrier is exactly when the meters
|
|
// matter most — it is the carrier an operator is watching an SWR on — and
|
|
// asking only on t.tx left them at zero for the whole tune, because the
|
|
// radio reports tuning as its own state and not as a transmission.
|
|
if t.tx || t.panel.st.Tuning {
|
|
tx := t
|
|
go func() {
|
|
_ = tx.send("tx_power;")
|
|
_ = tx.send("tx_swr;")
|
|
}()
|
|
}
|
|
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 {
|
|
// Remember what we ASKED for. SetMode reads it to choose the sideband, and
|
|
// the radio's own echo can be a moment behind — see SetMode.
|
|
t.mu.Lock()
|
|
t.wantFreq = hz
|
|
t.mu.Unlock()
|
|
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
|
|
// Prefer the frequency we just COMMANDED over the one the radio has echoed.
|
|
//
|
|
// Clicking a spot sets the frequency and then the mode, and the sideband is
|
|
// chosen from the frequency (below 10 MHz → LSB). Read from the echo, that
|
|
// is the frequency we were on BEFORE the click whenever the echo has not
|
|
// landed yet: a 14 MHz spot clicked from 7 MHz got LSB, and clicking the same
|
|
// spot again — now that the echo has arrived — got USB. Reported from a
|
|
// SunSDR as "the frequency is right, the mode is wrong until I click twice".
|
|
if t.wantFreq > 0 {
|
|
freq = t.wantFreq
|
|
}
|
|
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).
|
|
//
|
|
// A refusal by the radio is reported rather than swallowed. ExpertSDR announces
|
|
// transmit permission with TX_ENABLE and then simply IGNORES trx when it is
|
|
// false — out-of-band frequency, TX disabled in the program, no PA. The command
|
|
// went out, nothing happened, and nothing anywhere said why. Now the operator
|
|
// is told, and the message names the place to look.
|
|
func (t *TCI) SetPTT(on bool) error {
|
|
if on {
|
|
t.mu.Lock()
|
|
known, allowed := t.txAllowedKnown, t.txAllowed
|
|
t.mu.Unlock()
|
|
if known && !allowed {
|
|
return fmt.Errorf("the radio is refusing to transmit (TCI reports TX disabled) — " +
|
|
"check the frequency is inside a transmit band and that TX is enabled in ExpertSDR")
|
|
}
|
|
}
|
|
// THE THIRD ARGUMENT NAMES THE AUDIO SOURCE, and it is the whole answer to
|
|
// "why does the radio ignore what I send it".
|
|
//
|
|
// TCI 2.0 §TRX: "The signal for transmitting is always taken from the
|
|
// microphone selected in the ExpertSDR3. If a third-party software connected
|
|
// via TCI wants to transmit its audio signal, you must specify the third
|
|
// argument - TCI." Without it the radio never sends a single chrono frame,
|
|
// whatever the mode and whatever is configured in its window — which is
|
|
// exactly what a night of experiments showed and misread as "digital modes
|
|
// only".
|
|
//
|
|
// Sent only when a transmission is ours to feed. A plain trx keeps the
|
|
// operator's own microphone, which is what every other PTT in OpsLog means.
|
|
t.mu.Lock()
|
|
src := t.txSource
|
|
t.mu.Unlock()
|
|
if on && src != "" {
|
|
return t.send(fmt.Sprintf("trx:0,true,%s;", src))
|
|
}
|
|
return t.send(fmt.Sprintf("trx:0,%t;", on))
|
|
}
|
|
|
|
// SetTXAudioSource says where the radio should take its transmit audio from
|
|
// while OpsLog keys it: "tci" for the stream this program sends, "" for the
|
|
// microphone the operator chose in ExpertSDR3.
|
|
//
|
|
// Set from the audio settings — it follows the "To radio" device — so keying
|
|
// for a voice message and keying for anything else behave differently on
|
|
// purpose: only the first one takes the audio away from the microphone.
|
|
func (t *TCI) SetTXAudioSource(src string) {
|
|
t.mu.Lock()
|
|
changed := t.txSource != src
|
|
t.txSource = src
|
|
t.mu.Unlock()
|
|
if changed {
|
|
if src == "" {
|
|
debugLog.Printf("TCI: transmit audio will come from the radio's own microphone")
|
|
} else {
|
|
debugLog.Printf("TCI: transmit audio will be taken from %s when OpsLog keys the radio", src)
|
|
}
|
|
}
|
|
}
|
|
|
|
// send writes a command to the WebSocket (one writer at a time).
|
|
// subscribeSensors asks the radio to push its meters. Nothing measures anything
|
|
// until this goes out — the S-meter, the transmit power and the SWR are all
|
|
// subscription-only (TCI §4.4) — and it is sent at connect AND again at every
|
|
// "ready", because a subscription sent during the server's initial dump can be
|
|
// dropped. 200 ms is the rate the protocol's own examples use.
|
|
func (t *TCI) subscribeSensors(when string) {
|
|
// Both cases, deliberately. Every other command this backend sends works in
|
|
// lower case, but the meters stayed silent on a real SunSDR through two
|
|
// rounds of fixes — and the protocol document's own examples are upper case
|
|
// (TX_SENSORS_ENABLE:true,200;). A server that is case-insensitive ignores
|
|
// the duplicate; one that is not finally hears the subscription.
|
|
e1 := t.send("rx_sensors_enable:true,200;")
|
|
e2 := t.send("tx_sensors_enable:true,200;")
|
|
_ = t.send("RX_SENSORS_ENABLE:true,200;")
|
|
_ = t.send("TX_SENSORS_ENABLE:true,200;")
|
|
debugLog.Printf("TCI: sensor subscription sent (%s, both cases): rx=%v tx=%v", when, e1, e2)
|
|
}
|
|
|
|
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")
|
|
}
|
|
t.wmu.Lock()
|
|
defer t.wmu.Unlock()
|
|
_ = 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 {
|
|
mt, data, err := conn.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
// TEXT frames are commands, BINARY frames are streams. The type used to
|
|
// be ignored and every frame split on ';' — harmless only for as long as
|
|
// no stream was ever opened, since audio bytes would then have been fed
|
|
// to the command parser a hundred times a second.
|
|
if wsMessageIsBinary(mt) {
|
|
t.handleBinary(data)
|
|
continue
|
|
}
|
|
// 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()
|
|
lower := strings.ToLower(name)
|
|
// The console's own messages first. Most of them were being logged once as
|
|
// unhandled and thrown away — the radio has been announcing its drive, its
|
|
// filters and its noise blanker since the first connection.
|
|
if t.handlePanel(lower, get, args) {
|
|
// Still falls through for the few the rig state also needs (split, tune),
|
|
// which is why this does not return.
|
|
switch lower {
|
|
case "split_enable", "trx", "modulation", "vfo":
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
switch lower {
|
|
case "device":
|
|
t.device = strings.TrimSpace(args)
|
|
// The server's own announcement: "protocol:ExpertSDR3,1.9;" — its name and
|
|
// the TCI version it speaks. Worth keeping rather than filing under
|
|
// "unhandled": panorama spots need a version that HAS the spot command, and
|
|
// without this an operator on an older ExpertSDR sees nothing on the
|
|
// waterfall and nothing anywhere saying why.
|
|
case "protocol":
|
|
t.serverName, t.serverVersion = get(0), get(1)
|
|
debugLog.Printf("TCI: server is %s, TCI %s", t.serverName, t.serverVersion)
|
|
if t.spotsEnabled && tciSpotsUnsupported(t.serverVersion) {
|
|
debugLog.Printf("TCI: this server speaks TCI %s — panorama spots need 1.5 or later, so they will not appear",
|
|
t.serverVersion)
|
|
}
|
|
// The radio ANNOUNCES its audio format at connect —
|
|
// "audio_stream_sample_type:float32" and "audio_stream_channels:2" — which
|
|
// is better evidence than anything derived from a frame, and it arrives
|
|
// before the first frame does. Both were being logged as unhandled.
|
|
case "audio_stream_sample_type":
|
|
t.audio.declaredType = strings.TrimSpace(args)
|
|
case "audio_stream_channels":
|
|
if n, err := strconv.Atoi(strings.TrimSpace(args)); err == nil && n > 0 && n <= 8 {
|
|
t.audio.declaredChans = n
|
|
}
|
|
case "ready", "start":
|
|
t.ready = true
|
|
// (Re)subscribe to the meters HERE, not only at connect. ExpertSDR3
|
|
// dumps its whole state and then says "ready"; a unidirectional control
|
|
// command sent while that dump is still in flight can be ignored, and
|
|
// the report from a real SunSDR — transmit meters still empty after the
|
|
// connect-time subscription — has exactly that shape. From a goroutine:
|
|
// send takes t.mu, which this handler holds.
|
|
go t.subscribeSensors("ready")
|
|
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
|
|
// The radio has caught up: from here the echo IS the truth.
|
|
if t.wantFreq != 0 && absInt64(hz-t.wantFreq) < 100 {
|
|
t.wantFreq = 0
|
|
}
|
|
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" {
|
|
was := t.tx
|
|
t.tx = get(1) == "true"
|
|
// Said out loud, every time. The transmit side of TCI can only be
|
|
// written from a log of a real transmission, and the first one came
|
|
// back without a single line to say whether the radio had even been
|
|
// keyed — which left the interesting question, why no transmit
|
|
// frames, indistinguishable from nobody having pressed anything.
|
|
if was != t.tx {
|
|
t.noteTXTransition(t.tx)
|
|
}
|
|
}
|
|
case "drive":
|
|
if get(0) == "0" {
|
|
if v, err := strconv.Atoi(get(1)); err == nil {
|
|
t.drive = v
|
|
}
|
|
}
|
|
case "tx_enable":
|
|
if get(0) == "0" {
|
|
allowed := get(1) == "true"
|
|
if !t.txAllowedKnown || t.txAllowed != allowed {
|
|
debugLog.Printf("TCI: the radio %s transmitting", map[bool]string{true: "allows", false: "REFUSES"}[allowed])
|
|
}
|
|
t.txAllowed, t.txAllowedKnown = allowed, true
|
|
}
|
|
default:
|
|
lname := lower
|
|
// A click on one of our panorama spots comes back as
|
|
// CLICKED_ON_SPOT:<call>,<hz> (legacy)
|
|
// RX_CLICKED_ON_SPOT:<rx>,<ch>,<call>,<hz>
|
|
// Neither name starts with "spot", which is why the click was silently
|
|
// ignored before. Read the callsign (the one non-numeric field) and the
|
|
// frequency (the large numeric field) positionally-independently, so both
|
|
// shapes work without depending on the exact arg order.
|
|
if strings.Contains(lname, "spot") {
|
|
var call string
|
|
var hz int64
|
|
for _, raw := range f {
|
|
v := strings.TrimSpace(raw)
|
|
if v == "" {
|
|
continue
|
|
}
|
|
if n, err := strconv.ParseInt(v, 10, 64); err == nil {
|
|
if n >= 10000 { // a real frequency, not an rx/channel index
|
|
hz = n
|
|
}
|
|
} else if call == "" {
|
|
call = strings.ToUpper(v) // callsigns always carry letters
|
|
}
|
|
}
|
|
debugLog.Printf("TCI: spot click %q → call=%s freq=%d", msg, call, hz)
|
|
if call != "" && t.OnSpotClick != nil {
|
|
cb := t.OnSpotClick
|
|
go cb(call, hz)
|
|
}
|
|
return
|
|
}
|
|
// Log every OTHER unknown message TYPE once, so the protocol (incl. any
|
|
// spot-click notification named differently) is discoverable from the log
|
|
// without flooding it with the frequent streamed messages.
|
|
if t.unhandledSeen == nil {
|
|
t.unhandledSeen = map[string]bool{}
|
|
}
|
|
if !t.unhandledSeen[lname] {
|
|
t.unhandledSeen[lname] = true
|
|
debugLog.Printf("TCI: (unhandled once) %s", msg)
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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"
|
|
}
|
|
}
|
|
|
|
// tciSpotsUnsupported reports whether a TCI version predates the spot commands.
|
|
//
|
|
// SPOT / SPOT_DELETE / SPOT_CLEAR arrived in TCI 1.5. An older ExpertSDR accepts
|
|
// the connection, answers frequency and mode perfectly, and silently ignores
|
|
// every spot — which is indistinguishable from a bug in the logger unless
|
|
// somebody says so. Anything unparseable is treated as supported: refusing to
|
|
// draw on a doubt would be the worse mistake.
|
|
func tciSpotsUnsupported(version string) bool {
|
|
var maj, min int
|
|
if n, err := fmt.Sscanf(strings.TrimSpace(version), "%d.%d", &maj, &min); n < 2 || err != nil {
|
|
return false
|
|
}
|
|
return maj < 1 || (maj == 1 && min < 5)
|
|
}
|