feat: amplifier band-follow — answer the amp's frequency polls
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.
This commit is contained in:
@@ -0,0 +1,376 @@
|
||||
// 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
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user