On its CAT/AUX connector an ACOM is the MASTER: it polls a transceiver and changes band from the reply, so nothing can be pushed to it. internal/catemu answers those polls on a second serial port, in ACOM command set 5 (Kenwood / Elecraft): FA;, FB;, IF; and ID;, and nothing else — a wrong-length reply is worse than none, it desynchronises the amp's parser for the following poll. Also offered for SPE. Some amps do not poll at all but read the radio↔PC CAT line in parallel; those never hear a responder, so an optional unprompted send (500/1000 ms) covers them. The TX frequency is what is sent: in split the amp must be tuned where we transmit. Frame lengths are pinned by tests — the failure they guard against is silent and only shows up as an amp that mistunes. Untested on hardware.
377 lines
10 KiB
Go
377 lines
10 KiB
Go
// 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
|
|
)
|
|
}
|