From 55a643d9a44bb87530d54ab2f3a97eb9d8172c8b Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 17:43:32 +0200 Subject: [PATCH] feat(kpa): the Elecraft KPA500 / KPA1500 protocol, decoded and pinned MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- internal/kpa/doc.go | 60 ++++++ internal/kpa/kpa.go | 374 +++++++++++++++++++++++++++++++++++++ internal/kpa/parse.go | 157 ++++++++++++++++ internal/kpa/parse_test.go | 104 +++++++++++ 4 files changed, 695 insertions(+) create mode 100644 internal/kpa/doc.go create mode 100644 internal/kpa/kpa.go create mode 100644 internal/kpa/parse.go create mode 100644 internal/kpa/parse_test.go diff --git a/internal/kpa/doc.go b/internal/kpa/doc.go new file mode 100644 index 0000000..7f446f8 --- /dev/null +++ b/internal/kpa/doc.go @@ -0,0 +1,60 @@ +// Package kpa talks to the Elecraft KPA500 and KPA1500 amplifiers. +// +// One package for both: they share the Elecraft command set — ASCII, a caret +// prefix, a semicolon terminator, case-insensitive on the way in and upper case +// on the way back — which is the same family as the K3/K4 panel in +// internal/cat. What differs between the two models is the transport and which +// commands exist, not the grammar. +// +// # Transports +// +// KPA500: serial only. +// +// KPA1500: serial, and a network server. Four things may be connected AT ONCE, +// which is unusual enough to design around — the Host PC USB port, the XCVR +// SERIAL connector when repurposed as a second host, ONE TCP client, and any +// number of UDP clients: +// +// - TCP on port 1500 (changed with ^CP). Single client. If the operator +// already has the Elecraft utility or another program on TCP, OpsLog will +// not get in, and the failure is a refused connection rather than anything +// the amplifier says. +// - UDP on the same port. Many clients, one command per packet and at most +// one response, and packets may be dropped under congestion — so it is the +// right choice for sharing the amplifier and the wrong one for a command +// that must not be missed. +// +// # Pacing +// +// There is NO flow control. The reference is explicit: pace commands by waiting +// for the response to the previous one. So this client is strictly +// question-and-answer on one connection, like the ACOM and SPE clients, rather +// than firing a poll cycle and sorting out the replies afterwards. +// +// # Serial speed +// +// 4800 to 230400, 8N1, set on the amplifier (^BR / SERIAL SPEED HOST) and not +// negotiated. Elecraft's own utility finds it by sending bare semicolons at +// each speed until something answers — worth copying if operators turn up with +// amplifiers whose speed they do not know. +// +// # What is settled, and how +// +// From the KPA1500 Programming Reference: +// +// - ^SW is the SWR IN TENTHS. "123 is 12.3:1", so ^SW015 is 1.5:1. This was +// first taken from Hamlib's backend and is now confirmed by the document, +// which matters more than it sounds: a wrongly scaled SWR bar reports a +// good match on a bad antenna. +// - ^WS returns forward power AND SWR together, ^VI returns PA voltage AND +// current together. Two round trips instead of four on the link the display +// depends on while the operator is transmitting. +// - ^SF returns the fault log: index, fault code, a short name in quotes, a +// timestamp, and fault-specific values. ^FC describes the codes. +// +// # Not touched +// +// ^TX simulates a KEY IN — it makes the amplifier transmit from software — and +// ^ON0 switches the main supplies off. Neither belongs on a poll loop or behind +// a button that can be pressed by accident. +package kpa diff --git a/internal/kpa/kpa.go b/internal/kpa/kpa.go new file mode 100644 index 0000000..b66b215 --- /dev/null +++ b/internal/kpa/kpa.go @@ -0,0 +1,374 @@ +package kpa + +// The client: one connection, strict question-and-answer, a cached status. +// +// Shaped like internal/acom and internal/spe so a third amplifier is the same +// thing to read — but the traffic is the opposite kind. Those two are told to +// stream and are then listened to; this one is asked, and answers. The +// reference is explicit that there is no flow control and that commands are +// paced by waiting for the previous reply, so nothing here ever has two +// questions outstanding. + +import ( + "bufio" + "fmt" + "io" + "net" + "strings" + "sync" + "time" + + "go.bug.st/serial" + + "hamlog/internal/applog" +) + +const ( + dialTimeout = 5 * time.Second + ioTimeout = 2 * time.Second + // pollInterval is the fast cycle: forward power, SWR, and whether a fault has + // appeared. Four times a second is enough for a bar that is read while + // talking, and it is four round trips a second on a link with no flow + // control — faster buys nothing and costs the set commands their latency. + pollInterval = 250 * time.Millisecond + // slowEvery is how many fast cycles pass between the readings that do not + // move: mode, band, temperature, supply. Once a second. + slowEvery = 4 +) + +// Status is what the panel polls. +type Status struct { + Connected bool `json:"connected"` + Transport string `json:"transport"` // "serial" | "tcp" + Model string `json:"model,omitempty"` + LastError string `json:"last_error,omitempty"` + + // PowerOn is the main supplies (^ON), Operate is OPERATE vs STANDBY (^OS). + // They are different questions: an amplifier can be switched on and in + // standby, which is the normal state between overs. + PowerOn bool `json:"power_on"` + Operate bool `json:"operate"` + + FwdW int `json:"fwd_w"` + SWR float64 `json:"swr"` + VoltV float64 `json:"volt_v"` + CurA int `json:"cur_a"` + TempC int `json:"temp_c"` + Band string `json:"band,omitempty"` + + // Tuning is the ATU mid-cycle (^TP), so a panel can say so rather than + // showing a wild SWR and a power reading nobody should act on. + Tuning bool `json:"tuning"` + + // Fault is the current fault code and its meaning. A fault puts the + // amplifier in STANDBY by itself, so it is the first thing to show. + FaultCode int `json:"fault_code"` + FaultText string `json:"fault_text,omitempty"` +} + +// Config selects the model and how to reach it. +type Config struct { + Model string // "KPA500" | "KPA1500" + Transport string // "serial" | "tcp" + ComPort string // serial + Baud int // serial: 4800…230400, set on the amplifier and not negotiated + Host string // tcp (KPA1500 only) + Port int // tcp, default 1500 +} + +type Client struct { + cfg Config + + mu sync.Mutex // serialises the connection: one question at a time + conn io.ReadWriteCloser + rd *bufio.Reader + + statusMu sync.RWMutex + status Status + + stop chan struct{} + running bool +} + +// New builds a client. Nothing is opened until Start. +func New(cfg Config) *Client { + if cfg.Baud <= 0 { + cfg.Baud = 38400 + } + if cfg.Port <= 0 { + cfg.Port = 1500 + } + if strings.TrimSpace(cfg.Model) == "" { + cfg.Model = "KPA1500" + } + c := &Client{cfg: cfg, stop: make(chan struct{})} + c.status.Transport = cfg.Transport + c.status.Model = strings.ToUpper(strings.TrimSpace(cfg.Model)) + return c +} + +func (c *Client) Start() error { + if c.running { + return nil + } + c.running = true + go c.pollLoop() + return nil +} + +func (c *Client) Stop() { + if !c.running { + return + } + c.running = false + close(c.stop) + c.mu.Lock() + c.dropLocked() + c.mu.Unlock() +} + +func (c *Client) GetStatus() Status { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + return c.status +} + +func (c *Client) setErr(msg string) { + c.statusMu.Lock() + was := c.status.LastError + c.status.Connected = false + c.status.LastError = msg + c.statusMu.Unlock() + // Logged on CHANGE only: a disconnected amplifier is polled four times a + // second, and the log is where a hardware problem is diagnosed hours later. + if msg != "" && msg != was { + applog.Printf("kpa: %s", msg) + } +} + +// dropLocked closes the connection. Caller holds c.mu. +func (c *Client) dropLocked() { + if c.conn != nil { + _ = c.conn.Close() + c.conn = nil + c.rd = nil + } +} + +// connectLocked opens the transport. Caller holds c.mu. +func (c *Client) connectLocked() error { + if c.conn != nil { + return nil + } + switch strings.ToLower(strings.TrimSpace(c.cfg.Transport)) { + case "tcp": + if strings.TrimSpace(c.cfg.Host) == "" { + return fmt.Errorf("no address configured for the amplifier") + } + addr := net.JoinHostPort(c.cfg.Host, fmt.Sprint(c.cfg.Port)) + conn, err := net.DialTimeout("tcp", addr, dialTimeout) + if err != nil { + // Named for what it usually is. The KPA1500 accepts ONE TCP client, + // so the common failure is not a wrong address but the Elecraft + // utility already holding the socket — and "connection refused" + // sends an operator looking at their network instead. + return fmt.Errorf("cannot reach the amplifier on %s: %w (it accepts a single TCP connection — close the Elecraft utility or any other program using it)", addr, err) + } + c.conn = conn + default: + if strings.TrimSpace(c.cfg.ComPort) == "" { + return fmt.Errorf("no serial port configured for the amplifier") + } + p, err := serial.Open(c.cfg.ComPort, &serial.Mode{BaudRate: c.cfg.Baud}) + if err != nil { + return fmt.Errorf("cannot open %s: %w", c.cfg.ComPort, err) + } + _ = p.SetReadTimeout(ioTimeout) + c.conn = p + } + c.rd = bufio.NewReader(c.conn) + applog.Printf("kpa: connected to the %s", c.status.Model) + return nil +} + +// ask sends one command and reads its answer. +// +// The whole exchange is under the lock: with no flow control, two questions in +// flight means two answers to sort out, and the only thing distinguishing them +// is the prefix — which is exactly what payload() has to reject when it +// happens. +func (c *Client) ask(cmd string) (string, error) { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.connectLocked(); err != nil { + return "", err + } + if tc, ok := c.conn.(net.Conn); ok { + _ = tc.SetDeadline(time.Now().Add(ioTimeout)) + } + if _, err := c.conn.Write([]byte(cmd)); err != nil { + c.dropLocked() + return "", fmt.Errorf("writing %s: %w", cmd, err) + } + // Answers end with a semicolon and nothing else does, so the terminator is + // the frame. + line, err := c.rd.ReadString(';') + if err != nil { + c.dropLocked() + return "", fmt.Errorf("no answer to %s: %w", cmd, err) + } + return strings.TrimSpace(line), nil +} + +// send is a SET: written, and not answered. The reference says SET commands do +// not generally produce a response, so waiting for one would stall the poll +// loop for a whole timeout every time the operator pressed a button. +func (c *Client) send(cmd string) error { + c.mu.Lock() + defer c.mu.Unlock() + if err := c.connectLocked(); err != nil { + return err + } + if tc, ok := c.conn.(net.Conn); ok { + _ = tc.SetDeadline(time.Now().Add(ioTimeout)) + } + if _, err := c.conn.Write([]byte(cmd)); err != nil { + c.dropLocked() + return fmt.Errorf("writing %s: %w", cmd, err) + } + applog.Printf("kpa: → %s", cmd) + return nil +} + +// Operate puts the amplifier in OPERATE (true) or STANDBY (false). +// +// Worth knowing, and worth saying in the UI: from firmware 01.41 onwards, +// going to OPERATE also CLEARS the current fault — every one except +// temperature, which clears by cooling. So this button is the way out of a +// fault as well as the way into transmit. +func (c *Client) Operate(on bool) error { + if on { + return c.send("^OS1;") + } + return c.send("^OS0;") +} + +// ClearFault clears the current fault without changing mode (^FLC). +func (c *Client) ClearFault() error { return c.send("^FLC;") } + +// PowerOn switches the main supplies on or off (^ON1 / ^ON0). +// +// Off is a real power-down, not standby, and the way back on over the network +// is Wake-on-LAN or the front panel — so a caller should be asking the operator +// first. The sleeping microcontroller does answer ^ON while the supplies are +// off, which is why "off" is a state this can report rather than a silence. +func (c *Client) PowerOn(on bool) error { + if on { + return c.send("^ON1;") + } + return c.send("^ON0;") +} + +// Tune starts an ATU tune cycle (^FT). It needs drive from the transceiver. +func (c *Client) Tune() error { return c.send("^FT;") } + +// pollLoop keeps the status fresh, reconnecting as needed. +func (c *Client) pollLoop() { + t := time.NewTicker(pollInterval) + defer t.Stop() + var n uint64 + for { + select { + case <-c.stop: + return + case <-t.C: + c.pollOnce(n) + n++ + } + } +} + +func (c *Client) pollOnce(n uint64) { + // Forward power and SWR in ONE exchange (^WS), which is why that command + // exists and why the two are not asked separately. + reply, err := c.ask("^WS;") + if err != nil { + c.setErr(err.Error()) + return + } + w, swr, err := parseWS(reply) + if err != nil { + c.setErr(err.Error()) + return + } + + c.statusMu.Lock() + c.status.Connected = true + c.status.LastError = "" + c.status.FwdW, c.status.SWR = w, swr + c.statusMu.Unlock() + + // The fault, every cycle: it puts the amplifier in standby by itself, and an + // operator watching a power bar needs to know why it stopped moving. + if reply, err := c.ask("^FL;"); err == nil { + if code, err := parseFault(reply); err == nil { + c.statusMu.Lock() + was := c.status.FaultCode + c.status.FaultCode = code + c.status.FaultText = FaultName(code) + c.statusMu.Unlock() + if code != was && code != 0 { + applog.Printf("kpa: FAULT %02X — %s", code, FaultName(code)) + } + } + } + + if n%slowEvery != 0 { + return + } + // The readings that do not move fast. Each is optional: an older firmware or + // a KPA500 that does not know one of these must not take the rest down with + // it, so a failure here leaves the previous value standing. + if reply, err := c.ask("^OS;"); err == nil { + if v, err := parseInt(reply, "^OS"); err == nil { + c.statusMu.Lock() + c.status.Operate = v == 1 + c.statusMu.Unlock() + } + } + if reply, err := c.ask("^ON;"); err == nil { + if v, err := parseInt(reply, "^ON"); err == nil { + c.statusMu.Lock() + c.status.PowerOn = v == 1 + c.statusMu.Unlock() + } + } + if reply, err := c.ask("^VI;"); err == nil { + if v, a, err := parseVI(reply); err == nil { + c.statusMu.Lock() + c.status.VoltV, c.status.CurA = v, a + c.statusMu.Unlock() + } + } + if reply, err := c.ask("^TM;"); err == nil { + if v, err := parseInt(reply, "^TM"); err == nil { + c.statusMu.Lock() + c.status.TempC = v + c.statusMu.Unlock() + } + } + if reply, err := c.ask("^BN;"); err == nil { + if v, err := parseInt(reply, "^BN"); err == nil { + c.statusMu.Lock() + c.status.Band = BandName(v) + c.statusMu.Unlock() + } + } + 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() + } + } +} diff --git a/internal/kpa/parse.go b/internal/kpa/parse.go new file mode 100644 index 0000000..a3e83b8 --- /dev/null +++ b/internal/kpa/parse.go @@ -0,0 +1,157 @@ +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] } diff --git a/internal/kpa/parse_test.go b/internal/kpa/parse_test.go new file mode 100644 index 0000000..6743fe6 --- /dev/null +++ b/internal/kpa/parse_test.go @@ -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") + } +}