344 lines
8.6 KiB
Go
344 lines
8.6 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
|
|
cmdStatus byte = 0x90 // request the status string
|
|
|
|
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) }
|
|
|
|
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
|
|
}
|
|
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
|
|
}
|
|
}
|
|
|
|
// 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 (from the Expert user manual): 0=60m, 1=160m, 2=80m, 3=40m,
|
|
// 4=30m, 5=20m, 6=17m, 7=15m, 8=12m, 9=10m, 0xA(=10)=6m. The status frame reports
|
|
// it as a 2-digit decimal string ("05", "10", …).
|
|
switch strings.TrimSpace(code) {
|
|
case "00":
|
|
return "60m"
|
|
case "01":
|
|
return "160m"
|
|
case "02":
|
|
return "80m"
|
|
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
|
|
}
|
|
}
|