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
+10
View File
@@ -1,4 +1,14 @@
[
{
"version": "0.27.3",
"date": "",
"en": [
"KPA500: the amplifier no longer switches itself off and commands respond instantly. A command this model does not know (the KPA1500s ATU poll) was tearing the link down every cycle, and each reconnect toggled the serial control lines — which are the KPA500s power switch. The lines are now held steady, silence is not treated as a dead link, and the baud is picked from a list."
],
"fr": [
"KPA500 : lampli ne s’éteint plus tout seul et les commandes répondent instantanément. Une commande inconnue de ce modèle (le poll ATU du KPA1500) détruisait le lien à chaque cycle, et chaque reconnexion basculait les lignes de contrôle série — qui sont linterrupteur du KPA500. Les lignes sont désormais tenues stables, le silence nest plus traité comme un lien mort, et le baud se choisit dans une liste."
]
},
{
"version": "0.27.2",
"date": "",
+11 -2
View File
@@ -4416,8 +4416,17 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged
</div>
<div className="space-y-1">
<Label>Baud</Label>
<Input type="number" min={1200} value={amp.baud}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) || 115200 })} className="font-mono" />
{/* A list, not a free number: the KPA500 report that
began this had its operator wondering whether a typed
baud was the whole problem. These are the rates the
supported amplifiers actually speak. */}
<select value={String(amp.baud)}
onChange={(e) => patchAmp(i, { baud: parseInt(e.target.value) })}
className="h-9 w-full px-2 rounded-md border border-border bg-background text-sm font-mono">
{[4800, 9600, 19200, 38400, 57600, 115200].map((b) => (
<option key={b} value={String(b)}>{b}</option>
))}
</select>
</div>
</div>
) : (
+24 -1
View File
@@ -82,6 +82,7 @@ type Client struct {
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")
}
}