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 }