chore: release v0.20.7
This commit is contained in:
@@ -0,0 +1,504 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user