Files
OpsLog/internal/spe/spe.go
T

436 lines
12 KiB
Go

// Package spe drives the SPE Expert 1.3K-FA / 1.5K-FA / 2K-FA amplifiers over
// their proprietary serial protocol (SPE "Application Programmer's Guide" rev 1.1).
// The amp is reached either directly over USB (a virtual COM port) or over TCP via
// an RS232-to-Ethernet bridge — both are just an io.ReadWriteCloser to this code.
//
// Wire format (host → amp): 0x55 0x55 0x55 | CNT | DATA… | CHK
// CNT = number of DATA bytes, CHK = sum(DATA) mod 256.
// Status reply (amp → host): 0xAA 0xAA 0xAA | LEN | <LEN CSV bytes> | chk0 chk1 | CR LF
// LEN is 0x43 (67); the payload is 19 comma-separated fixed fields.
//
// This MVP implements the two commands anchored by worked examples in the guide:
// OPERATE (0x0D, toggles STANDBY↔OPERATE) and STATUS (0x90). Other keystroke codes
// exist but the guide's command table did not extract unambiguously, so they are
// left out rather than risk sending the wrong key to the amplifier.
package spe
import (
"bufio"
"fmt"
"io"
"net"
"strconv"
"strings"
"sync"
"time"
"go.bug.st/serial"
"hamlog/internal/applog"
)
const (
cmdOperate byte = 0x0D // toggles STANDBY ↔ OPERATE (confirmed on hw)
cmdStatus byte = 0x90 // request the status string
// Best-guess keystroke codes reconstructed from the APG command table after
// correcting the PDF's note-wrap misalignment (anchored to the confirmed
// OPERATE=0x0D): OFF=0x0A, POWER=0x0B. The POWER key doubles as power-on (when
// the amp is off) and the output-level cycle L→M→H (when it's on) — same as the
// single physical POWER button. To be verified on hardware.
cmdOff byte = 0x0A // OFF key — switch the amplifier off
cmdPower byte = 0x0B // POWER key — turn on / cycle output power level
syncHost = 0x55
syncAmp = 0xAA
dialTimeout = 5 * time.Second
ioTimeout = 3 * time.Second
pollEvery = 800 * time.Millisecond
)
// Status is the decoded amplifier state for the UI.
type Status struct {
Connected bool `json:"connected"`
LastError string `json:"last_error,omitempty"`
Model string `json:"model,omitempty"` // "20K" / "13K"
Operate bool `json:"operate"` // true = OPERATE, false = STANDBY
TX bool `json:"tx"` // true = transmitting
Input string `json:"input,omitempty"` // "1" / "2"
Band string `json:"band,omitempty"` // raw 2-char band code
PowerLevel string `json:"power_level,omitempty"` // L / M / H
OutputW int `json:"output_w"`
SWRATU float64 `json:"swr_atu"`
SWRAnt float64 `json:"swr_ant"`
VoltPA float64 `json:"volt_pa"`
CurrPA float64 `json:"curr_pa"`
TempC int `json:"temp_c"` // heatsink (upper) temperature
Warnings string `json:"warnings,omitempty"`
Alarms string `json:"alarms,omitempty"`
}
// Config selects the transport.
type Config struct {
Transport string // "serial" | "tcp"
ComPort string // serial
Baud int // serial
Host string // tcp
Port int // tcp
}
type Client struct {
cfg Config
mu sync.Mutex // serialises access to the connection
conn io.ReadWriteCloser
r *bufio.Reader
statusMu sync.RWMutex
status Status
lastRaw string // last raw status payload logged (log only on change)
stop chan struct{}
running bool
}
func New(cfg Config) *Client {
if cfg.Baud <= 0 {
cfg.Baud = 115200
}
return &Client{cfg: cfg, stop: make(chan struct{})}
}
func (c *Client) Start() error {
if c.running {
return nil
}
c.running = true
go c.pollLoop()
return nil
}
func (c *Client) Stop() {
if !c.running {
return
}
c.running = false
close(c.stop)
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
}
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
}
func (c *Client) setErr(err error) {
c.statusMu.Lock()
c.status.Connected = false
c.status.LastError = err.Error()
c.statusMu.Unlock()
}
// Operate toggles the amplifier between STANDBY and OPERATE (the amp has a single
// OPERATE key that flips the state, so we send it only when the desired state
// differs from the last-read one).
func (c *Client) Operate(on bool) error {
if c.GetStatus().Operate == on {
return nil
}
return c.sendCmd(cmdOperate)
}
// ToggleOperate flips STANDBY/OPERATE unconditionally.
func (c *Client) ToggleOperate() error { return c.sendCmd(cmdOperate) }
// PowerOn turns the amplifier on. Per the SPE manual this is NOT a data command
// but a hardware DTR pulse on the serial line (the "Remote_ON" pin), which needs
// the special SPE cable — so it only applies to the serial transport. Over TCP
// there is no DTR line and power-on isn't possible remotely.
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 line (special SPE cable); not available over network")
}
// A single positive pulse (~1.2s) on DTR, as the manual describes.
if err := sp.SetDTR(true); err != nil {
return err
}
time.Sleep(1200 * time.Millisecond)
return sp.SetDTR(false)
}
// PowerOff presses the OFF key (switches the amp off).
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
// SetPowerLevel cycles the output power level (L/M/H) to the requested one by
// tapping the POWER key (0x0B) and WAITING for the amp to actually report the new
// level before the next tap — the status is streamed, so we poll GetStatus rather
// than sleeping a fixed time. `level` is "L", "M" or "H" (case-insensitive).
func (c *Client) SetPowerLevel(level string) error {
want := strings.ToUpper(strings.TrimSpace(level))
if want == "" {
return nil
}
// At most 3 taps to walk the 3-way L→M→H cycle around to the target.
for i := 0; i < 3; i++ {
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) == want {
return nil
}
prev := strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel))
if err := c.sendCmd(cmdPower); err != nil {
return err
}
// Wait (up to ~2s) for the streamed status to reflect the change.
for w := 0; w < 20; w++ {
time.Sleep(100 * time.Millisecond)
if strings.ToUpper(strings.TrimSpace(c.GetStatus().PowerLevel)) != prev {
break
}
}
}
return nil
}
func (c *Client) pollLoop() {
t := time.NewTicker(pollEvery)
defer t.Stop()
for {
select {
case <-c.stop:
return
case <-t.C:
if err := c.ensureConn(); err != nil {
c.setErr(fmt.Errorf("connect: %w", err))
continue
}
// The amp streams status frames faster than one per poll, so a backlog
// builds up and we'd read stale frames (the display lagged ~15s behind
// the real amp). Flush anything pending, THEN request + read exactly one
// fresh frame — the status we show is always current.
c.drainInput()
if err := c.sendCmd(cmdStatus); err != nil {
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
c.setErr(err)
continue
}
c.readStatus()
}
}
}
func (c *Client) ensureConn() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn != nil {
return nil
}
var rwc io.ReadWriteCloser
var err error
if c.cfg.Transport == "tcp" {
var nc net.Conn
nc, err = net.DialTimeout("tcp", net.JoinHostPort(c.cfg.Host, strconv.Itoa(c.cfg.Port)), dialTimeout)
rwc = nc
} else {
rwc, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud})
}
if err != nil {
return err
}
c.conn = rwc
c.r = bufio.NewReader(rwc)
return nil
}
func (c *Client) dropLocked() {
if c.conn != nil {
c.conn.Close()
c.conn = nil
c.r = nil
}
}
// drainInput discards everything currently pending on the link (OS buffer + the
// bufio reader) so the next read returns a fresh frame rather than a queued stale
// one. Called before each status request.
func (c *Client) drainInput() {
c.mu.Lock()
defer c.mu.Unlock()
if c.conn == nil {
return
}
if sp, ok := c.conn.(serial.Port); ok {
_ = sp.ResetInputBuffer()
} else if nc, ok := c.conn.(net.Conn); ok {
_ = nc.SetReadDeadline(time.Now().Add(15 * time.Millisecond))
buf := make([]byte, 4096)
for {
n, err := nc.Read(buf)
if n == 0 || err != nil {
break
}
}
}
if c.r != nil {
c.r.Reset(c.conn)
}
}
// sendCmd frames one keystroke code and writes it. Single-byte payload → CHK is
// the code itself.
func (c *Client) sendCmd(code 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))
}
pkt := []byte{syncHost, syncHost, syncHost, 0x01, code, code}
_, err := c.conn.Write(pkt)
return err
}
// readStatus reads one amp packet and, when it's a status string, decodes it. ACK
// packets (short) are consumed and ignored.
func (c *Client) readStatus() {
c.mu.Lock()
r := c.r
if nc, ok := c.conn.(net.Conn); ok && nc != nil {
_ = nc.SetReadDeadline(time.Now().Add(ioTimeout))
}
c.mu.Unlock()
if r == nil {
return
}
// Sync on three 0xAA bytes.
run := 0
for run < 3 {
b, err := r.ReadByte()
if err != nil {
c.mu.Lock()
c.dropLocked()
c.mu.Unlock()
c.setErr(err)
return
}
if b == syncAmp {
run++
} else {
run = 0
}
}
length, err := r.ReadByte()
if err != nil {
return
}
data := make([]byte, int(length))
if _, err := io.ReadFull(r, data); err != nil {
return
}
// Status strings are the long ones (LEN 0x43 = 67). Short packets are ACKs
// (1 checksum byte, no CRLF) — nothing else to consume for those.
if length >= 40 {
// consume the 2 checksum bytes + CR LF
_, _ = r.Discard(4)
c.decodeCSV(string(data))
} else {
_, _ = r.Discard(1) // ACK checksum
}
}
// decodeCSV parses the 19-field comma-separated status payload.
func (c *Client) decodeCSV(payload string) {
f := strings.Split(payload, ",")
get := func(i int) string {
if i < len(f) {
return strings.TrimSpace(f[i])
}
return ""
}
pf := func(s string) float64 { v, _ := strconv.ParseFloat(strings.TrimSpace(s), 64); return v }
pi := func(s string) int { v, _ := strconv.Atoi(strings.TrimSpace(s)); return v }
c.statusMu.Lock()
defer c.statusMu.Unlock()
// Log the raw status frame ONCE per change, so field alignment can be verified
// against real hardware (the doc's 19-field layout may differ per firmware).
if payload != c.lastRaw {
c.lastRaw = payload
applog.Printf("spe: status raw=%q fields=%d", payload, len(f))
}
c.status.Connected = true
c.status.LastError = ""
// The real frame carries a leading empty field (it starts with a comma), so the
// 19 documented fields live at indices 1..19, not 0..18. Verified against a live
// 1.3K-FA: ",13K,S,R,A,1,05,1b,0r,M,0000, 0.00, 0.00, 1.3, 0.0, 26,000,000,N,N,".
c.status.Model = get(1)
c.status.Operate = get(2) == "O" // "O" = OPERATE, "S" = STANDBY
c.status.TX = get(3) == "T" // "T" = transmit, "R" = receive
c.status.Input = get(5)
c.status.Band = bandName(get(6))
c.status.PowerLevel = get(9)
c.status.OutputW = pi(get(10))
c.status.SWRATU = pf(get(11))
c.status.SWRAnt = pf(get(12))
c.status.VoltPA = pf(get(13))
c.status.CurrPA = pf(get(14))
c.status.TempC = pi(get(15))
if w := get(18); w != "" && w != "N" {
c.status.Warnings = w
} else {
c.status.Warnings = ""
}
if a := get(19); a != "" && a != "N" {
c.status.Alarms = a
} else {
c.status.Alarms = ""
}
}
// bandName maps the SPE 2-digit band index to a human band label, falling back to
// the raw code for anything unrecognised.
func bandName(code string) string {
// SPE band index, verified against a live 1.3K-FA: the status frame reports the
// band as a 2-digit decimal string ordered by descending wavelength — 80m→"01"
// and 20m→"05" were both confirmed on hardware (the printed manual's table was
// off by one). 00=160m, 01=80m, 02=60m, 03=40m, 04=30m, 05=20m, 06=17m, 07=15m,
// 08=12m, 09=10m, 10=6m.
switch strings.TrimSpace(code) {
case "00":
return "160m"
case "01":
return "80m"
case "02":
return "60m"
case "03":
return "40m"
case "04":
return "30m"
case "05":
return "20m"
case "06":
return "17m"
case "07":
return "15m"
case "08":
return "12m"
case "09":
return "10m"
case "10":
return "6m"
case "":
return ""
default:
return code
}
}