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