Files
OpsLog/internal/kpa/parse_test.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

105 lines
3.2 KiB
Go

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")
}
}