fix(kpa): stop switching the KPA500 off, and answer its operator instantly

Three faults, one report. The slow poll asks ^TP — the KPA1500's ATU, which
a KPA500 (no ATU) never answers — and ask() dropped the whole connection on
any read timeout: a two-second stall and a reconnect every slow cycle, which
is why buttons lagged and the status read wrong. Worse, every serial reopen
toggled DTR/RTS — and those lines are the KPA500's POWER SWITCH (that is how
the Elecraft utility turns it on), so the amplifier obediently switched off
twenty seconds after its operator pressed nothing but Standby.

Silence is no longer a dead link (write errors still are), ^TP is never asked
again after one silence, the control lines are asserted once and held, and
the baud field becomes a list of the rates these amplifiers actually speak.
This commit is contained in:
2026-08-30 19:14:35 +02:00
parent 2cde1a2c27
commit 7e6c0b4f7e
3 changed files with 48 additions and 6 deletions
+27 -4
View File
@@ -79,9 +79,10 @@ type Config struct {
type Client struct {
cfg Config
mu sync.Mutex // serialises the connection: one question at a time
conn io.ReadWriteCloser
rd *bufio.Reader
mu sync.Mutex // serialises the connection: one question at a time
conn io.ReadWriteCloser
rd *bufio.Reader
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
statusMu sync.RWMutex
status Status
@@ -183,6 +184,13 @@ func (c *Client) connectLocked() error {
if err != nil {
return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err)
}
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
// switches the amplifier on by raising them. Held asserted, once, and
// never touched again — reconnect cycles that toggled them were
// switching a KPA500 OFF twenty seconds after its operator pressed
// nothing but Standby.
_ = p.SetDTR(true)
_ = p.SetRTS(true)
_ = p.SetReadTimeout(ioTimeout)
c.conn = p
}
@@ -214,7 +222,13 @@ func (c *Client) ask(cmd string) (string, error) {
// the frame.
line, err := c.rd.ReadString(';')
if err != nil {
c.dropLocked()
// NOT dropped. A command this model simply does not know (^TP is the
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
// link, and dropping here tore the connection down on every slow poll
// cycle: two seconds of stalled commands, a reconnect, and a DTR
// toggle the amplifier read as the off switch. Nothing arrived, so
// nothing is left to desynchronise the next exchange. Write errors —
// the genuinely dead link — still drop, above.
return "", fmt.Errorf("no answer to %s: %w", cmd, err)
}
return strings.TrimSpace(line), nil
@@ -364,12 +378,21 @@ func (c *Client) pollOnce(n uint64) {
c.statusMu.Unlock()
}
}
if c.skipTP {
return
}
if reply, err := c.ask("^TP;"); err == nil {
if v, err := parseInt(reply, "^TP"); err == nil {
c.statusMu.Lock()
c.status.Tuning = v == 1
c.statusMu.Unlock()
}
} else {
// One silence is the model's answer for good: a KPA500 has no ATU and
// will never answer ^TP — asking again every cycle cost a two-second
// stall each time.
c.skipTP = true
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
}
}