// 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 (confirmed on hw) cmdStatus byte = 0x90 // request the status string // From the official APG rev 1.1 command table. Note POWER does NOT switch a // sleeping amp on (that is the RTS/DTR wake, see PowerOn) — on a running amp // it cycles the output level L→M→H, which is all we use it for. cmdOff byte = 0x0A // SWITCH OFF key — how PowerOff switches the amp off cmdPower byte = 0x0B // POWER key — cycles the output power level syncHost = 0x55 syncAmp = 0xAA dialTimeout = 5 * time.Second ioTimeout = 3 * time.Second pollEvery = 800 * time.Millisecond // How long a modem line is held LOW before being raised again, to give the // amp's edge-triggered remote-on input a transition it can see. wakePulse = time.Second ) // Status is the decoded amplifier state for the UI. type Status struct { Connected bool `json:"connected"` Transport string `json:"transport"` // "serial" | "tcp" — the UI enables power-ON on serial even when asleep 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 lastConnErr string // last connect failure logged (log only on change) } 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() s := c.status if c.cfg.Transport == "tcp" { s.Transport = "tcp" } else { s.Transport = "serial" } return s } 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 switches the amplifier on with a RISING EDGE on RTS, then on DTR. // // Confirmed on a live 1.3K-FA. The remote-on input is EDGE-triggered, which is // why holding the lines high does nothing: a plain port open already leaves both // high (the serial library's default), so there is no transition left to give. // Hence low → pause → high on each line, in that order. The keystroke route is a // dead end — a switched-off amp does not answer its UART at all, and the POWER // key (0x0B) merely cycles L/M/H on an amp that is already running. // // Serial only: over TCP there are no modem lines. The port is (re)opened first, // since the poll loop has dropped the connection to a sleeping amp long before // the user clicks ON. func (c *Client) PowerOn() error { if c.cfg.Transport == "tcp" { return fmt.Errorf("power-on needs the serial RTS/DTR lines; not available over a network bridge") } // On an amp that is already awake there is nothing to wake. if c.GetStatus().Connected { return nil } // The poll goroutine may still be inside a blocking read on the old handle; // Windows keeps the port "busy" until that read times out (ioTimeout). Retry // the open for a little longer than that. var err error deadline := time.Now().Add(ioTimeout + 3*time.Second) for { if err = c.ensureConn(); err == nil || time.Now().After(deadline) { break } time.Sleep(200 * time.Millisecond) } if err != nil { applog.Printf("spe: power ON failed — cannot open %s: %v", c.cfg.ComPort, err) return fmt.Errorf("power-on: %w", err) } c.mu.Lock() sp, ok := c.conn.(serial.Port) c.mu.Unlock() if !ok { return fmt.Errorf("power-on needs a serial connection") } pulse := func(set func(bool) error) error { if err := set(false); err != nil { return err } time.Sleep(wakePulse) return set(true) } if err = pulse(sp.SetRTS); err == nil { err = pulse(sp.SetDTR) } // Booting, the amp re-enumerates its USB interface, which leaves this handle // pointing at a device that no longer exists — the amp came up and OpsLog // still read "offline". Drop it; the poll loop reopens a fresh one as soon as // the port is back. c.mu.Lock() c.dropLocked() c.mu.Unlock() applog.Printf("spe: power ON — RTS then DTR pulsed on %s (err=%v)", c.cfg.ComPort, err) return err } // PowerOff presses the SWITCH OFF key (0x0A) — the amp is running, so it hears // its UART. Dropping DTR then RTS switches it off too (it is the mirror of the // wake, and does work when done by hand), but back-to-back from one click it // did not, while this keystroke has never once failed. The amp then stays off: // the poll loop keeps reopening the port with both lines high and that has // never woken it — only the deliberate low→high sequence in PowerOn does. func (c *Client) PowerOff() error { err := c.sendCmd(cmdOff) applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err) return err } // 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 { // Logged once per distinct message: "the amp is on but OpsLog // says offline" was impossible to diagnose without the reason. if msg := err.Error(); msg != c.lastConnErr { c.lastConnErr = msg applog.Printf("spe: connect %s failed: %s", c.cfg.ComPort, msg) } c.setErr(fmt.Errorf("connect: %w", err)) continue } c.lastConnErr = "" // 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 { var sp serial.Port // Plain open: both modem lines come up high (the library's default), which // is what the interface needs to talk. That is not enough to switch the amp // on — the remote-on input wants the deliberate low→high sequence PowerOn // sends — so reconnecting never wakes an amplifier the operator switched // off, and forcing either line low here would switch a running one off. sp, err = serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud}) if err == nil { // Without this, a read on a sleeping amp blocks forever inside the // Windows serial driver — the poll goroutine survived the window and // the process took ~a minute to die. _ = sp.SetReadTimeout(ioTimeout) rwc = sp } } if err != nil { return err } c.conn = rwc if sp, ok := rwc.(serial.Port); ok { // The driver reports a read timeout as "0 bytes, no error", which bufio // spins on; surface it as a real error so the poll loop drops and retries. c.r = bufio.NewReader(serialTimeoutReader{sp}) } else { c.r = bufio.NewReader(rwc) } return nil } // serialTimeoutReader converts the serial driver's timeout convention (n=0, // err=nil once SetReadTimeout expires) into an explicit error, so a bufio // reader fails fast instead of retrying an amp that is asleep. type serialTimeoutReader struct{ p serial.Port } func (s serialTimeoutReader) Read(b []byte) (int, error) { n, err := s.p.Read(b) if n == 0 && err == nil { return 0, fmt.Errorf("spe: read timeout") } return n, err } 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() // One raw frame per session is enough to verify field alignment against real // hardware — the frame carries live meter values, so logging each change // meant logging nearly every frame. if 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 } }