Files
OpsLog/internal/kpa/parse.go
T
rouggy 55a643d9a4 feat(kpa): the Elecraft KPA500 / KPA1500 protocol, decoded and pinned
One package for both amplifiers: they share the Elecraft command set — a
caret, letters, a semicolon, case-insensitive in and upper case out — the
same family as the K3/K4 panel. What differs is the transport and which
commands exist, not the grammar.

Everything here comes from the KPA1500 Programming Reference, and the
document's own examples ARE the test:

  ^WS1204 014;  1204 W and SWR 1.4:1 — power and SWR in one exchange
  ^VI513 061;   51.3 V and 61 A — volts in tenths, amps whole
  ^FL91;        HEX, and 0x91 is 'antenna not connected?'

That last one is why the parsing is pinned rather than eyeballed: read as
decimal, 90 and 91 become 144 and 145 and match nothing, so an amplifier
shut down by high reflected power would report a fault OpsLog could not
name. SWR in tenths is confirmed by the reference too — 'expressed in
tenths, 123 is 12.3:1' — where it had only been inferred from Hamlib.

The client is question-and-answer under one lock, never two questions in
flight: the reference states there is no flow control and that commands
are paced by waiting for the reply. Fast cycle four times a second for
power, SWR and the fault; the rest once a second.

Faults are named in the operator's terms — 'the ATU found no match', not
'fault 92' — and an unknown code from a newer firmware still says
something rather than nothing.

Not wired to the app yet, and two commands are deliberately absent: ^TX
makes the amplifier transmit from software, and ^ON0 cuts the main
supplies with Wake-on-LAN as the way back. Neither belongs on a poll loop
or behind a button that can be pressed by accident.
2026-08-26 17:43:32 +02:00

158 lines
5.1 KiB
Go

package kpa
// Decoding the amplifier's answers.
//
// Every format here is quoted from the KPA1500 Programming Reference, with the
// document's own example kept in the test next door. That is the whole
// discipline: a meter decoded from a guess reports a good match on a bad
// antenna, and nobody finds out until something is damaged.
import (
"fmt"
"strconv"
"strings"
)
// payload strips the leading "^", the command letters and the trailing ";",
// leaving the value. Returns false when the answer is not for this command —
// which happens on a shared serial line and on the first read after a
// reconnect, where a stale reply is still in flight.
func payload(reply, cmd string) (string, bool) {
r := strings.TrimSpace(reply)
r = strings.TrimSuffix(r, ";")
r = strings.TrimPrefix(r, "^")
cmd = strings.TrimSuffix(strings.TrimPrefix(cmd, "^"), ";")
if !strings.HasPrefix(strings.ToUpper(r), strings.ToUpper(cmd)) {
return "", false
}
return strings.TrimSpace(r[len(cmd):]), true
}
// parseWS reads forward power and SWR from one answer.
//
// ^WS1204 014; → 1204 W, SWR 1.4
//
// The watts field is FOUR digits on a KPA1500 and THREE on a KPA500 — the
// reference says so where it explains that ^WS exists for KPA500 compatibility
// — so the split is on the space and not on a width. The SWR is in tenths, the
// same units as everywhere else in this protocol.
func parseWS(reply string) (watts int, swr float64, err error) {
v, ok := payload(reply, "^WS")
if !ok {
return 0, 0, fmt.Errorf("not a ^WS answer: %q", reply)
}
f := strings.Fields(v)
if len(f) != 2 {
return 0, 0, fmt.Errorf("^WS wants two fields, got %q", v)
}
w, err1 := strconv.Atoi(f[0])
s, err2 := strconv.Atoi(f[1])
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("^WS not numeric: %q", v)
}
return w, float64(s) / 10, nil
}
// parseVI reads the PA supply voltage and current.
//
// ^VI513 061; → 51.3 V, 61 A
//
// Volts in TENTHS, amps whole. Two different scales in one answer, which is
// exactly the kind of detail that is wrong when it is assumed.
func parseVI(reply string) (volts float64, amps int, err error) {
v, ok := payload(reply, "^VI")
if !ok {
return 0, 0, fmt.Errorf("not a ^VI answer: %q", reply)
}
f := strings.Fields(v)
if len(f) != 2 {
return 0, 0, fmt.Errorf("^VI wants two fields, got %q", v)
}
dv, err1 := strconv.Atoi(f[0])
a, err2 := strconv.Atoi(f[1])
if err1 != nil || err2 != nil {
return 0, 0, fmt.Errorf("^VI not numeric: %q", v)
}
return float64(dv) / 10, a, nil
}
// parseInt reads the plain numeric answers: ^TMxxx (°C), ^PCnnn (A),
// ^BNbb (band number), ^OSx, ^ONx, ^TPx.
func parseInt(reply, cmd string) (int, error) {
v, ok := payload(reply, cmd)
if !ok {
return 0, fmt.Errorf("not a %s answer: %q", cmd, reply)
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return 0, fmt.Errorf("%s not numeric: %q", cmd, v)
}
return n, nil
}
// parseFault reads ^FLhh — TWO HEX DIGITS, not decimal. Fault 90 is reflected
// power and fault 91 is "antenna not connected"; read as decimal they would be
// 144 and 145 and match nothing in the table.
func parseFault(reply string) (int, error) {
v, ok := payload(reply, "^FL")
if !ok {
return 0, fmt.Errorf("not a ^FL answer: %q", reply)
}
n, err := strconv.ParseInt(strings.TrimSpace(v), 16, 32)
if err != nil {
return 0, fmt.Errorf("^FL not hex: %q", v)
}
return int(n), nil
}
// faultNames is the table from the reference, keyed by the hex code.
//
// Said in the operator's terms rather than the amplifier's: "the antenna is not
// connected" is a thing to go and fix, "fault 91" is a thing to go and look up.
var faultNames = map[int]string{
0x00: "no fault",
0x10: "watchdog timer reset",
0x20: "PA current too high",
0x40: "too hot — clears as it cools",
0x60: "drive power too high",
0x61: "gain too low for the drive",
0x70: "frequency outside a ham band",
0x80: "50 V supply out of range",
0x81: "5 V supply out of range",
0x82: "10 V supply out of range",
0x83: "12 V supply out of range",
0x84: "-12 V supply out of range",
0x85: "no LPF board supply detected",
0x90: "reflected power too high",
0x91: "SWR very high — antenna not connected?",
0x92: "the ATU found no match",
0xB0: "dissipated power too high",
0xC0: "forward power too high",
0xC1: "forward power too high for this ATU setting",
0xF0: "gain too high for the drive",
}
// FaultName describes a fault code, or says the code itself when the firmware
// reports one this table does not know — a newer amplifier must not be able to
// produce a blank explanation.
func FaultName(code int) string {
if code == 0 {
return ""
}
if s, ok := faultNames[code]; ok {
return s
}
return fmt.Sprintf("fault %02X", code)
}
// bandNames maps ^BN to the ADIF band. The numbering is the K3/K4 one, which is
// why it is worth writing down: it is not frequency order beyond 6 m and there
// is no arithmetic that produces it.
var bandNames = map[int]string{
0: "160m", 1: "80m", 2: "60m", 3: "40m", 4: "30m", 5: "20m",
6: "17m", 7: "15m", 8: "12m", 9: "10m", 10: "6m",
}
// BandName is the ADIF band for a ^BN number, or "" when unknown.
func BandName(n int) string { return bandNames[n] }