// 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. func (s *Server) Stop() { 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) } // 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.ptt, s.pttKnown = on, true s.mu.Unlock() if known && prev == on { s.broadcast(fmt.Sprintf("trx:0,%t;", on)) return "" } if err := s.rig.SetPTT(on); err != nil { s.log("tci server: PTT %v refused: %v", on, err) return "" } 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 "" }