feat(elecraft): a K3/K4 console — power, volume, S-meter, SWR, MOX, tune

The Elecraft backend already existed; what was missing was somewhere to
operate the radio from. This adds the panel, on the Kenwood-dialect
client the K3 already speaks, in a tab of its own beside the Yaesu and
Icom consoles.

Scope is the six controls asked for and nothing else. Every extra command
added without a radio to test it against is a control that may or may not
do what its label says, and a K3 exposes dozens.

No K3 was available while writing this, so the two halves are treated
differently. The setters are the commands the reference documents
unambiguously and whose effect is visible and reversible (PC, AG,
TX/RX). The meters are the opposite: their scaling differs by model and
firmware, so the raw answers are logged next to the power setting, the
panel says the scaling is provisional, and an unmeasured SWR shows as
'—' rather than as a perfect 1.0 — a good match on an antenna nobody
measured is the reading that costs a radio.

ATU tune sends SWT20 (the K3 front-panel tap) and logs exactly what it
sent, so a wrong mapping names itself instead of leaving an operator
guessing which button OpsLog pressed.
This commit is contained in:
2026-08-24 14:09:28 +02:00
parent 1b63483596
commit 60dad353ce
11 changed files with 669 additions and 6 deletions
+301
View File
@@ -0,0 +1,301 @@
package cat
// Elecraft K3/K4 control panel — power, volume, S-meter, SWR, MOX and ATU tune.
//
// Built on the Kenwood-dialect backend, because a K3 speaks it: plain ASCII,
// every command terminated by ';'. What is Elecraft-specific is the vocabulary
// below, taken from the K3 Programmer's Reference; the K4 accepts the same set.
//
// NO K3 WAS AVAILABLE WHILE WRITING THIS, and that shapes it:
//
// - The commands that SET something are the ones the reference documents
// unambiguously and whose effect is visible and reversible (PC, AG, TX/RX).
// - The meters are the opposite: their scaling differs between models and
// firmware, and a wrongly scaled SWR bar is worse than none — it reports a
// good match on a bad antenna. So the raw answers are LOGGED, for a real
// radio to settle, and until then the panel says the scaling is provisional.
//
// The same discipline as the Yaesu meters, which were guessed wrong twice and
// only settled when an FTDX10 keyed a carrier at two known power levels.
import (
"fmt"
"strconv"
"strings"
"time"
)
// KenwoodTXState is what the panel shows.
type KenwoodTXState struct {
Available bool `json:"available"`
Model string `json:"model,omitempty"`
Elecraft bool `json:"elecraft"` // a K3/K4 rather than a Kenwood
Mode string `json:"mode,omitempty"`
Transmitting bool `json:"transmitting"`
Split bool `json:"split"`
SplitTXHz int64 `json:"split_tx_hz"`
// SMeter is 0-100 for the bar. SMeterRaw is what the rig actually answered,
// kept because the scaling is not yet confirmed on a real K3 and a number
// nobody can check is worth less than the reading it came from.
SMeter int `json:"s_meter"`
SMeterRaw int `json:"s_meter_raw"`
// PowerMeter is 0-100 while transmitting. SWR is the ratio; 0 means "not
// measured", NOT a perfect match.
PowerMeter int `json:"power_meter"`
SWR float64 `json:"swr"`
SWRRaw int `json:"swr_raw"`
RFPower int `json:"rf_power"` // watts, the PC setting
AFGain int `json:"af_gain"` // 0-100
// MetersProvisional says the meter scaling has not been confirmed against a
// real radio. The panel says so rather than presenting a guess as a
// measurement.
MetersProvisional bool `json:"meters_provisional"`
}
// KenwoodPanelController is the K3/K4 panel capability. Separate from
// KenwoodController (the CW keyer) so a backend can offer one without the other.
type KenwoodPanelController interface {
KenwoodState() KenwoodTXState
RefreshKenwood() error
SetKenwoodPower(int) error
SetKenwoodAFGain(int) error
SetKenwoodTX(bool) error
TuneKenwoodATU() error
}
// kenwoodPanelSlowBeat is how many polls pass between full re-reads of the
// settings. The meters are read every poll; a power setting is not.
const kenwoodPanelSlowBeat = 8
// KenwoodState returns the panel snapshot.
func (k *Kenwood) KenwoodState() KenwoodTXState {
k.mu.Lock()
defer k.mu.Unlock()
st := k.panel
st.Available = k.port != nil
st.Model = k.model
st.Elecraft = k.elecraft
return st
}
// RefreshKenwood forces the settings to be re-read on the next poll, so a value
// changed on the radio's own front panel shows up at once.
func (k *Kenwood) RefreshKenwood() error {
k.mu.Lock()
k.panelCycle = kenwoodPanelSlowBeat
k.mu.Unlock()
return nil
}
// readPanel refreshes the panel. Called from ReadState with the mutex HELD, so
// it shares the same serialised link as everything else.
func (k *Kenwood) readPanel(mode string, split bool, txHz int64) {
k.panel.Mode = mode
k.panel.Split = split
k.panel.SplitTXHz = 0
if split {
k.panel.SplitTXHz = txHz
}
k.panel.Transmitting = k.tx
k.panel.MetersProvisional = true
if k.panel.Transmitting {
k.readTXMeters()
} else {
// Cleared, not frozen: a power bar left standing after the carrier drops
// reads as a live transmission.
k.panel.PowerMeter = 0
k.panel.SWR, k.panel.SWRRaw = 0, 0
k.powerPeak, k.swrPeak = meterPeak{}, meterPeak{}
// The S-meter only means anything while receiving.
if v, ok := k.askNum("SM;", "SM", 4); ok {
k.panel.SMeterRaw = v
k.panel.SMeter = kenwoodSMeterPercent(v)
}
}
k.panelCycle++
if k.panelLoaded && k.panelCycle < kenwoodPanelSlowBeat {
return
}
k.panelCycle = 0
k.panelLoaded = true
k.readPanelSettings()
}
// readPanelSettings re-reads what a knob can change.
func (k *Kenwood) readPanelSettings() {
// PC is watts on both the K3 and the Kenwoods — a setting, not a scale.
if v, ok := k.askNum("PC;", "PC", 3); ok {
k.panel.RFPower = v
}
// AF gain. The K3 answers three digits 000-255; a Kenwood answers AG0nnn,
// which is why the plain form is tried first and the addressed one after.
if v, ok := k.askNum("AG;", "AG", 3); ok {
k.panel.AFGain = scale255(v)
} else if v, ok := k.askNum("AG0;", "AG0", 3); ok {
k.panel.AFGain = scale255(v)
}
}
// kenwoodMeterProbes are the candidate meter commands, asked once per
// transmission burst so a real radio can settle what they mean.
//
// They are NOT interchangeable — BG is a bargraph position, SM during transmit
// is something else again — which is exactly why the log records which one
// answered and what it said, next to the power SETTING: the meter that tracks a
// known carrier at two different power levels is the power meter, and no amount
// of reading the reference settles that as well as one transmission does.
var kenwoodMeterProbes = []struct {
cmd string
prefix string
digits int
}{
{"SM;", "SM", 4},
{"BG;", "BG", 2},
{"SW;", "SW", 4},
{"PO;", "PO", 3},
}
// readTXMeters reads the transmit meters.
func (k *Kenwood) readTXMeters() {
now := time.Now()
if v, ok := k.askNum("BG;", "BG", 2); ok {
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now)
}
if v, ok := k.askNum("SW;", "SW", 4); ok {
k.panel.SWRRaw = v
// Tenths of a ratio, provisionally: 15 → 1.5. Reported as raw as well,
// so the log can correct this without anyone having to trust the bar.
if v > 0 {
k.panel.SWR = float64(k.swrPeak.update(v, now)) / 10
}
}
if k.metersLogged >= 20 {
return
}
k.metersLogged++
raw := make([]string, 0, len(kenwoodMeterProbes))
for _, p := range kenwoodMeterProbes {
if v, ok := k.askNum(p.cmd, p.prefix, p.digits); ok {
raw = append(raw, fmt.Sprintf("%s=%d", strings.TrimSuffix(p.cmd, ";"), v))
} else {
raw = append(raw, strings.TrimSuffix(p.cmd, ";")+"=-")
}
}
debugLog.Printf("kenwood: TX meters at PC=%dW: %s (compare two power settings, and a known SWR)",
k.panel.RFPower, strings.Join(raw, " "))
}
// kenwoodSMeterPercent turns the S-meter answer into a bar percentage.
//
// The K3 reports 0-21 across S0…S9+60, which is not a linear dB scale but is
// what its own display shows — and matching the radio's own bar is something an
// operator can check at a glance. A Kenwood answers 0-30 on the same command;
// both are covered by clamping rather than by guessing which rig is on the
// other end.
func kenwoodSMeterPercent(v int) int {
switch {
case v <= 0:
return 0
case v >= 30:
return 100
}
return v * 100 / 21
}
// kenwoodBargraphPercent scales the K3 bargraph (0-12 segments) to the bar.
func kenwoodBargraphPercent(v int) int {
switch {
case v <= 0:
return 0
case v >= 12:
return 100
}
return v * 100 / 12
}
// SetKenwoodPower sets the transmit power, in watts.
func (k *Kenwood) SetKenwoodPower(w int) error {
if w < 0 {
w = 0
}
if w > 200 {
w = 200
}
return k.setPanel(fmt.Sprintf("PC%03d;", w))
}
// SetKenwoodAFGain sets the volume, 0-100, scaled to the rig's 0-255.
func (k *Kenwood) SetKenwoodAFGain(p int) error {
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
return k.setPanel(fmt.Sprintf("AG%03d;", p*255/100))
}
// SetKenwoodTX keys or unkeys the transmitter — the panel's MOX.
//
// Goes through SetPTT rather than writing TX;/RX; here, so the backend's own
// idea of transmitting stays true: it suppresses polling while the carrier is
// up, and a panel that keyed behind its back would leave it polling a rig that
// answers "?;" to everything.
func (k *Kenwood) SetKenwoodTX(on bool) error {
return k.SetPTT(on)
}
// kenwoodATUTune is the command that starts an ATU tuning cycle on a K3.
//
// The K3 has no dedicated "tune" command: the reference exposes the front panel
// instead, and SWT20 is the tap of the ATU TUNE button. That mapping has NOT
// been confirmed on a radio here, which is why the command actually sent is
// logged — if it presses something else on a real K3, the log names what was
// sent instead of leaving an operator to guess which button OpsLog reached for.
const kenwoodATUTune = "SWT20;"
// TuneKenwoodATU starts an ATU tuning cycle.
func (k *Kenwood) TuneKenwoodATU() error {
debugLog.Printf("kenwood: ATU tune — sending %q (K3 front-panel tap; report it if another button responded)", kenwoodATUTune)
return k.setPanel(kenwoodATUTune)
}
// setPanel writes one command and schedules a settings re-read, so the panel
// shows what the radio did rather than what it was asked to do.
func (k *Kenwood) setPanel(cmd string) error {
k.mu.Lock()
defer k.mu.Unlock()
if k.port == nil {
return fmt.Errorf("kenwood: not connected")
}
if err := k.write(cmd); err != nil {
return err
}
k.panelCycle = kenwoodPanelSlowBeat // re-read on the next poll
return nil
}
// askNum asks a command and parses its numeric body. ask() already remembers
// what this rig answered "?;" to and refuses to ask it again, so an unsupported
// meter costs one timeout for the life of the session, not one per poll.
func (k *Kenwood) askNum(cmd, prefix string, digits int) (int, bool) {
r, err := k.ask(cmd)
if err != nil {
return 0, false
}
body := strings.TrimSuffix(strings.TrimPrefix(r, prefix), ";")
if len(body) < digits {
return 0, false
}
n, err := strconv.Atoi(strings.TrimSpace(body[:digits]))
if err != nil {
return 0, false
}
return n, true
}