From 55a643d9a44bb87530d54ab2f3a97eb9d8172c8b Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 17:43:32 +0200 Subject: [PATCH 1/4] 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") + } +} From 85872f837952f41337afd068824378a11a731a49 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 17:50:30 +0200 Subject: [PATCH 2/4] feat(kpa): the Elecraft amplifiers in the Settings, the card and the widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Wired into the multi-amplifier support that already carries the SPE, the ACOM and the PowerGenius: a fourth brand rather than a fourth mechanism. The linking, the fan-out and the poll all work on it unchanged. Three things the KPA does differently, and each is handled where it shows rather than explained in a hint: - A KPA500 has no network side at all. Choosing it puts the entry on serial, and a configuration still asking for TCP is refused with a line saying so instead of retrying an address that cannot answer. - The amplifier answers ^ON while its main supplies are OFF — a sleeping microcontroller stays awake for exactly that — so power-on stays available over the network, unlike the SPE and ACOM which need their serial control lines. - Going to OPERATE clears the current fault, everything except temperature. The widget says so under the fault, because the button that fixes it is the one already on screen. Defaults that leave a working configuration when the model is changed: 38400 baud, TCP port 1500, and a full-scale power mark of 500 or 1500 W by model — a KPA500 read against a 1500 W scale looks idle at full output. --- app.go | 31 +++++++++++++++ frontend/src/components/AmpWidget.tsx | 36 ++++++++++++----- frontend/src/components/SettingsModal.tsx | 40 ++++++++++++++++--- frontend/src/lib/i18n.tsx | 4 +- frontend/wailsjs/go/models.ts | 47 +++++++++++++++++++++++ 5 files changed, 142 insertions(+), 16 deletions(-) diff --git a/app.go b/app.go index 2e040a1..0ec1bc0 100644 --- a/app.go +++ b/app.go @@ -43,6 +43,7 @@ import ( "hamlog/internal/geo" "hamlog/internal/gridcache" "hamlog/internal/integrations/udp" + "hamlog/internal/kpa" "hamlog/internal/lookup" "hamlog/internal/lotwusers" "hamlog/internal/netctl" @@ -18075,6 +18076,7 @@ type ampInst struct { pgxl *powergenius.Client spe *spe.Client acom *acom.Client + kpa *kpa.Client catemu *catemu.Server // Kenwood-format responder for band-follow (ACOM) } @@ -18088,6 +18090,9 @@ func (i *ampInst) stopAll() { if i.acom != nil { i.acom.Stop() } + if i.kpa != nil { + i.kpa.Stop() + } if i.catemu != nil { i.catemu.Stop() } @@ -18102,6 +18107,8 @@ func ampTypeLabel(t string) string { return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t] case strings.HasPrefix(t, "acom"): return "ACOM " + strings.TrimPrefix(t, "acom") + "S" + case strings.HasPrefix(t, "kpa"): + return "Elecraft " + strings.ToUpper(t) } return t } @@ -18213,6 +18220,19 @@ func (a *App) startAmps() { if a.acom == nil { a.acom = inst.acom } + case strings.HasPrefix(c.Type, "kpa"): + // A KPA500 has no network port at all, so a configuration asking for + // one is a mistake worth naming rather than a connection that never + // succeeds. + if strings.EqualFold(c.Type, "kpa500") && c.Transport == "tcp" { + applog.Printf("amp %s: a KPA500 has no network connection — use its serial port", c.Name) + continue + } + inst.kpa = kpa.New(kpa.Config{ + Model: strings.ToUpper(c.Type), Transport: c.Transport, + ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port, + }) + _ = inst.kpa.Start() default: // spe* inst.spe = spe.New(spe.Config{Transport: c.Transport, ComPort: c.ComPort, Baud: c.Baud, Host: c.Host, Port: c.Port}) _ = inst.spe.Start() @@ -18284,6 +18304,7 @@ type AmpStatus struct { PGXL *powergenius.Status `json:"pgxl,omitempty"` SPE *spe.Status `json:"spe,omitempty"` ACOM *acom.Status `json:"acom,omitempty"` + KPA *kpa.Status `json:"kpa,omitempty"` } // GetAmpStatuses returns the live state of every ENABLED amplifier, in the @@ -18310,6 +18331,9 @@ func (a *App) GetAmpStatuses() []AmpStatus { case inst.acom != nil: v := inst.acom.GetStatus() st.ACOM = &v + case inst.kpa != nil: + v := inst.kpa.GetStatus() + st.KPA = &v } } out = append(out, st) @@ -18391,6 +18415,8 @@ func (a *App) ampOperateOne(id string, on bool) error { return inst.spe.Operate(on) case inst.acom != nil: return inst.acom.Operate(on) + case inst.kpa != nil: + return inst.kpa.Operate(on) } return fmt.Errorf("amplifier not running") } @@ -18427,6 +18453,11 @@ func (a *App) ampPowerOne(id string, on, linked bool) error { return inst.acom.PowerOn() } return inst.acom.PowerOff() + case inst.kpa != nil: + // OFF is a real power-down on a KPA1500: the main supplies drop and the + // way back on is the front panel or Wake-on-LAN. The button that reaches + // this asks first — see the UI — because "off" here is not standby. + return inst.kpa.PowerOn(on) } // Not an error worth surfacing when linked: a PGXL alongside two SPEs simply // has no power command on its direct link, and reporting that as a failure diff --git a/frontend/src/components/AmpWidget.tsx b/frontend/src/components/AmpWidget.tsx index 1bf6555..94563a9 100644 --- a/frontend/src/components/AmpWidget.tsx +++ b/frontend/src/components/AmpWidget.tsx @@ -15,7 +15,7 @@ import { AmpOperate, AmpPower, AmpPowerLevel, AmpFanMode, FlexAmpOperate } from // With several amplifiers configured the caller passes a selection ("all" or an // amp id) chosen from the toolbar icon's dropdown. -type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any }; +type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any }; // Full-scale watts for the output bar, from the model string. function maxW(model?: string, fallback = 1300): number { @@ -92,27 +92,39 @@ function OperateButton({ operate, disabled, onClick, t }: { function AmpBlock({ amp, flex, showName, t }: { amp: Amp; flex: any; showName: boolean; t: (k: string, v?: any) => string; }) { - const spe = amp.spe, acom = amp.acom; + const spe = amp.spe, acom = amp.acom, kpaAmp = amp.kpa; const hold = usePeakHold(); - if (spe || acom) { - const s = spe || acom; + // One block for the three amplifiers OpsLog drives over its own link. They + // report the same handful of things under different names, so the differences + // are named here rather than spread through the markup. + if (spe || acom || kpaAmp) { + const s = spe || acom || kpaAmp; // The amp reports zero watts on receive, so its TX flag clears the meter as // soon as the operator lets go — and the radio's own flag, when we have one, // gets there first (the amp is polled on its own slower cycle). const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : s.tx !== false; const w = hold('w', Number(spe ? s.output_w : s.fwd_w) || 0, txing); const swr = Number(spe ? s.swr_ant : s.swr) || 0; - const hi = spe ? maxW(s.model) : (Number(s.max_w) || 800); + // The full-scale mark. A KPA500 reading against a 1500 W scale would look + // idle at full output, so the model decides it. + const hi = spe ? maxW(s.model) + : kpaAmp ? (String(s.model).includes('500') ? 500 : 1500) + : (Number(s.max_w) || 800); // Power ON drives the remote-on control lines, so it stays available while // the amplifier is off and reporting nothing — but only over a serial link. - const canPowerOn = spe ? (s.connected || s.transport === 'serial') : (s.port_open && s.transport === 'serial'); + // A KPA answers ^ON while its main supplies are off — the sleeping + // microcontroller stays awake for exactly that — so power-on is available + // whenever the link itself is up, over serial and over the network alike. + const canPowerOn = spe ? (s.connected || s.transport === 'serial') + : kpaAmp ? !!s.connected + : (s.port_open && s.transport === 'serial'); return (
{showName && (
- {amp.name || (spe ? 'SPE' : 'ACOM')} + {amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'ACOM')} {s.connected && s.band && {s.band}}
)} @@ -155,11 +167,17 @@ function AmpBlock({ amp, flex, showName, t }: { ) : (
{t('ampw.offline')}
)} - {(s.warnings || s.alarms || s.err_text) && ( + {(s.warnings || s.alarms || s.err_text || s.fault_text) && (
- {s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()} + {s.fault_text || s.err_text || `${s.warnings || ''} ${s.alarms || ''}`.trim()}
)} + {/* Said where the fault is read, because it is the way out of it: on a + KPA, going to OPERATE clears the current fault — everything except + temperature, which clears by cooling. */} + {kpaAmp && s.fault_text && ( +
{t('ampw.kpaClear')}
+ )}
); diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index aa252d8..dab807c 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -905,9 +905,10 @@ function AmpStatusCard({ id }: { id: string }) { const t = window.setInterval(tick, 1000); return () => { alive = false; window.clearInterval(t); }; }, [id]); - const st: any = amp?.spe ?? amp?.acom ?? amp?.pgxl ?? { connected: false }; + const st: any = amp?.spe ?? amp?.acom ?? amp?.kpa ?? amp?.pgxl ?? { connected: false }; const isSPE = !!amp?.spe; const isACOM = !!amp?.acom; + const isKPA = !!amp?.kpa; const operate = !!st.operate; return (
@@ -948,7 +949,23 @@ function AmpStatusCard({ id }: { id: string }) { {st.err_text &&
⚠ {st.err_text} ({st.err_code})
}
)} - {st.connected && !isSPE && !isACOM && ( + {st.connected && isKPA && ( +
+
{st.power_on ? 'ON' : 'OFF'}
+
Band {st.band || '—'}
+
{st.fwd_w} W
+
SWR {Number(st.swr ?? 0).toFixed(1)}
+
{st.volt_v} V
+
{st.cur_a} A
+
{st.temp_c}°C
+
{st.tuning ? 'TUNING' : ''}
+ {/* A fault has already put the amplifier in standby by itself, so it + is the one thing worth the width — and OPERATE is the way out of + it, which the button above already is. */} + {st.fault_text &&
⚠ {st.fault_text}
} +
+ )} + {st.connected && !isSPE && !isACOM && !isKPA && (
{st.state || ''}
Fan {st.fan_mode || '—'}
@@ -4142,15 +4159,27 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged { value: 'acom1200', label: '1200S' }, { value: 'acom2020', label: '2020S' }, ], + kpa: [ + { value: 'kpa1500', label: 'KPA1500' }, + { value: 'kpa500', label: 'KPA500' }, + ], }; - const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' : ty.startsWith('acom') ? 'acom' : 'spe'; + const brandOf = (ty: string) => (!ty || ty === 'pgxl') ? 'pgxl' + : ty.startsWith('acom') ? 'acom' + : ty.startsWith('kpa') ? 'kpa' : 'spe'; const patchAmp = (i: number, patch: Partial) => setAmps((l) => l.map((a, j) => (j === i ? { ...a, ...patch } : a))); // Each family has a fixed serial speed: SPE talks 115200, the ACOM S-series is // 9600 8N1 — preset it so switching brand just works. PGXL is TCP-only. + // Each family has its own fixed serial speed and its own default port, so + // switching model leaves a working configuration rather than one the + // operator has to repair. A KPA500 has no network side at all — it is put + // on serial here rather than being allowed to sit on a TCP setting that + // could never connect. const applyType = (i: number, v: string) => patchAmp(i, { type: v, - transport: v === 'pgxl' ? 'tcp' : amps[i].transport, - baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : amps[i].baud, + transport: v === 'pgxl' ? 'tcp' : v === 'kpa500' ? 'serial' : amps[i].transport, + baud: v.startsWith('acom') ? 9600 : v.startsWith('spe') ? 115200 : v.startsWith('kpa') ? 38400 : amps[i].baud, + port: v === 'kpa1500' ? 1500 : amps[i].port, }); const addAmp = () => setAmps((l) => [...l, { id: '', name: '', enabled: true, type: 'spe13', transport: 'tcp', host: '', port: 9008, com_port: '', baud: 115200, @@ -4195,6 +4224,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged 4O3A SPE ACOM + Elecraft
diff --git a/frontend/src/lib/i18n.tsx b/frontend/src/lib/i18n.tsx index 2355510..29ee785 100644 --- a/frontend/src/lib/i18n.tsx +++ b/frontend/src/lib/i18n.tsx @@ -419,7 +419,7 @@ const en: Dict = { 'agp.portDeselect': 'Port {letter} — click to deselect', 'agp.portSelect': 'Select on port {letter}', 'agp.online': 'online', 'agp.offline': 'offline', 'agp.close': 'Close', 'agp.connecting': 'Connecting…', 'agp.noAntennas': 'No antennas configured.', 'agp.filterOnHint': 'Showing antennas for {band} only — click to show all bands', 'agp.filterOffHint': 'Showing all antennas — click to show only the current band', 'ampw.title': 'Amplifier', 'ampw.all': 'All amplifiers', 'ampw.pick': 'Which amplifier the widget shows', 'ampw.showHint': 'Amplifier · click to show', 'ampw.hideHint': 'Amplifier — shown · click to hide', - 'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.none': 'No amplifier configured', + 'ampw.close': 'Close', 'ampw.offline': 'offline', 'ampw.kpaClear': 'OPERATE clears this fault', 'ampw.none': 'No amplifier configured', 'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF', 'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High', 'ampw.pwr': 'Watts', 'ampw.swr': 'SWR', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Fan mode', @@ -891,7 +891,7 @@ const fr: Dict = { 'agp.portDeselect': 'Port {letter} — clic pour désélectionner', 'agp.portSelect': 'Sélectionner sur le port {letter}', 'agp.online': 'en ligne', 'agp.offline': 'hors ligne', 'agp.close': 'Fermer', 'agp.connecting': 'Connexion…', 'agp.noAntennas': 'Aucune antenne configurée.', 'agp.filterOnHint': 'Antennes du {band} uniquement — clic pour afficher toutes les bandes', 'agp.filterOffHint': 'Toutes les antennes affichées — clic pour n’afficher que la bande courante', 'ampw.title': 'Amplificateur', 'ampw.all': 'Tous les amplis', 'ampw.pick': 'Ampli affiché par le widget', 'ampw.showHint': 'Amplificateur · cliquer pour afficher', 'ampw.hideHint': 'Amplificateur — affiché · cliquer pour masquer', - 'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.none': 'Aucun amplificateur configuré', + 'ampw.close': 'Fermer', 'ampw.offline': 'hors ligne', 'ampw.kpaClear': 'OPERATE efface ce défaut', 'ampw.none': 'Aucun amplificateur configuré', 'ampw.operate': 'Operate', 'ampw.standby': 'Standby', 'ampw.on': 'ON', 'ampw.off': 'OFF', 'ampw.lvlL': 'Low', 'ampw.lvlM': 'Mid', 'ampw.lvlH': 'High', 'ampw.pwr': 'Watts', 'ampw.swr': 'ROS', 'ampw.temp': 'Temp', 'ampw.id': 'Id (A)', 'ampw.fan': 'Mode ventil.', diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1f5f42b..5c162da 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1500,6 +1500,51 @@ export namespace extsvc { } +export namespace kpa { + + export class Status { + connected: boolean; + transport: string; + model?: string; + last_error?: string; + power_on: boolean; + operate: boolean; + fwd_w: number; + swr: number; + volt_v: number; + cur_a: number; + temp_c: number; + band?: string; + tuning: boolean; + fault_code: number; + fault_text?: string; + + static createFrom(source: any = {}) { + return new Status(source); + } + + constructor(source: any = {}) { + if ('string' === typeof source) source = JSON.parse(source); + this.connected = source["connected"]; + this.transport = source["transport"]; + this.model = source["model"]; + this.last_error = source["last_error"]; + this.power_on = source["power_on"]; + this.operate = source["operate"]; + this.fwd_w = source["fwd_w"]; + this.swr = source["swr"]; + this.volt_v = source["volt_v"]; + this.cur_a = source["cur_a"]; + this.temp_c = source["temp_c"]; + this.band = source["band"]; + this.tuning = source["tuning"]; + this.fault_code = source["fault_code"]; + this.fault_text = source["fault_text"]; + } + } + +} + export namespace lookup { export class Result { @@ -1701,6 +1746,7 @@ export namespace main { pgxl?: powergenius.Status; spe?: spe.Status; acom?: acom.Status; + kpa?: kpa.Status; static createFrom(source: any = {}) { return new AmpStatus(source); @@ -1714,6 +1760,7 @@ export namespace main { this.pgxl = this.convertValues(source["pgxl"], powergenius.Status); this.spe = this.convertValues(source["spe"], spe.Status); this.acom = this.convertValues(source["acom"], acom.Status); + this.kpa = this.convertValues(source["kpa"], kpa.Status); } convertValues(a: any, classs: any, asMap: boolean = false): any { From de95a437bbf0352639455221e6b874e51fc3b064 Mon Sep 17 00:00:00 2001 From: rouggy Date: Wed, 26 Aug 2026 18:00:46 +0200 Subject: [PATCH 3/4] fix(kpa): its own card, and no band-follow it does not need MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- app.go | 17 ++++++-- frontend/package.json.md5 | 2 +- frontend/src/components/AmpCard.tsx | 52 ++++++++++++++++++++++- frontend/src/components/AmpWidget.tsx | 2 +- frontend/src/components/SettingsModal.tsx | 14 ++++-- frontend/src/lib/i18n.tsx | 8 ++-- internal/kpa/kpa.go | 38 +++++++++++++++++ 7 files changed, 118 insertions(+), 15 deletions(-) diff --git a/app.go b/app.go index 0ec1bc0..743cdda 100644 --- a/app.go +++ b/app.go @@ -18106,7 +18106,7 @@ func ampTypeLabel(t string) string { case strings.HasPrefix(t, "spe"): return "SPE " + map[string]string{"spe13": "1.3K-FA", "spe15": "1.5K-FA", "spe2k": "2K-FA"}[t] case strings.HasPrefix(t, "acom"): - return "ACOM " + strings.TrimPrefix(t, "acom") + "S" + return "Acom " + strings.TrimPrefix(t, "acom") + "S" case strings.HasPrefix(t, "kpa"): return "Elecraft " + strings.ToUpper(t) } @@ -18278,6 +18278,17 @@ func (a *App) feedAmpBandFollow(s cat.RigState) { inst.catemu.SetFrequency(s.FreqHz) inst.catemu.SetMode(s.Mode) } + // A KPA is TOLD its band, on the link it is already on. + // + // The emulator above exists because an Acom polls a transceiver and has + // no command to be given a band; the KPA has one (^BN), so it needs + // neither a second serial port nor a pretend rig. Sent from a goroutine + // because this runs on the CAT state-change path, and nothing about the + // rig should wait on an amplifier's link. + if inst.kpa != nil && s.Band != "" { + k, band := inst.kpa, s.Band + go func() { _ = k.SetBand(band) }() + } } } @@ -18553,7 +18564,7 @@ func (a *App) GetACOMStatus() acom.Status { // protocol has explicit commands for each, unlike the SPE's toggle key. func (a *App) ACOMSetOperate(on bool) error { if a.acom == nil { - return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier") + return fmt.Errorf("Acom amplifier not connected — enable it in Settings → Amplifier") } return a.acom.Operate(on) } @@ -18562,7 +18573,7 @@ func (a *App) ACOMSetOperate(on bool) error { // the power-on pins wired in the cable) or off (false, the OFF data command). func (a *App) ACOMSetPower(on bool) error { if a.acom == nil { - return fmt.Errorf("ACOM amplifier not connected — enable it in Settings → Amplifier") + return fmt.Errorf("Acom amplifier not connected — enable it in Settings → Amplifier") } if on { return a.acom.PowerOn() diff --git a/frontend/package.json.md5 b/frontend/package.json.md5 index 693b40b..b826f3b 100644 --- a/frontend/package.json.md5 +++ b/frontend/package.json.md5 @@ -1 +1 @@ -f9b41e192918fa2511f68cd1b361fcd3 \ No newline at end of file +704fe1bf370b669665df0606fae8a69d \ No newline at end of file diff --git a/frontend/src/components/AmpCard.tsx b/frontend/src/components/AmpCard.tsx index a02e642..ff2893b 100644 --- a/frontend/src/components/AmpCard.tsx +++ b/frontend/src/components/AmpCard.tsx @@ -47,7 +47,7 @@ function powerLevelLabel(pl?: string): string { } } -type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; pgxl?: any }; +type Amp = { id: string; name: string; type?: string; spe?: any; acom?: any; kpa?: any; pgxl?: any }; export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, v?: any) => string }) { // Peak-hold so the jittery VITA-49 meters read steadily (own ref per card). @@ -64,6 +64,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, const isSPE = !!amp.spe; const isACOM = !!amp.acom; + const isKPA = !!amp.kpa; if (isSPE) { const spe = amp.spe; @@ -127,7 +128,7 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string, if (isACOM) { const acom = amp.acom; return ( - +
+ {/* The amplifier answers while its main supplies are off — a sleeping + microcontroller stays awake for exactly that — so ON is offered + over the network as well, unlike the SPE and Acom. */} +
+ + +
+ + + {kpa.connected ? (kpa.tuning ? t('flxp.kpaTuning') : (kpa.power_on ? 'ON' : 'OFF')) : t('flxp.acomOffline')} + + {kpa.connected && ( + + {kpa.band ? `${kpa.band} · ` : ''}{kpa.fwd_w}W · SWR {Number(kpa.swr ?? 0).toFixed(1)} · {kpa.temp_c}°C · {kpa.volt_v}V {kpa.cur_a}A + + )} +
+ {kpa.fault_text && ( + ⚠ {kpa.fault_text} + )} +
+ {kpa.connected && ( + (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} /> + )} + + ); + } + // PowerGenius XL — OPERATE + meters ride on the Flex; fan mode on the GSCP link. const pg = amp.pgxl || {}; const viaFlex = !!flex?.amp_available; diff --git a/frontend/src/components/AmpWidget.tsx b/frontend/src/components/AmpWidget.tsx index 94563a9..1721ceb 100644 --- a/frontend/src/components/AmpWidget.tsx +++ b/frontend/src/components/AmpWidget.tsx @@ -124,7 +124,7 @@ function AmpBlock({ amp, flex, showName, t }: { {showName && (
- {amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'ACOM')} + {amp.name || (spe ? 'SPE' : kpaAmp ? (s.model || 'KPA') : 'Acom')} {s.connected && s.band && {s.band}}
)} diff --git a/frontend/src/components/SettingsModal.tsx b/frontend/src/components/SettingsModal.tsx index dab807c..d7a168a 100644 --- a/frontend/src/components/SettingsModal.tsx +++ b/frontend/src/components/SettingsModal.tsx @@ -4196,6 +4196,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged const brand = brandOf(amp.type); const isPGXL = brand === 'pgxl'; const isACOM = brand === 'acom'; + const isKPA = brand === 'kpa'; const isSerial = !isPGXL && amp.transport === 'serial'; return (
@@ -4223,7 +4224,7 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged 4O3A SPE - ACOM + Acom Elecraft @@ -4314,10 +4315,15 @@ function SettingsModalImpl({ onClose, onSaved, initialSection, onMainPaneChanged )} {/* Band-follow, for any amp that takes its band from a transceiver - CAT link (ACOM and SPE both do) — never PowerGenius, which is + CAT link (Acom and SPE both do) — never PowerGenius, which is driven over its network protocol. A SECOND serial port, - separate from the metering one above. */} - {!isPGXL && ( + separate from the metering one above. + Never a KPA either: it has a band command of its own (^BN), + so OpsLog tells it directly on the link it is already using. + Offering a second serial port and a transceiver emulator for + that would be a workaround for a problem this amplifier does + not have. */} + {!isPGXL && !isKPA && (