Two faults with one cause: this radio does not echo 'tune:0,true'. So the panel never knew a tune was running. The button stayed on TUNE and every further press sent another START — there was no way to stop it from here at all. The state is recorded when the command is sent now; whatever the radio says afterwards still wins, it simply never says anything. And the transmit meters were asked for only while t.tx, which a tune carrier does not set: the radio reports tuning as its own state, not as a transmission. So power and SWR sat at zero for the whole tune — the exact carrier an operator holds a tune for in order to watch an SWR on. They now follow PTT or TUNE, and the S-meter reads '—' under our own carrier either way.
654 lines
22 KiB
Go
654 lines
22 KiB
Go
//go:build windows
|
|
|
|
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
|
|
|
|
// 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)
|
|
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))
|
|
}
|
|
// TCI's SPOT command wants the colour as a signed 32-bit DECIMAL integer in
|
|
// 0xAARRGGBB order — NOT a "0x…" hex string (e.g. "spot:UN7GK,cw,14025000,
|
|
// -16776961,test;"). ExpertSDR silently drops a spot whose colour field it
|
|
// can't parse as a number, which is why spots never showed on the panorama
|
|
// while tuning (a separate command) still worked.
|
|
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)
|
|
return t.send(fmt.Sprintf("spot:%s,%s,%d,%d,%s;", call, mode, s.FreqHz, int32(argb), text))
|
|
}
|
|
|
|
// 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 {
|
|
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
|
|
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).
|
|
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 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
|
|
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
|
|
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"
|
|
}
|
|
}
|