TCI already carries the frequency, the mode, the meters and now the audio. It also carries everything else about the radio — and OpsLog was logging most of it once as '(unhandled once)' and throwing it away. The console is mostly a place to put what was already arriving. That makes it the cheapest panel here, and it is worth saying why. A K3 console costs a command and a reply for every value it shows, which is why it reads its settings in a rotation and its meters only while on screen. TCI PUSHES: the radio announces its drive, its filters, its noise blanker and the rest on connect, and again whenever any of them changes — including when the operator changes them in ExpertSDR3's own window, which this panel therefore follows without asking anything. What it drives: drive and tune drive, mic gain, TUNE, volume, mute, squelch and its threshold, NB, NR, ANF, APF, AGC speed, the passband, RIT and XIT with their offsets, and the VFO lock. The S-meter is a real dBm reading, so its S units are arithmetic rather than the calibration guess a K3's meter needs. Setters never update the cached state. The radio answers with the new value, and taking its word is what keeps the panel honest when a setting is refused, clamped, or changed at the radio a second later — the one exception being a slider mid-drag, held for 900 ms so it is not dragged back by its own echo. Capped width and centred, like the other consoles. Also offered as a docked pane — and the Elecraft console is offered there too now: App has always had that pane, Settings simply never listed it.
638 lines
21 KiB
Go
638 lines
21 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
|
|
}
|
|
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"
|
|
}
|
|
}
|