// Package catemu emulates a transceiver on a serial port so a device that // POLLS a radio for its frequency can follow OpsLog instead. // // This exists for the ACOM amplifiers: on their CAT/AUX connector the amp is // the master — it polls the transceiver every few hundred milliseconds and // changes band only when it gets a valid reply. There is no way to push a // frequency to it, so following OpsLog means answering its polls. // // The dialect is ACOM "command set 5" (Kenwood / Elecraft RS-232, also what // Flex and SunSDR users select): plain ASCII commands terminated by ';'. It is // the simplest of the five sets by a wide margin, which is why the SDC utility // uses it to steer an ACOM with no physical radio attached. // // Only the handful of commands an amp actually asks for are implemented: // // FA; → FA00014025000; TX frequency, 11 digits, Hz // FB; → same (sub VFO — amps poll it on some firmware) // IF; → the 38-character TS-2000 status frame // ID; → ID019; (TS-2000 — a known model keeps the amp from timing out) // // Anything else is ignored rather than answered: a wrong-length reply is worse // than none, because it desynchronises the amp's parser for the next poll. package catemu import ( "fmt" "strings" "sync" "sync/atomic" "time" "go.bug.st/serial" ) // Config is the serial port the amplifier's CAT/AUX cable is wired to. This is // a SECOND port, independent of the one used for the amp's own remote/metering // protocol — both run at the same time on an ACOM. type Config struct { ComPort string Baud int // BroadcastMs > 0 also sends the frequency UNPROMPTED every that many // milliseconds. Some amplifiers do not poll at all: they sit in parallel on // the radio↔PC CAT line and read whatever goes past. Such an amp would never // hear us, since answering polls means speaking only when spoken to. // 0 = answer polls only. BroadcastMs int } // Status is what the settings panel shows about the link. type Status struct { Enabled bool `json:"enabled"` Connected bool `json:"connected"` Port string `json:"port"` Polls int64 `json:"polls"` // replies sent since start LastCmd string `json:"last_cmd"` // last command received, e.g. "FA;" LastAt string `json:"last_at"` // RFC3339 of the last poll, "" if none FreqHz int64 `json:"freq_hz"` // what we are currently answering Error string `json:"error"` // last open/IO failure } // Server answers a polling amplifier on one serial port. type Server struct { cfg Config mu sync.Mutex port serial.Port status Status freqHz atomic.Int64 mode atomic.Value // string, ADIF-ish ("CW", "USB"…) stop chan struct{} done chan struct{} logf func(string, ...any) } // New builds a server. Nothing is opened until Start. func New(cfg Config, logf func(string, ...any)) *Server { if cfg.Baud <= 0 { cfg.Baud = 9600 } s := &Server{cfg: cfg, logf: logf} s.mode.Store("") s.status.Port = cfg.ComPort return s } func (s *Server) log(format string, args ...any) { if s.logf != nil { s.logf(format, args...) } } // SetFrequency updates the frequency reported to the amplifier. Called from the // CAT state callback; safe from any goroutine and never blocks — the serve loop // reads the value when a poll arrives, so a fast-tuning VFO costs nothing. func (s *Server) SetFrequency(hz int64) { s.freqHz.Store(hz) } // SetMode updates the mode digit in the IF frame. Optional: the amp only cares // about the frequency, but a coherent frame avoids odd firmware behaviour. func (s *Server) SetMode(mode string) { s.mode.Store(strings.ToUpper(strings.TrimSpace(mode))) } // Start opens the port and serves polls until Stop. It returns immediately; // a port that is missing or busy is retried every 5 s, because the amplifier is // often powered on after the software. func (s *Server) Start() { s.stop = make(chan struct{}) s.done = make(chan struct{}) go s.run() } // Stop closes the port and waits for the loop to end. func (s *Server) Stop() { if s.stop == nil { return } close(s.stop) s.mu.Lock() if s.port != nil { _ = s.port.Close() s.port = nil } s.mu.Unlock() <-s.done s.stop = nil } // GetStatus returns a snapshot for the UI. func (s *Server) GetStatus() Status { s.mu.Lock() defer s.mu.Unlock() st := s.status st.Enabled = true st.FreqHz = s.freqHz.Load() return st } func (s *Server) setErr(msg string) { s.mu.Lock() s.status.Error = msg s.status.Connected = false s.mu.Unlock() } func (s *Server) run() { defer close(s.done) for { select { case <-s.stop: return default: } if err := s.open(); err != nil { s.setErr(err.Error()) s.log("catemu: %s open failed: %v (retry in 5s)", s.cfg.ComPort, err) select { case <-s.stop: return case <-time.After(5 * time.Second): } continue } s.serve() } } func (s *Server) open() error { if strings.TrimSpace(s.cfg.ComPort) == "" { return fmt.Errorf("no COM port configured") } p, err := serial.Open(s.cfg.ComPort, &serial.Mode{ BaudRate: s.cfg.Baud, DataBits: 8, Parity: serial.NoParity, StopBits: serial.OneStopBit, }) if err != nil { return err } // A short read timeout keeps the loop responsive to Stop while idle: the amp // may poll only every few hundred ms, and a blocking read would hold the // port open past shutdown. _ = p.SetReadTimeout(200 * time.Millisecond) s.mu.Lock() s.port = p s.status.Connected = true s.status.Error = "" s.mu.Unlock() s.log("catemu: serving Kenwood-format polls on %s at %d baud", s.cfg.ComPort, s.cfg.Baud) return nil } // broadcast sends an unsolicited FA frame at the configured interval, for an // amplifier that listens to the CAT line rather than polling it. It stops when // the port is closed or Stop is called. func (s *Server) broadcast(stopServe <-chan struct{}) { if s.cfg.BroadcastMs <= 0 { return } // Below ~100 ms this is pure noise on the wire; the band only ever changes // at human speed. every := time.Duration(s.cfg.BroadcastMs) * time.Millisecond if every < 100*time.Millisecond { every = 100 * time.Millisecond } t := time.NewTicker(every) defer t.Stop() for { select { case <-s.stop: return case <-stopServe: return case <-t.C: hz := s.freqHz.Load() if hz <= 0 { continue // nothing known yet — say nothing rather than "0 Hz" } s.mu.Lock() p := s.port s.mu.Unlock() if p == nil { return } if _, err := p.Write([]byte(fmt.Sprintf("FA%011d;", hz))); err != nil { s.setErr(err.Error()) return } } } } // serve reads commands until the port fails or Stop is called. func (s *Server) serve() { // The broadcaster shares this port and must die with it, or it would write // into a closed handle after a reopen. stopServe := make(chan struct{}) defer close(stopServe) go s.broadcast(stopServe) buf := make([]byte, 64) var acc []byte for { select { case <-s.stop: return default: } s.mu.Lock() p := s.port s.mu.Unlock() if p == nil { return } n, err := p.Read(buf) if err != nil { s.setErr(err.Error()) s.log("catemu: %s read failed: %v — reopening", s.cfg.ComPort, err) s.mu.Lock() if s.port != nil { _ = s.port.Close() s.port = nil } s.mu.Unlock() return } if n == 0 { continue } acc = append(acc, buf[:n]...) // Commands are ';'-terminated; handle every complete one in the buffer. for { i := indexByte(acc, ';') if i < 0 { break } cmd := strings.ToUpper(strings.TrimSpace(string(acc[:i]))) acc = acc[i+1:] s.handle(p, cmd) } // A runaway buffer means we are seeing something that is not this // protocol (wrong baud, or the amp's other port); drop it rather than // grow without bound. if len(acc) > 512 { acc = acc[:0] } } } func indexByte(b []byte, c byte) int { for i := range b { if b[i] == c { return i } } return -1 } // handle answers one command. cmd has no trailing ';'. func (s *Server) handle(p serial.Port, cmd string) { hz := s.freqHz.Load() var reply string switch { case cmd == "FA" || cmd == "FB": reply = fmt.Sprintf("%s%011d;", cmd, hz) case cmd == "IF": reply = s.ifFrame(hz) case cmd == "ID": reply = "ID019;" // TS-2000 default: // Unknown or a SET command (FA00014025000;) — silently ignored: an amp // never sets our frequency, and answering the wrong length would break // its parser for the following poll. return } if _, err := p.Write([]byte(reply)); err != nil { s.setErr(err.Error()) return } s.mu.Lock() s.status.Polls++ s.status.LastCmd = cmd + ";" s.status.LastAt = time.Now().Format(time.RFC3339) s.mu.Unlock() } // modeDigit maps our mode name to the Kenwood mode digit used in IF. func (s *Server) modeDigit() byte { m, _ := s.mode.Load().(string) switch { case strings.HasPrefix(m, "CW"): return '3' case strings.HasPrefix(m, "LSB"): return '1' case strings.HasPrefix(m, "USB"), strings.HasPrefix(m, "SSB"): return '2' case strings.HasPrefix(m, "FM"): return '4' case strings.HasPrefix(m, "AM"): return '5' case m == "RTTY", strings.HasPrefix(m, "FSK"): return '6' case m == "": return '2' default: // Data modes (FT8, PSK…) ride on SSB as far as an amplifier cares. return '2' } } // ifFrame builds the 38-character TS-2000 IF status frame: // // IF | freq(11) | step(4) | RIT(6) | RIT/XIT/…(3) | memch(2) | rx/tx | mode | // FR | scan | split | tone | tone#(2) | shift | ; // // Only the frequency and mode carry meaning here; the rest is a valid, inert // state (no RIT, receiving, simplex) so the amp's parser is satisfied. func (s *Server) ifFrame(hz int64) string { return fmt.Sprintf("IF%011d%04d%+06d%03d%02d%01d%c%01d%01d%01d%01d%02d%01d;", hz, // P1 frequency, Hz 0, // P2 frequency step 0, // P3 RIT/XIT offset, signed 5 digits 0, // P4-P6 RIT off, XIT off, channel-bank 0, // P7 memory channel 0, // P8 0 = RX s.modeDigit(), // P9 mode 0, // P10 VFO A 0, // P11 scan off 0, // P12 split off 0, // P13 tone off 0, // P14 tone number 0, // P15 shift ) }