682 lines
22 KiB
Go
682 lines
22 KiB
Go
// Package tciserver shares OpsLog's CAT link with programs that speak TCI.
|
|
//
|
|
// It is the second half of internal/rigctld, and exists for the same reason:
|
|
// Windows gives a COM port to ONE process, so the moment OpsLog talks to the
|
|
// radio directly nothing else can. rigctld answers the programs that speak
|
|
// Hamlib NET rigctl (WSJT-X, JTDX, MSHV, Log4OM); this answers the ones built
|
|
// around Expert Electronics' TCI instead — and it answers them whatever radio
|
|
// is actually connected, because it sits on the same backend-agnostic
|
|
// interface. An operator with an Icom or a Yaesu can hand a TCI-only program a
|
|
// working rig.
|
|
//
|
|
// ── The protocol ──────────────────────────────────────────────────────────
|
|
// Text commands over a WebSocket, "name:arg,arg;", the same syntax in both
|
|
// directions. On connection the server sends a block of initialisation
|
|
// commands describing the device, ending with ready; and start;. Thereafter
|
|
// either side may send a control command, and the server echoes every change
|
|
// to all connected clients so they stay in step with each other.
|
|
//
|
|
// vfo:0,0,14074000; receiver 0, channel A (RX), Hz
|
|
// vfo:0,1,14080000; channel B — the TX frequency when split is on
|
|
// modulation:0,usb; mode
|
|
// trx:0,true; PTT
|
|
// split_enable:0,true; split
|
|
// vfo:0,0; a READ: the reply is the three-argument form
|
|
//
|
|
// Written against the official TCI Protocol document (ExpertSDR3/TCI, 12
|
|
// January 2024, MIT) — the initialisation set and the argument order of every
|
|
// command below are from §4.1 and §4.2, not from guesswork about what a client
|
|
// might accept.
|
|
package tciserver
|
|
|
|
import (
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/gorilla/websocket"
|
|
)
|
|
|
|
// Rig is what the server needs from OpsLog's CAT manager. An interface, so this
|
|
// package stays testable without a radio and without importing internal/cat —
|
|
// which also keeps it building on every platform.
|
|
type Rig interface {
|
|
Freq() int64 // TX frequency in Hz (ADIF sense), 0 if unknown
|
|
RxFreq() int64 // RX frequency in Hz; equals Freq when not split
|
|
Mode() string // ADIF mode (SSB, CW, FT8…)
|
|
Split() (bool, int64) // split on?, and the TX frequency
|
|
SetFreq(hz int64) error
|
|
SetMode(mode string) error
|
|
SetPTT(on bool) error
|
|
SetSplit(on bool, txHz int64) error
|
|
}
|
|
|
|
// DefaultPort is TCI's own default, which is what a client offers first.
|
|
const DefaultPort = 40001
|
|
|
|
// pollInterval is how often the rig is compared with what the clients were last
|
|
// told. TCI is an event protocol — a client is entitled to sit silent and be
|
|
// told when something moves — so this is the rate at which a knob turned on the
|
|
// radio reaches it.
|
|
const pollInterval = 250 * time.Millisecond
|
|
|
|
type Server struct {
|
|
port int
|
|
rig Rig
|
|
log func(string, ...any)
|
|
|
|
mu sync.Mutex
|
|
ln net.Listener
|
|
http *http.Server
|
|
conns map[*client]struct{}
|
|
closed bool
|
|
|
|
// pendingTxHz is a transmit frequency a client set on channel B while the rig
|
|
// was still simplex.
|
|
//
|
|
// It must be REMEMBERED, not discarded. A client working split sends two
|
|
// commands and is free to send them in either order; when the frequency comes
|
|
// first, throwing it away means the split is then armed on whatever the
|
|
// transmit VFO happened to hold — the receive frequency — and the operator
|
|
// transmits straight onto the DX while their software shows exactly what they
|
|
// asked for. rigctld learned this the same way, and pairs set_split_vfo with
|
|
// set_split_freq for the same reason.
|
|
pendingTxHz int64
|
|
|
|
// ptt mirrors the last PTT state a client commanded, so a repeat can be
|
|
// recognised. A client is free to restate PTT as often as it likes, and one
|
|
// does: through the rigctl server Nexus sent set_ptt 0 about sixteen times a
|
|
// second, and the Flex's own "xmit 1" landed between two of them and was
|
|
// overwritten inside a millisecond — a transmit request that simply did
|
|
// nothing. The same radio sits behind this server.
|
|
ptt bool
|
|
pttKnown bool
|
|
|
|
// last is what the clients have been told, so only changes are sent. TCI
|
|
// clients redraw on every command they receive; re-sending an unchanged
|
|
// frequency four times a second makes a VFO readout flicker and, in some
|
|
// clients, fights the operator's own tuning.
|
|
last state
|
|
}
|
|
|
|
// state is the part of the rig the clients are kept in step with.
|
|
type state struct {
|
|
rxHz int64
|
|
txHz int64
|
|
mode string
|
|
split bool
|
|
valid bool
|
|
}
|
|
|
|
// clientLogCap bounds how many of one client's commands reach the log.
|
|
const clientLogCap = 200
|
|
|
|
// client is one connected program.
|
|
type client struct {
|
|
conn *websocket.Conn
|
|
mu sync.Mutex // one writer at a time: gorilla panics on concurrent writes
|
|
// logged counts what has been written to the log for this connection. Only
|
|
// the reader goroutine touches it, so it needs no lock of its own.
|
|
logged int
|
|
}
|
|
|
|
func (c *client) send(s string) error {
|
|
c.mu.Lock()
|
|
defer c.mu.Unlock()
|
|
if c.conn == nil {
|
|
return nil // a client with no socket: the tests exercise the protocol, not the transport
|
|
}
|
|
_ = c.conn.SetWriteDeadline(time.Now().Add(3 * time.Second))
|
|
return c.conn.WriteMessage(websocket.TextMessage, []byte(s))
|
|
}
|
|
|
|
func New(port int, rig Rig, logf func(string, ...any)) *Server {
|
|
if port <= 0 || port > 65535 {
|
|
port = DefaultPort
|
|
}
|
|
if logf == nil {
|
|
logf = func(string, ...any) {}
|
|
}
|
|
return &Server{port: port, rig: rig, log: logf, conns: map[*client]struct{}{}}
|
|
}
|
|
|
|
// Start binds the port and serves until Stop.
|
|
func (s *Server) Start() error {
|
|
ln, err := net.Listen("tcp", fmt.Sprintf(":%d", s.port))
|
|
if err != nil {
|
|
return fmt.Errorf("tci server: port %d: %w", s.port, err)
|
|
}
|
|
up := websocket.Upgrader{
|
|
// Any origin: the clients are desktop programs on the same machine or
|
|
// LAN, and they send whatever Origin their toolkit happens to set. This
|
|
// is the same trust boundary as the rigctl server on 4532 — a plain TCP
|
|
// port with no authentication, which is what every logger expects.
|
|
CheckOrigin: func(*http.Request) bool { return true },
|
|
}
|
|
mux := http.NewServeMux()
|
|
// Any path: clients connect to ws://host:port/ but some append a name.
|
|
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
|
|
conn, err := up.Upgrade(w, r, nil)
|
|
if err != nil {
|
|
s.log("tci server: upgrade from %s failed: %v", r.RemoteAddr, err)
|
|
return
|
|
}
|
|
s.serve(&client{conn: conn}, r.RemoteAddr)
|
|
})
|
|
srv := &http.Server{Handler: mux}
|
|
s.mu.Lock()
|
|
s.ln, s.http, s.closed = ln, srv, false
|
|
s.mu.Unlock()
|
|
go func() { _ = srv.Serve(ln) }()
|
|
go s.pushLoop()
|
|
s.log("tci server: listening on :%d", s.port)
|
|
return nil
|
|
}
|
|
|
|
// Stop closes the listener and every client.
|
|
// releasePTT drops a PTT this server asserted, and does it once.
|
|
//
|
|
// A client that dies mid-over — or a settings save that closes the server —
|
|
// leaves the rig keyed with nobody left to un-key it, into an amplifier that has
|
|
// no idea the transmission ended. rigctld has had this guard for a while (a K3
|
|
// once sat in transmit for 29 s until the CAT link happened to be rebuilt); the
|
|
// TCI server was written without it, so an operator who moved from Hamlib to TCI
|
|
// silently lost the protection.
|
|
//
|
|
// pttKnown is cleared whatever happens: after an emergency unkey the radio's
|
|
// state is a guess, and the next command must reach it rather than be dismissed
|
|
// as a repeat.
|
|
// Keyed reports whether a client currently has the rig transmitting. See the
|
|
// rigctld server's own Keyed for why the caller wants to know.
|
|
func (s *Server) Keyed() bool {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return s.ptt
|
|
}
|
|
|
|
func (s *Server) releasePTT(why string) {
|
|
s.mu.Lock()
|
|
keyed := s.ptt
|
|
s.ptt, s.pttKnown = false, false
|
|
s.mu.Unlock()
|
|
if !keyed {
|
|
return
|
|
}
|
|
s.log("tci server: %s while the rig was keyed — dropping PTT", why)
|
|
if err := s.rig.SetPTT(false); err != nil {
|
|
s.log("tci server: emergency unkey FAILED: %v", err)
|
|
return
|
|
}
|
|
// Any client still attached is told, so a second logger's transmit indicator
|
|
// does not stay lit over a rig that is back in receive.
|
|
s.broadcast("trx:0,false;")
|
|
}
|
|
|
|
func (s *Server) Stop() {
|
|
// Before anything is torn down: the CAT backend is still up here, so an unkey
|
|
// still lands. Same ordering as rigctld.Stop for the same reason.
|
|
s.releasePTT("TCI server stopped")
|
|
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.closed = true
|
|
ln, srv := s.ln, s.http
|
|
conns := make([]*client, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.conns = map[*client]struct{}{}
|
|
s.last = state{}
|
|
s.mu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.conn.Close()
|
|
}
|
|
if srv != nil {
|
|
_ = srv.Close()
|
|
}
|
|
if ln != nil {
|
|
_ = ln.Close()
|
|
}
|
|
s.log("tci server: stopped")
|
|
}
|
|
|
|
// Clients reports how many programs are connected — the one thing an operator
|
|
// wants to know when a client says it cannot find the rig.
|
|
func (s *Server) Clients() int {
|
|
s.mu.Lock()
|
|
defer s.mu.Unlock()
|
|
return len(s.conns)
|
|
}
|
|
|
|
// serve runs one connection: the initialisation block, then commands until it
|
|
// closes.
|
|
func (s *Server) serve(c *client, remote string) {
|
|
s.mu.Lock()
|
|
if s.closed {
|
|
s.mu.Unlock()
|
|
_ = c.conn.Close()
|
|
return
|
|
}
|
|
s.conns[c] = struct{}{}
|
|
s.mu.Unlock()
|
|
s.log("tci server: %s connected", remote)
|
|
|
|
for _, line := range s.initBlock() {
|
|
if err := c.send(line); err != nil {
|
|
break
|
|
}
|
|
}
|
|
for {
|
|
_, data, err := c.conn.ReadMessage()
|
|
if err != nil {
|
|
break
|
|
}
|
|
// One frame may carry several ";"-terminated commands.
|
|
for _, cmd := range strings.Split(string(data), ";") {
|
|
if cmd = strings.TrimSpace(cmd); cmd == "" {
|
|
continue
|
|
}
|
|
// Every command the client sends, in the log.
|
|
//
|
|
// This is the only evidence there will ever be about a program on
|
|
// someone else's machine: "MSHV's PTT test does nothing" is
|
|
// unanswerable without knowing whether MSHV sent trx at all, and if
|
|
// so in what form. Cheap, because TCI is event-driven — a client
|
|
// speaks when the operator does something, not on a timer.
|
|
//
|
|
// Capped so a client that DOES poll cannot quietly fill the
|
|
// operator's log; the cap says so once and then stays quiet.
|
|
if c.logged < clientLogCap {
|
|
c.logged++
|
|
s.log("tci server: ← %s;", cmd)
|
|
} else if c.logged == clientLogCap {
|
|
c.logged++
|
|
s.log("tci server: (further commands from this client are not logged)")
|
|
}
|
|
s.handle(c, cmd)
|
|
}
|
|
}
|
|
s.mu.Lock()
|
|
delete(s.conns, c)
|
|
s.mu.Unlock()
|
|
_ = c.conn.Close()
|
|
s.log("tci server: %s disconnected", remote)
|
|
// A client that walks away mid-over must not leave the rig transmitting.
|
|
s.releasePTT("client " + remote + " left")
|
|
}
|
|
|
|
// initBlock is the initialisation set from §4.1 of the protocol document, in
|
|
// the documented order, followed by the current state so a client that has just
|
|
// connected shows the right frequency instead of waiting for the first change.
|
|
//
|
|
// A client will not proceed without these: they are how it learns the device
|
|
// exists, what it can do, and that the server has finished setting up.
|
|
func (s *Server) initBlock() []string {
|
|
rx, tx, mode, split := s.read()
|
|
return []string{
|
|
"protocol:ExpertSDR3,1.9;",
|
|
"device:OpsLog;",
|
|
"receive_only:false;",
|
|
"trx_count:1;",
|
|
"channel_count:2;",
|
|
// The whole HF/VHF/UHF span OpsLog itself works over. A client uses this
|
|
// to bound its own tuning; too narrow a range and it refuses to follow the
|
|
// rig onto 2 m.
|
|
"vfo_limits:10000,470000000;",
|
|
"if_limits:-48000,48000;",
|
|
"modulations_list:am,sam,dsb,lsb,usb,cw,nfm,digl,digu;",
|
|
"ready;",
|
|
"start;",
|
|
fmt.Sprintf("vfo:0,0,%d;", rx),
|
|
fmt.Sprintf("vfo:0,1,%d;", tx),
|
|
fmt.Sprintf("modulation:0,%s;", mode),
|
|
fmt.Sprintf("split_enable:0,%t;", split),
|
|
"trx:0,false;",
|
|
// TRANSMIT PERMISSION, and it is not optional in practice.
|
|
//
|
|
// The document files TX_ENABLE under unidirectional control rather than
|
|
// initialisation, but its own note says it is "sent to the client when
|
|
// connected". A client that models permission — and one written for
|
|
// ExpertSDR users has every reason to — starts out assuming it may NOT
|
|
// transmit, and without this it never even tries: PTT does nothing and
|
|
// the server never sees a trx command to refuse.
|
|
//
|
|
// Always true. OpsLog is not the thing that decides: the radio behind
|
|
// whichever backend is connected does, and its refusal comes back through
|
|
// SetPTT and into the log.
|
|
"tx_enable:0,true;",
|
|
fmt.Sprintf("tx_frequency:%d;", tx),
|
|
}
|
|
}
|
|
|
|
// read takes one consistent snapshot of the rig in TCI's terms: channel A is
|
|
// where we LISTEN and channel B where we transmit, which is the opposite way
|
|
// round from ADIF's RigState and the one mistake here that would make a client
|
|
// transmit on the DX's frequency.
|
|
func (s *Server) read() (rxHz, txHz int64, mode string, split bool) {
|
|
split, txHz = s.rig.Split()
|
|
rxHz = s.rig.RxFreq()
|
|
if !split {
|
|
txHz = s.rig.Freq()
|
|
if rxHz == 0 {
|
|
rxHz = txHz
|
|
}
|
|
}
|
|
if rxHz == 0 {
|
|
rxHz = s.rig.Freq()
|
|
}
|
|
if txHz == 0 {
|
|
txHz = rxHz
|
|
}
|
|
mode = adifToTCIMode(s.rig.Mode(), rxHz)
|
|
return rxHz, txHz, mode, split
|
|
}
|
|
|
|
// pushLoop tells the clients what has changed on the radio.
|
|
func (s *Server) pushLoop() {
|
|
t := time.NewTicker(pollInterval)
|
|
defer t.Stop()
|
|
for range t.C {
|
|
s.mu.Lock()
|
|
done := s.closed
|
|
s.mu.Unlock()
|
|
if done {
|
|
return
|
|
}
|
|
s.publish()
|
|
}
|
|
}
|
|
|
|
// publish sends only what moved. Returns the lines sent, for the tests.
|
|
func (s *Server) publish() []string {
|
|
rx, tx, mode, split := s.read()
|
|
cur := state{rxHz: rx, txHz: tx, mode: mode, split: split, valid: true}
|
|
|
|
s.mu.Lock()
|
|
prev := s.last
|
|
s.last = cur
|
|
s.mu.Unlock()
|
|
|
|
var lines []string
|
|
if !prev.valid || prev.rxHz != cur.rxHz {
|
|
lines = append(lines, fmt.Sprintf("vfo:0,0,%d;", cur.rxHz))
|
|
}
|
|
if !prev.valid || prev.txHz != cur.txHz {
|
|
lines = append(lines, fmt.Sprintf("vfo:0,1,%d;", cur.txHz))
|
|
// The transmit frequency has its own command, which is what a client
|
|
// showing "TX 14.080" reads. Channel B alone leaves that stale.
|
|
lines = append(lines, fmt.Sprintf("tx_frequency:%d;", cur.txHz))
|
|
}
|
|
if (!prev.valid || prev.mode != cur.mode) && cur.mode != "" {
|
|
lines = append(lines, fmt.Sprintf("modulation:0,%s;", cur.mode))
|
|
}
|
|
if !prev.valid || prev.split != cur.split {
|
|
lines = append(lines, fmt.Sprintf("split_enable:0,%t;", cur.split))
|
|
}
|
|
for _, l := range lines {
|
|
s.broadcast(l)
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func (s *Server) broadcast(line string) {
|
|
s.mu.Lock()
|
|
conns := make([]*client, 0, len(s.conns))
|
|
for c := range s.conns {
|
|
conns = append(conns, c)
|
|
}
|
|
s.mu.Unlock()
|
|
for _, c := range conns {
|
|
_ = c.send(line)
|
|
}
|
|
}
|
|
|
|
// handle answers one command from a client. Returns what was sent back, which
|
|
// is "" for a command that only acts on the radio.
|
|
//
|
|
// A command that SETS something is echoed to every client, not just answered to
|
|
// the one that sent it: the protocol document is explicit that the server
|
|
// synchronises all connected clients, and two loggers that disagree about the
|
|
// frequency are worse than one that is merely slow.
|
|
func (s *Server) handle(c *client, cmd string) string {
|
|
name, args := cmd, ""
|
|
if i := strings.IndexByte(cmd, ':'); i >= 0 {
|
|
name, args = cmd[:i], cmd[i+1:]
|
|
}
|
|
f := strings.Split(args, ",")
|
|
arg := func(i int) string {
|
|
if i < len(f) {
|
|
return strings.TrimSpace(f[i])
|
|
}
|
|
return ""
|
|
}
|
|
num := func(i int) int64 {
|
|
v, _ := strconv.ParseInt(arg(i), 10, 64)
|
|
return v
|
|
}
|
|
reply := func(line string) string {
|
|
_ = c.send(line)
|
|
return line
|
|
}
|
|
rx, tx, mode, split := s.read()
|
|
|
|
switch strings.ToLower(strings.TrimSpace(name)) {
|
|
case "vfo":
|
|
// Read form: two arguments. Set form: three.
|
|
if len(f) < 3 || arg(2) == "" {
|
|
if arg(1) == "1" {
|
|
return reply(fmt.Sprintf("vfo:0,1,%d;", tx))
|
|
}
|
|
return reply(fmt.Sprintf("vfo:0,0,%d;", rx))
|
|
}
|
|
hz := num(2)
|
|
if hz <= 0 {
|
|
return ""
|
|
}
|
|
if arg(1) == "1" {
|
|
// Channel B is the transmit frequency. Setting it while simplex must
|
|
// not move the rig's only VFO — the client asked to prepare a split
|
|
// transmit frequency, not to QSY — but it must not be thrown away
|
|
// either: it is where the split will be armed a moment from now.
|
|
s.mu.Lock()
|
|
s.pendingTxHz = hz
|
|
s.mu.Unlock()
|
|
if !split {
|
|
s.broadcast(fmt.Sprintf("vfo:0,1,%d;", hz))
|
|
return ""
|
|
}
|
|
if err := s.rig.SetSplit(true, hz); err != nil {
|
|
s.log("tci server: split TX %d Hz refused: %v", hz, err)
|
|
return ""
|
|
}
|
|
} else if err := s.rig.SetFreq(hz); err != nil {
|
|
s.log("tci server: tune to %d Hz refused: %v", hz, err)
|
|
return ""
|
|
}
|
|
s.broadcast(fmt.Sprintf("vfo:0,%s,%d;", orZero(arg(1)), hz))
|
|
return ""
|
|
|
|
case "modulation":
|
|
if len(f) < 2 || arg(1) == "" {
|
|
return reply(fmt.Sprintf("modulation:0,%s;", mode))
|
|
}
|
|
m := tciModeToADIF(arg(1))
|
|
if m == "" {
|
|
return ""
|
|
}
|
|
if err := s.rig.SetMode(m); err != nil {
|
|
s.log("tci server: mode %s refused: %v", m, err)
|
|
return ""
|
|
}
|
|
s.broadcast(fmt.Sprintf("modulation:0,%s;", strings.ToLower(arg(1))))
|
|
return ""
|
|
|
|
case "trx":
|
|
if len(f) < 2 || arg(1) == "" {
|
|
return reply("trx:0,false;")
|
|
}
|
|
on := strings.EqualFold(arg(1), "true")
|
|
// Only touch the radio on a CHANGE — restating a state is not a request
|
|
// to change it. The first command always goes through, since there is no
|
|
// knowing how the radio was left.
|
|
s.mu.Lock()
|
|
known, prev := s.pttKnown, s.ptt
|
|
s.mu.Unlock()
|
|
if known && prev == on {
|
|
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
|
return ""
|
|
}
|
|
if err := s.rig.SetPTT(on); err != nil {
|
|
// The cache is stamped ONLY on success, and a failure clears "known"
|
|
// outright so the NEXT command — whatever it is — reaches the radio.
|
|
//
|
|
// It used to be written before the radio was commanded and left in
|
|
// place when the command failed. That is how a rig got stuck keyed for
|
|
// good: the un-key failed on a lost CI-V acknowledgement, the cache
|
|
// recorded "off" regardless, and from then on every trx:0,false was
|
|
// dismissed as a repeat of a state the radio had never reached. Not
|
|
// even reconnecting the client cleared it — this cache is per-server,
|
|
// not per-connection — so the transmitter stayed keyed into the
|
|
// amplifier until the operator switched the radio off. A cache must
|
|
// never claim something the radio refused.
|
|
s.mu.Lock()
|
|
s.pttKnown = false
|
|
s.mu.Unlock()
|
|
s.log("tci server: PTT %v refused: %v", on, err)
|
|
return ""
|
|
}
|
|
s.mu.Lock()
|
|
s.ptt, s.pttKnown = on, true
|
|
s.mu.Unlock()
|
|
s.log("tci server: PTT %s", map[bool]string{true: "ON", false: "off"}[on])
|
|
s.broadcast(fmt.Sprintf("trx:0,%t;", on))
|
|
return ""
|
|
|
|
case "split_enable":
|
|
if len(f) < 2 || arg(1) == "" {
|
|
return reply(fmt.Sprintf("split_enable:0,%t;", split))
|
|
}
|
|
on := strings.EqualFold(arg(1), "true")
|
|
// Already in the state asked for? Then it is done, and nothing goes to
|
|
// the radio. This is the lesson the rigctl server paid for: JTDX in "Fake
|
|
// It" uses no split but still says so to be sure, and a backend that
|
|
// cannot set split answered an error to a request that was already true.
|
|
// JTDX read that as rig control failing and abandoned the transmission a
|
|
// second into the frame. A refusal is only honest when something actually
|
|
// needed doing.
|
|
if on == split {
|
|
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
|
return ""
|
|
}
|
|
// Arm on the frequency the client gave for channel B, which it is free to
|
|
// have sent before this command rather than after.
|
|
s.mu.Lock()
|
|
pending := s.pendingTxHz
|
|
s.mu.Unlock()
|
|
txHz := tx
|
|
if on && pending > 0 {
|
|
txHz = pending
|
|
}
|
|
if err := s.rig.SetSplit(on, txHz); err != nil {
|
|
// The refusal is the useful part: a backend that cannot split says
|
|
// so, and the client can tell the operator instead of transmitting
|
|
// on the wrong frequency believing all is well.
|
|
s.log("tci server: split %v refused: %v", on, err)
|
|
return ""
|
|
}
|
|
s.log("tci server: split %s, TX %d Hz", map[bool]string{true: "ON", false: "off"}[on], txHz)
|
|
s.broadcast(fmt.Sprintf("split_enable:0,%t;", on))
|
|
return ""
|
|
|
|
case "dds":
|
|
// The panorama's centre frequency. OpsLog has no panorama, so it answers
|
|
// with the receive frequency — which is where a client draws its own.
|
|
return reply(fmt.Sprintf("dds:0,%d;", rx))
|
|
|
|
case "if":
|
|
// Offset of the tuning filter inside the panorama: zero, since our "dds"
|
|
// is the receive frequency itself.
|
|
return reply("if:0,0,0;")
|
|
|
|
case "start", "stop", "ready":
|
|
return ""
|
|
|
|
default:
|
|
// Everything else — audio streams, CW macros, the E-Coder, the
|
|
// panorama's own settings — belongs to a radio, not to a CAT link.
|
|
// Silence rather than an error: a client sends these hopefully at
|
|
// connect, and a refusal it did not ask for reads as a fault.
|
|
return ""
|
|
}
|
|
}
|
|
|
|
func orZero(s string) string {
|
|
if s == "" {
|
|
return "0"
|
|
}
|
|
return s
|
|
}
|
|
|
|
// adifToTCIMode maps an ADIF mode to a TCI modulation.
|
|
//
|
|
// SSB carries no sideband, so it is resolved from the frequency the way every
|
|
// operator does: below 10 MHz lower, above it upper. A client told "ssb" would
|
|
// not recognise it — the modulation list is the vocabulary.
|
|
func adifToTCIMode(mode string, hz int64) string {
|
|
switch strings.ToUpper(strings.TrimSpace(mode)) {
|
|
case "":
|
|
return ""
|
|
case "CW", "CWR":
|
|
return "cw"
|
|
case "USB":
|
|
return "usb"
|
|
case "LSB":
|
|
return "lsb"
|
|
case "SSB":
|
|
if hz > 0 && hz < 10_000_000 {
|
|
return "lsb"
|
|
}
|
|
return "usb"
|
|
case "AM":
|
|
return "am"
|
|
case "FM", "NFM":
|
|
return "nfm"
|
|
case "RTTY":
|
|
return "digl"
|
|
}
|
|
// Everything else is a data mode: FT8, FT4, JT65, PSK31, MSK144, VARA…
|
|
// TCI has one pair for the whole family, and the sideband follows the same
|
|
// rule the data modes themselves use — upper, but for the few HF corners
|
|
// where LSB is conventional the radio is already there.
|
|
return "digu"
|
|
}
|
|
|
|
// tciModeToADIF maps a TCI modulation back to an ADIF mode.
|
|
func tciModeToADIF(m string) string {
|
|
switch strings.ToLower(strings.TrimSpace(m)) {
|
|
case "cw":
|
|
return "CW"
|
|
case "usb":
|
|
return "USB"
|
|
case "lsb":
|
|
return "LSB"
|
|
case "am", "sam":
|
|
return "AM"
|
|
case "nfm", "fm", "wfm":
|
|
return "FM"
|
|
case "digl", "digu", "dsb", "drm":
|
|
// The data family: the mode the operator is actually running (FT8, RTTY)
|
|
// is chosen in OpsLog, and a client switching to "digital" must not
|
|
// overwrite it with a guess. DATA is the honest ADIF answer.
|
|
return "DATA"
|
|
}
|
|
return ""
|
|
}
|