Files
OpsLog/internal/acom/acom.go
T
2026-07-21 18:26:34 +02:00

505 lines
14 KiB
Go

// Package acom drives the ACOM solid-state amplifiers (500S / 600S / 700S /
// 1200S / 2020S) over their (unpublished) RS-232 protocol, reverse-engineered by
// the ACOM-Controller project (bjornekelund, C#). The amp is reached either
// directly over a serial COM port (9600 8N1) or over TCP via an RS232-to-Ethernet
// bridge — both are just an io.ReadWriteCloser to this code, same as the SPE
// backend.
//
// Wire format (host → amp): fixed raw byte strings, validated by the same rule as
// telemetry (sum of ALL frame bytes ≡ 0 mod 256):
//
// enable telemetry: 55 92 04 15
// disable telemetry: 55 91 04 16
// OPERATE: 55 81 08 02 00 06 00 1A
// STANDBY: 55 81 08 02 00 05 00 1B
// OFF (power down): 55 81 08 02 00 0A 00 16
//
// Power ON is NOT a data command: it is a hardware pulse on the serial DTR/RTS
// lines (the amp's remote power-on pins) — serial transport only, and only if the
// cable wires those pins (a telemetry-only cable uses just RX/TX/GND).
//
// IMPORTANT: DTR and RTS must be held LOW at all times otherwise — asserting them
// permanently blocks the amplifier's front-panel power button.
//
// Telemetry (amp → host): once enabled, the amp streams 72-byte frames starting
// 0x55 0x2F, valid when the sum of all 72 bytes ≡ 0 (mod 256). Field offsets are
// decoded in decodeFrame below.
package acom
import (
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
var (
cmdEnableTelemetry = []byte{0x55, 0x92, 0x04, 0x15}
cmdDisableTelemetry = []byte{0x55, 0x91, 0x04, 0x16}
cmdOperate = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x06, 0x00, 0x1A}
cmdStandby = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x05, 0x00, 0x1B}
cmdOff = []byte{0x55, 0x81, 0x08, 0x02, 0x00, 0x0A, 0x00, 0x16}
)
const (
frameLen = 72
sync0 = 0x55
sync1 = 0x2F
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
// The amp streams roughly 4-5 frames/s once telemetry is on; if nothing valid
// arrives for this long the link (or the amp) is considered down.
staleAfter = 5 * time.Second
)
// model carries the per-model constants: the temperature word offset and the
// nominal/max forward power (for UI bar scaling).
type model struct {
Name string
TempOffset int
NominalW int
MaxW int
}
var models = map[string]model{
"500S": {"500S", 282, 500, 600},
"600S": {"600S", 273, 600, 700},
"700S": {"700S", 282, 700, 800},
"1200S": {"1200S", 281, 1200, 1400},
"2020S": {"2020S", 282, 1800, 2000},
}
// paStatusNames maps the PAstatus nibble to a display string.
var paStatusNames = map[int]string{
1: "RESET", 2: "INIT", 3: "DEBUG", 4: "SERVICE",
5: "STANDBY", 6: "RECEIVE", 7: "TRANSMIT", 9: "SYSTEM", 10: "OFF",
}
// acomBands maps the band nibble to a band label.
var acomBands = []string{"?", "160m", "80m", "40/60m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m"}
// errText translates the error code (frame byte 66) shown on the amp's display.
// 0xFF means NO error — everything else, including 0x00, is a fault/warning.
func errText(code int) string {
switch code {
case 0xFF:
return ""
case 0x00, 0x08:
return "Hot switching"
case 0x03:
return "Drive power at wrong time"
case 0x04, 0x05:
return "Reflected power warning"
case 0x06, 0x07:
return "Drive power too high"
case 0x0C:
return "RF power at wrong time"
case 0x0E:
return "Stop transmission first"
case 0x0F:
return "Remove drive power"
case 0x24, 0x25, 0x39, 0x44, 0x45, 0x59:
return "Excessive PAM current"
case 0x70:
return "CAT error"
default:
return "ERROR — see display"
}
}
// Status is the decoded amplifier state for the UI.
type Status struct {
Connected bool `json:"connected"` // valid telemetry is flowing
PortOpen bool `json:"port_open"` // transport is open (serial port / TCP socket) — power-on possible even when the amp itself is off
Transport string `json:"transport"` // "serial" | "tcp" — the UI disables power-ON over tcp (no DTR line)
LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"`
State string `json:"state,omitempty"` // STANDBY / RECEIVE / TRANSMIT / OFF / …
Operate bool `json:"operate"` // RECEIVE or TRANSMIT (vs STANDBY)
TX bool `json:"tx"`
FwdW int `json:"fwd_w"` // forward/output power
ReflW int `json:"refl_w"` // reflected power
SWR float64 `json:"swr"`
DriveW int `json:"drive_w"`
DCPowerW int `json:"dc_w"`
TempC int `json:"temp_c"` // PA temperature
Band string `json:"band,omitempty"`
FanLevel int `json:"fan"` // 1..4
ErrCode int `json:"err_code"` // raw code from the frame; 0xFF = none
ErrText string `json:"err_text,omitempty"` // human message; empty = no error
NominalW int `json:"nominal_w"`
MaxW int `json:"max_w"`
}
// Config selects the transport and amplifier model.
type Config struct {
Model string // "500S" | "600S" | "700S" | "1200S" | "2020S"
Transport string // "serial" | "tcp"
ComPort string // serial
Baud int // serial (the amp is fixed 9600 8N1)
Host string // tcp (RS232-to-Ethernet bridge)
Port int // tcp
}
type Client struct {
cfg Config
mdl model
mu sync.Mutex // serialises access to the connection
conn io.ReadWriteCloser
statusMu sync.RWMutex
status Status
// Diagnostics: raw byte count + first-bytes capture, so a hardware session log
// tells apart "nothing on the wire" (cable/COM/amp) from "bytes but no valid
// frame" (framing/checksum) without a serial sniffer.
rawSeen int64
dbgBytes []byte
dbgLogged bool
ckFails int
frameLogged bool
stop chan struct{}
running bool
}
func New(cfg Config) *Client {
if cfg.Baud <= 0 {
cfg.Baud = 9600
}
mdl, ok := models[strings.ToUpper(strings.TrimSpace(cfg.Model))]
if !ok {
mdl = models["700S"]
}
c := &Client{cfg: cfg, mdl: mdl, stop: make(chan struct{})}
c.status.Model = mdl.Name
c.status.Transport = cfg.Transport
c.status.NominalW = mdl.NominalW
c.status.MaxW = mdl.MaxW
return c
}
func (c *Client) Start() error {
if c.running {
return nil
}
c.running = true
go c.readLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
if c.conn != nil {
_, _ = c.conn.Write(cmdDisableTelemetry) // best effort: stop the stream
}
c.dropLocked()
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setErr(msg string) {
c.statusMu.Lock()
c.status.Connected = false
c.status.LastError = msg
c.statusMu.Unlock()
}
// Operate puts the amp in OPERATE (true) or STANDBY (false). Unlike the SPE's
// single toggle key, the ACOM protocol has explicit commands for each state.
func (c *Client) Operate(on bool) error {
if on {
return c.send(cmdOperate)
}
return c.send(cmdStandby)
}
// PowerOff sends the power-down command (same as pressing OFF on the amp).
func (c *Client) PowerOff() error { return c.send(cmdOff) }
// PowerOn pulses the serial DTR/RTS lines — the amp's remote power-on pins, wired
// like a press of the front-panel power button. Hardware line ⇒ serial transport
// only (an RS232-to-Ethernet bridge doesn't forward DTR), and the cable must have
// those pins connected. Pulse length to be confirmed on real hardware.
func (c *Client) PowerOn() error {
c.mu.Lock()
defer c.mu.Unlock()
sp, ok := c.conn.(serial.Port)
if !ok {
return fmt.Errorf("power-on needs the serial DTR/RTS lines — not available over a network bridge")
}
if err := sp.SetDTR(true); err != nil {
return err
}
if err := sp.SetRTS(true); err != nil {
_ = sp.SetDTR(false)
return err
}
time.Sleep(2500 * time.Millisecond)
err1 := sp.SetDTR(false)
err2 := sp.SetRTS(false)
if err1 != nil {
return err1
}
return err2
}
func (c *Client) send(cmd []byte) error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return fmt.Errorf("not connected")
}
if nc, ok := c.conn.(net.Conn); ok {
_ = nc.SetWriteDeadline(time.Now().Add(ioTimeout))
}
_, err := c.conn.Write(cmd)
return err
}
// readLoop keeps the link up and consumes the telemetry stream. When no valid
// frame arrives for a while it re-arms telemetry (the amp forgets the enable
// across a power cycle) and, on TCP, reconnects. A serial port is kept open even
// with the amp off, so the DTR power-on pulse stays available.
func (c *Client) readLoop() {
var lastFrame time.Time
var lastEnable time.Time
for {
select {
case <-c.stop:
return
default:
}
if err := c.ensureConn(); err != nil {
c.setErr("connect: " + err.Error())
select {
case <-c.stop:
return
case <-time.After(2 * time.Second):
}
continue
}
frame, err := c.readFrame()
now := time.Now()
if err == nil {
lastFrame = now
if !c.frameLogged {
c.frameLogged = true
applog.Printf("acom: telemetry up — first valid frame received")
}
c.decodeFrame(frame)
continue
}
// No valid frame. Re-send the telemetry enable briskly — there is no way to
// know whether the amp has it on (the reference controller re-sends every
// 200ms until frames flow), and it revives the stream after a power cycle.
if now.Sub(lastEnable) >= 500*time.Millisecond {
lastEnable = now
_ = c.send(cmdEnableTelemetry)
}
if lastFrame.IsZero() || now.Sub(lastFrame) > staleAfter {
if c.cfg.Transport == "tcp" {
// The bridge socket may be dead — force a reconnect.
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
}
if prev := c.GetStatus(); prev.Connected || prev.LastError == "" {
applog.Printf("acom: no telemetry (raw bytes seen so far: %d, checksum fails: %d)", c.rawSeen, c.ckFails)
}
c.setErr("no telemetry — amplifier off or cable/bridge issue")
}
}
}
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
if c.cfg.Transport == "tcp" {
nc, err := net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
if err != nil {
return err
}
c.conn = nc
} else {
sp, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
if err != nil {
return err
}
// CRITICAL: hold DTR/RTS LOW. Windows may assert them on open, and while
// asserted the amp's front-panel power button is blocked (they are its
// remote power-on lines).
_ = sp.SetDTR(false)
_ = sp.SetRTS(false)
// Short read timeout so the frame reader can poll the stop channel and
// detect staleness rather than blocking forever.
_ = sp.SetReadTimeout(200 * time.Millisecond)
c.conn = sp
}
c.statusMu.Lock()
c.status.PortOpen = true
c.statusMu.Unlock()
applog.Printf("acom: %s link open (%s)", c.mdl.Name, c.cfg.Transport)
_, _ = c.conn.Write(cmdEnableTelemetry)
return nil
}
func (c *Client) dropLocked() {
if c.conn != nil {
c.conn.Close()
c.conn = nil
}
c.statusMu.Lock()
c.status.PortOpen = false
c.statusMu.Unlock()
}
// readByte reads a single byte, honouring the transport's short timeout. A serial
// read that times out returns (0, nil) with go.bug.st/serial — mapped to an error
// here so callers can distinguish "no data yet".
var errNoData = fmt.Errorf("no data")
func (c *Client) readByte(deadline time.Time) (byte, error) {
c.mu.Lock()
conn := c.conn
c.mu.Unlock()
if conn == nil {
return 0, fmt.Errorf("not connected")
}
buf := make([]byte, 1)
for {
if nc, ok := conn.(net.Conn); ok {
_ = nc.SetReadDeadline(time.Now().Add(300 * time.Millisecond))
}
n, err := conn.Read(buf)
if n == 1 {
c.rawSeen++
// Capture the first 144 raw bytes (~2 frames) once, so a session log shows
// what the wire actually carries when frames won't validate.
if !c.dbgLogged {
c.dbgBytes = append(c.dbgBytes, buf[0])
if len(c.dbgBytes) >= 144 {
c.dbgLogged = true
applog.Printf("acom: first raw bytes: % X", c.dbgBytes)
c.dbgBytes = nil
}
}
return buf[0], nil
}
if err != nil {
if ne, ok := err.(net.Error); ok && ne.Timeout() {
err = nil // treat like the serial short-timeout: just no data yet
} else {
return 0, err
}
}
select {
case <-c.stop:
return 0, fmt.Errorf("stopped")
default:
}
if time.Now().After(deadline) {
return 0, errNoData
}
}
}
// readFrame syncs on 0x55 0x2F, reads the rest of the 72-byte frame and verifies
// the mod-256 checksum (sum of ALL 72 bytes ≡ 0).
func (c *Client) readFrame() ([]byte, error) {
deadline := time.Now().Add(ioTimeout)
// Sync: hunt for 0x55 followed by 0x2F.
for {
b, err := c.readByte(deadline)
if err != nil {
return nil, err
}
if b != sync0 {
continue
}
b2, err := c.readByte(deadline)
if err != nil {
return nil, err
}
if b2 == sync1 {
break
}
}
frame := make([]byte, frameLen)
frame[0], frame[1] = sync0, sync1
for i := 2; i < frameLen; i++ {
b, err := c.readByte(deadline)
if err != nil {
return nil, err
}
frame[i] = b
}
var sum int
for _, b := range frame {
sum += int(b)
}
if sum%256 != 0 {
c.ckFails++
if c.ckFails <= 3 {
applog.Printf("acom: bad checksum (fail #%d): % X", c.ckFails, frame)
}
return nil, fmt.Errorf("bad checksum")
}
return frame, nil
}
// decodeFrame extracts the telemetry fields (see the package comment for the
// reverse-engineered layout; 16-bit values are little-endian lo + hi*256).
func (c *Client) decodeFrame(f []byte) {
u16 := func(i int) int { return int(f[i]) + int(f[i+1])*256 }
paStatus := int(f[3]&0xF0) >> 4
state := paStatusNames[paStatus]
if state == "" {
state = fmt.Sprintf("?%d", paStatus)
}
bandIdx := int(f[69] & 0x0F)
band := ""
if bandIdx > 0 && bandIdx < len(acomBands) {
band = acomBands[bandIdx]
}
c.statusMu.Lock()
defer c.statusMu.Unlock()
c.status.Connected = true
c.status.LastError = ""
c.status.State = state
c.status.Operate = paStatus == 6 || paStatus == 7
c.status.TX = paStatus == 7
c.status.DCPowerW = u16(8) / 10
c.status.TempC = u16(16) - c.mdl.TempOffset
c.status.DriveW = u16(20)
c.status.FwdW = u16(22)
c.status.ReflW = u16(24)
c.status.SWR = float64(u16(26)) / 100
c.status.ErrCode = int(f[66])
c.status.ErrText = errText(int(f[66]))
c.status.Band = band
c.status.FanLevel = int(f[69]&0xF0) >> 4
}