436 lines
12 KiB
Go
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
|
|
}
|
|
}
|