chore: release v0.22.9

This commit is contained in:
2026-08-02 06:40:10 +02:00
parent 296a4a55c0
commit f6cd6e999a
38 changed files with 1308 additions and 297 deletions
+129 -26
View File
@@ -33,13 +33,11 @@ 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
// 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
@@ -47,11 +45,16 @@ const (
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
@@ -91,6 +94,8 @@ type Client struct {
stop chan struct{}
running bool
lastConnErr string // last connect failure logged (log only on change)
}
func New(cfg Config) *Client {
@@ -123,7 +128,13 @@ func (c *Client) Stop() {
func (c *Client) GetStatus() Status {
c.statusMu.RLock()
defer c.statusMu.RUnlock()
return c.status
s := c.status
if c.cfg.Transport == "tcp" {
s.Transport = "tcp"
} else {
s.Transport = "serial"
}
return s
}
func (c *Client) setErr(err error) {
@@ -146,27 +157,79 @@ func (c *Client) Operate(on bool) error {
// 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.
// 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()
defer c.mu.Unlock()
sp, ok := c.conn.(serial.Port)
c.mu.Unlock()
if !ok {
return fmt.Errorf("power-on needs the serial DTR line (special SPE cable); not available over network")
return fmt.Errorf("power-on needs a serial connection")
}
// A single positive pulse (~1.2s) on DTR, as the manual describes.
if err := sp.SetDTR(true); err != nil {
return err
pulse := func(set func(bool) error) error {
if err := set(false); err != nil {
return err
}
time.Sleep(wakePulse)
return set(true)
}
time.Sleep(1200 * time.Millisecond)
return sp.SetDTR(false)
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 OFF key (switches the amp off).
func (c *Client) PowerOff() error { return c.sendCmd(cmdOff) }
// 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
@@ -206,9 +269,16 @@ func (c *Client) pollLoop() {
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
@@ -239,16 +309,48 @@ func (c *Client) ensureConn() error {
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})
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
c.r = bufio.NewReader(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()
@@ -361,9 +463,10 @@ func (c *Client) decodeCSV(payload string) {
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 {
// 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))
}