// 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 }