Two things the wiring got wrong, both visible on screen. A KPA fell through to the PowerGenius branch of the amplifier card and was drawn as one — titled 'POWERGENIUSXL · ELECRAFT KPA1500', with a PowerGenius's meters and none of its own. It has a card now: OPERATE, ON and OFF, band, power, SWR, temperature, supply, and the fault across the end. The OPERATE button carries a tooltip saying it also clears the fault, because that is the button an operator already has under the cursor when they need it. And the band-follow option was offered on it. That option exists because an Acom POLLS a transceiver and has no command to be given a band, so following it needs a second serial port and a rig emulator answering those polls. The KPA has ^BN — OpsLog simply tells it, on the link it is already using, and only when the band CHANGES. The option is gone from the KPA and the telling is automatic. Also: Acom rather than ACOM everywhere it is read, which is how the company writes it. Identifiers, package names and log lines are left alone — renaming those is churn with no reader.
413 lines
12 KiB
Go
413 lines
12 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
|
|
|
|
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()
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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
|
|
}
|