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.
158 lines
5.1 KiB
Go
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] }
|