Three faults, one report. The slow poll asks ^TP — the KPA1500's ATU, which a KPA500 (no ATU) never answers — and ask() dropped the whole connection on any read timeout: a two-second stall and a reconnect every slow cycle, which is why buttons lagged and the status read wrong. Worse, every serial reopen toggled DTR/RTS — and those lines are the KPA500's POWER SWITCH (that is how the Elecraft utility turns it on), so the amplifier obediently switched off twenty seconds after its operator pressed nothing but Standby. Silence is no longer a dead link (write errors still are), ^TP is never asked again after one silence, the control lines are asserted once and held, and the baud field becomes a list of the rates these amplifiers actually speak.
436 lines
13 KiB
Go
436 lines
13 KiB
Go
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
|
|
skipTP bool // ^TP went unanswered once — a KPA500, no ATU; never ask again
|
|
|
|
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)
|
|
}
|
|
// The KPA500 is POWER-CONTROLLED by these lines: the Elecraft utility
|
|
// switches the amplifier on by raising them. Held asserted, once, and
|
|
// never touched again — reconnect cycles that toggled them were
|
|
// switching a KPA500 OFF twenty seconds after its operator pressed
|
|
// nothing but Standby.
|
|
_ = p.SetDTR(true)
|
|
_ = p.SetRTS(true)
|
|
_ = 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 {
|
|
// NOT dropped. A command this model simply does not know (^TP is the
|
|
// KPA1500's ATU — a KPA500 never answers it) is silence, not a dead
|
|
// link, and dropping here tore the connection down on every slow poll
|
|
// cycle: two seconds of stalled commands, a reconnect, and a DTR
|
|
// toggle the amplifier read as the off switch. Nothing arrived, so
|
|
// nothing is left to desynchronise the next exchange. Write errors —
|
|
// the genuinely dead link — still drop, above.
|
|
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 c.skipTP {
|
|
return
|
|
}
|
|
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()
|
|
}
|
|
} else {
|
|
// One silence is the model's answer for good: a KPA500 has no ATU and
|
|
// will never answer ^TP — asking again every cycle cost a two-second
|
|
// stall each time.
|
|
c.skipTP = true
|
|
applog.Printf("kpa: ^TP unanswered — no ATU on this model, not asking again")
|
|
}
|
|
}
|
|
|
|
// SetBand puts the amplifier on a band by its ADIF name.
|
|
//
|
|
// The KPA takes its band from the transceiver on its own XCVR connector, but it
|
|
// also accepts ^BN — so OpsLog can simply say it on the link it is already
|
|
// using. That is worth knowing: an Acom has no such command, which is why
|
|
// following it needs a second serial port and a transceiver emulator answering
|
|
// its polls (internal/catemu). None of that applies here.
|
|
//
|
|
// Sent only when it CHANGES. Repeating the current band four times a second
|
|
// would be traffic on a link with no flow control, in exchange for nothing.
|
|
func (c *Client) SetBand(adifBand string) error {
|
|
n, ok := bandNumber(adifBand)
|
|
if !ok {
|
|
// Not an error the operator should see: the KPA covers 160-6 m, and
|
|
// tuning to 23 cm is not a fault, it is simply not this amplifier's
|
|
// business.
|
|
return nil
|
|
}
|
|
c.statusMu.Lock()
|
|
same := c.status.Band == adifBand
|
|
c.statusMu.Unlock()
|
|
if same {
|
|
return nil
|
|
}
|
|
return c.send(fmt.Sprintf("^BN%02d;", n))
|
|
}
|
|
|
|
// bandNumber is BandName backwards.
|
|
func bandNumber(adifBand string) (int, bool) {
|
|
b := strings.ToLower(strings.TrimSpace(adifBand))
|
|
for n, name := range bandNames {
|
|
if name == b {
|
|
return n, true
|
|
}
|
|
}
|
|
return 0, false
|
|
}
|