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.
This commit is contained in:
@@ -0,0 +1,104 @@
|
||||
package kpa
|
||||
|
||||
import "testing"
|
||||
|
||||
// The reference's own examples, kept as the test. Every one of these strings is
|
||||
// quoted from the KPA1500 Programming Reference rather than invented here, so a
|
||||
// change that breaks the decoding fails against the document.
|
||||
func TestParseTheDocumentedExamples(t *testing.T) {
|
||||
t.Run("^WS — forward power and SWR", func(t *testing.T) {
|
||||
w, swr, err := parseWS("^WS1204 014;")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if w != 1204 || swr != 1.4 {
|
||||
t.Errorf("got %d W, SWR %.1f; want 1204 W, SWR 1.4", w, swr)
|
||||
}
|
||||
})
|
||||
|
||||
// A KPA500 sends three digits for the watts. The split is on the space, so
|
||||
// the same code reads both amplifiers.
|
||||
t.Run("^WS from a KPA500 — three digits", func(t *testing.T) {
|
||||
w, swr, err := parseWS("^WS480 021;")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if w != 480 || swr != 2.1 {
|
||||
t.Errorf("got %d W, SWR %.1f; want 480 W, SWR 2.1", w, swr)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("^VI — volts in tenths, amps whole", func(t *testing.T) {
|
||||
v, a, err := parseVI("^VI513 061;")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if v != 51.3 || a != 61 {
|
||||
t.Errorf("got %.1f V, %d A; want 51.3 V, 61 A", v, a)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("^TM — heat sink temperature", func(t *testing.T) {
|
||||
c, err := parseInt("^TM045;", "^TM")
|
||||
if err != nil || c != 45 {
|
||||
t.Errorf("got %d, %v; want 45", c, err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("^OS — operate or standby", func(t *testing.T) {
|
||||
for reply, want := range map[string]int{"^OS0;": 0, "^OS1;": 1} {
|
||||
got, err := parseInt(reply, "^OS")
|
||||
if err != nil || got != want {
|
||||
t.Errorf("%s → %d, %v; want %d", reply, got, err, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("^BN — the K3 band numbering", func(t *testing.T) {
|
||||
n, err := parseInt("^BN05;", "^BN")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := BandName(n); got != "20m" {
|
||||
t.Errorf("^BN05 → %q, want 20m", got)
|
||||
}
|
||||
if got := BandName(10); got != "6m" {
|
||||
t.Errorf("^BN10 → %q, want 6m", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// ^FL is HEX. Read as decimal, 90 and 91 — reflected power and "antenna not
|
||||
// connected" — become 144 and 145 and match nothing at all, so the amplifier
|
||||
// would be shut down by a fault OpsLog could not name.
|
||||
func TestFaultCodesAreHex(t *testing.T) {
|
||||
code, err := parseFault("^FL91;")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if code != 0x91 {
|
||||
t.Fatalf("^FL91 → %d, want %d (0x91)", code, 0x91)
|
||||
}
|
||||
if name := FaultName(code); name == "" || name == "fault 91" {
|
||||
t.Errorf("0x91 should be named, got %q", name)
|
||||
}
|
||||
if got := FaultName(0); got != "" {
|
||||
t.Errorf("no fault should be empty, got %q", got)
|
||||
}
|
||||
// A code from a firmware newer than this table still says something.
|
||||
if got := FaultName(0xAB); got != "fault AB" {
|
||||
t.Errorf("unknown code → %q, want \"fault AB\"", got)
|
||||
}
|
||||
}
|
||||
|
||||
// Answers to somebody else's question are refused rather than misread. On a
|
||||
// serial line shared with the amplifier's own utility, or on the first read
|
||||
// after a reconnect, a stale reply is still in flight.
|
||||
func TestPayloadRefusesAnotherCommandsAnswer(t *testing.T) {
|
||||
if _, _, err := parseWS("^VI513 061;"); err == nil {
|
||||
t.Error("a ^VI answer was accepted as ^WS")
|
||||
}
|
||||
if _, err := parseInt("^TM045;", "^PC"); err == nil {
|
||||
t.Error("a ^TM answer was accepted as ^PC")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user