"The ON button does nothing" has three different causes — the modem-line pulse was refused, the pulse went out and the amplifier ignored it, or the amplifier woke and the UI missed it — and the log could not separate them: it recorded one combined error and never said whether the amp came up. RTS and DTR are now reported separately (not every USB-serial chip honours the modem lines, and some refuse them without saying so), alongside the model and transport. A watcher then logs whether the amplifier answered within 15 seconds, off the caller's goroutine so the click still returns at once. No change to the wake sequence itself, which is the part that took a long time to get right on the 1.3K-FA.
605 lines
19 KiB
Go
605 lines
19 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
|
|
|
|
// 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)
|
|
// offUntil suppresses status frames that were already in flight when the amp
|
|
// was switched off. decodeCSV marks the client connected on every frame it
|
|
// parses, and the poll goroutine can be mid-read while PowerOff runs — so the
|
|
// frame from a second ago resurrected an amplifier the operator had just
|
|
// switched off, and the console only caught up when they pressed OFF twice.
|
|
//
|
|
// Time-bounded rather than a latch: an amp switched back on at its own front
|
|
// panel must reappear on its own.
|
|
offUntil time.Time
|
|
|
|
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
|
|
}
|
|
// Switching on cancels the suppression window: the operator wants frames
|
|
// believed again, and waiting out a timer they cannot see would read as the
|
|
// power-on having failed.
|
|
c.statusMu.Lock()
|
|
c.offUntil = time.Time{}
|
|
c.statusMu.Unlock()
|
|
// 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)
|
|
}
|
|
// Each line reported separately. Not every USB-serial chip honours the modem
|
|
// lines, and some refuse them without saying so — on an amplifier that never
|
|
// wakes, "which of the two failed, or neither" is the whole diagnosis, and a
|
|
// single combined error cannot give it.
|
|
rtsErr := pulse(sp.SetRTS)
|
|
dtrErr := pulse(sp.SetDTR)
|
|
if err = rtsErr; err == nil {
|
|
err = dtrErr
|
|
}
|
|
applog.Printf("spe: power ON pulse on %s (model=%q transport=%s) — RTS err=%v, DTR err=%v",
|
|
c.cfg.ComPort, c.GetStatus().Model, c.cfg.Transport, rtsErr, dtrErr)
|
|
// 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)
|
|
|
|
// Say whether it actually woke. "The ON button does nothing" is a report with
|
|
// three different causes — the pulse was refused, the pulse went out and the
|
|
// amp ignored it, or the amp came up and the UI missed it — and only the log
|
|
// can separate them. Watched off the caller's goroutine so the click returns
|
|
// at once.
|
|
go func() {
|
|
deadline := time.Now().Add(15 * time.Second)
|
|
for time.Now().Before(deadline) {
|
|
time.Sleep(time.Second)
|
|
if c.GetStatus().Connected {
|
|
applog.Printf("spe: power ON — amp answered after %.0fs",
|
|
15-time.Until(deadline).Seconds())
|
|
return
|
|
}
|
|
}
|
|
applog.Printf("spe: power ON — no answer within 15s on %s. The pulse was sent; "+
|
|
"either this amplifier does not wake on RTS/DTR, or the adapter does not drive those lines",
|
|
c.cfg.ComPort)
|
|
}()
|
|
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 {
|
|
// Close the window BEFORE the key goes out, so a frame already travelling up
|
|
// the wire cannot land after setErr and undo it. Three seconds covers a poll
|
|
// interval with room to spare; after that a real answer means the amp is
|
|
// genuinely alive again, which is what happens if it is switched back on at
|
|
// its own front panel.
|
|
c.statusMu.Lock()
|
|
c.offUntil = time.Now().Add(3 * time.Second)
|
|
c.statusMu.Unlock()
|
|
err := c.sendCmd(cmdOff)
|
|
applog.Printf("spe: power OFF — SWITCH OFF key sent (err=%v)", err)
|
|
// Drop the link and mark the amp offline at once, exactly as a fresh start
|
|
// would see it. The amp stops answering its UART once off, but the connection
|
|
// that was already open BEFORE the off keystroke kept reading as "connected"
|
|
// (stale/queued frames), so the UI never showed OFF and PowerOn — which only
|
|
// wakes a DISCONNECTED amp — did nothing until the operator restarted OpsLog.
|
|
// The poll loop reopens the port on its next tick; a plain reopen never wakes a
|
|
// switched-off amp (only PowerOn's RTS/DTR edge does), so it stays offline.
|
|
time.Sleep(100 * time.Millisecond) // let the 6-byte OFF command flush before we close
|
|
c.mu.Lock()
|
|
c.dropLocked()
|
|
c.mu.Unlock()
|
|
c.setErr(fmt.Errorf("switched off"))
|
|
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))
|
|
}
|
|
if time.Now().Before(c.offUntil) {
|
|
return // a frame from before the switch-off — the amp is on its way down
|
|
}
|
|
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
|
|
}
|
|
}
|