chore: release v0.22.9
This commit is contained in:
+129
-26
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user