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,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
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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] }
|
||||
@@ -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