feat(elecraft): the rest of the main controls on the K3/K4 console

So the tester can exercise everything in one session instead of one
control per build: RF and mic gain, squelch, preamp, attenuator, NB, NR,
AGC, filter width, antenna, RIT/XIT with a clear, and the keyer speed.

The analogue scales differ between an Elecraft and a Kenwood — RF gain
000-250, mic 000-060, squelch 000-029 against 0-255 — so each is mapped
through its own full scale and shown as a percentage. Using one rig's
range on the other silently halves or doubles every setting.

Antenna only appears when the radio answers AN: a K3 without the internal
ATU has one socket, and a switch that goes nowhere is worse than none.
This commit is contained in:
2026-08-24 14:29:38 +02:00
parent 60dad353ce
commit dd77e7f467
8 changed files with 502 additions and 7 deletions
+230
View File
@@ -49,6 +49,28 @@ type KenwoodTXState struct {
RFPower int `json:"rf_power"` // watts, the PC setting
AFGain int `json:"af_gain"` // 0-100
RFGain int `json:"rf_gain"` // 0-100
MicGain int `json:"mic_gain"` // 0-100
Squelch int `json:"squelch"` // 0-100
// Receive controls. Preamp and attenuator are the K3's single-step ones
// (PA/RA); a rig that answers neither leaves them false and the row shows
// the state it read, not a state it assumed.
Preamp bool `json:"preamp"`
Att bool `json:"att"`
NB bool `json:"nb"`
NR bool `json:"nr"`
AGC string `json:"agc,omitempty"` // "OFF", "SLOW", "FAST"
// FilterHz is the DSP bandwidth in Hz (the K3 reports it in 10 Hz units).
FilterHz int `json:"filter_hz"`
// Antenna is 1 or 2 on a K3 with the internal ATU, 0 when the rig does not
// answer AN — which is also how the panel knows to hide the row rather than
// offer a switch that goes nowhere.
Antenna int `json:"antenna"`
RIT bool `json:"rit"`
XIT bool `json:"xit"`
KeySpeed int `json:"key_speed"` // WPM
// MetersProvisional says the meter scaling has not been confirmed against a
// real radio. The panel says so rather than presenting a guess as a
@@ -63,6 +85,19 @@ type KenwoodPanelController interface {
RefreshKenwood() error
SetKenwoodPower(int) error
SetKenwoodAFGain(int) error
SetKenwoodRFGain(int) error
SetKenwoodMicGain(int) error
SetKenwoodSquelch(int) error
SetKenwoodPreamp(bool) error
SetKenwoodAtt(bool) error
SetKenwoodNB(bool) error
SetKenwoodNR(bool) error
SetKenwoodAGC(string) error
SetKenwoodFilter(int) error
SetKenwoodAntenna(int) error
SetKenwoodRIT(bool) error
SetKenwoodXIT(bool) error
ClearKenwoodRIT() error
SetKenwoodTX(bool) error
TuneKenwoodATU() error
}
@@ -140,6 +175,116 @@ func (k *Kenwood) readPanelSettings() {
} else if v, ok := k.askNum("AG0;", "AG0", 3); ok {
k.panel.AFGain = scale255(v)
}
// RF gain. The K3 counts 000-250 in dB of reduction; a Kenwood uses the
// familiar 0-255. Both are shown as a percentage, so the slider means the
// same thing on either radio.
if v, ok := k.askNum("RG;", "RG", 3); ok {
k.panel.RFGain = scalePercent(v, k.rfGainFull())
}
if v, ok := k.askNum("MG;", "MG", 3); ok {
k.panel.MicGain = scalePercent(v, k.micGainFull())
}
if v, ok := k.askNum("SQ;", "SQ", 3); ok {
k.panel.Squelch = scalePercent(v, k.squelchFull())
} else if v, ok := k.askNum("SQ0;", "SQ0", 3); ok {
k.panel.Squelch = scale255(v)
}
if v, ok := k.askNum("PA;", "PA", 1); ok {
k.panel.Preamp = v != 0
}
if v, ok := k.askNum("RA;", "RA", 2); ok {
k.panel.Att = v != 0
}
if v, ok := k.askNum("NB;", "NB", 1); ok {
k.panel.NB = v != 0
}
if v, ok := k.askNum("NR;", "NR", 1); ok {
k.panel.NR = v != 0
}
if v, ok := k.askNum("GT;", "GT", 3); ok {
k.panel.AGC = kenwoodAGCName(v)
}
// Filter width. The K3 answers BW in 10 Hz units (BW0270 = 2.7 kHz); a
// Kenwood that does not implement it says nothing and the row stays as it
// was rather than showing a zero-width filter.
if v, ok := k.askNum("BW;", "BW", 4); ok && v > 0 {
k.panel.FilterHz = v * 10
}
// Antenna. Only a K3 with the internal ATU answers; 0 means "this radio has
// no antenna switching", which is what hides the row.
if v, ok := k.askNum("AN;", "AN", 1); ok {
k.panel.Antenna = v
}
if v, ok := k.askNum("RT;", "RT", 1); ok {
k.panel.RIT = v != 0
}
if v, ok := k.askNum("XT;", "XT", 1); ok {
k.panel.XIT = v != 0
}
if v, ok := k.askNum("KS;", "KS", 3); ok {
k.panel.KeySpeed = v
}
}
// The full-scale value of the analogue controls differs between an Elecraft and
// a Kenwood, and using one rig's scale on the other silently halves or doubles
// every setting. Kept as three small functions rather than a table so each one
// carries the range it comes from.
func (k *Kenwood) rfGainFull() int {
if k.elecraft {
return 250 // K3: 000-250, dB of reduction
}
return 255
}
func (k *Kenwood) micGainFull() int {
if k.elecraft {
return 60 // K3: 000-060
}
return 255
}
func (k *Kenwood) squelchFull() int {
if k.elecraft {
return 29 // K3: 000-029
}
return 255
}
// scalePercent turns a raw value into 0-100 against its own full scale.
func scalePercent(v, full int) int {
if full <= 0 || v <= 0 {
return 0
}
if v >= full {
return 100
}
return v * 100 / full
}
// kenwoodAGCName decodes GT. The K3 answers 000 (off), 002 (slow) or 004
// (fast); a Kenwood uses the same three values for the same three states.
func kenwoodAGCName(v int) string {
switch {
case v == 0:
return "OFF"
case v <= 2:
return "SLOW"
default:
return "FAST"
}
}
// kenwoodAGCValue is the inverse, for the buttons.
func kenwoodAGCValue(name string) int {
switch strings.ToUpper(strings.TrimSpace(name)) {
case "OFF":
return 0
case "SLOW":
return 2
default:
return 4
}
}
// kenwoodMeterProbes are the candidate meter commands, asked once per
@@ -241,6 +386,91 @@ func (k *Kenwood) SetKenwoodAFGain(p int) error {
return k.setPanel(fmt.Sprintf("AG%03d;", p*255/100))
}
// SetKenwoodRFGain sets the RF gain, 0-100 of the rig's own scale.
func (k *Kenwood) SetKenwoodRFGain(p int) error {
return k.setPanel(fmt.Sprintf("RG%03d;", clampPercentTo(p, k.rfGainFull())))
}
func (k *Kenwood) SetKenwoodMicGain(p int) error {
return k.setPanel(fmt.Sprintf("MG%03d;", clampPercentTo(p, k.micGainFull())))
}
func (k *Kenwood) SetKenwoodSquelch(p int) error {
return k.setPanel(fmt.Sprintf("SQ%03d;", clampPercentTo(p, k.squelchFull())))
}
func (k *Kenwood) SetKenwoodPreamp(on bool) error {
return k.setPanel(fmt.Sprintf("PA%d;", boolDigit(on)))
}
// SetKenwoodAtt switches the attenuator. Two digits: the K3 reads RA01 as its
// single 10 dB pad, and a Kenwood with several steps takes the first one.
func (k *Kenwood) SetKenwoodAtt(on bool) error {
if on {
return k.setPanel("RA01;")
}
return k.setPanel("RA00;")
}
func (k *Kenwood) SetKenwoodNB(on bool) error {
return k.setPanel(fmt.Sprintf("NB%d;", boolDigit(on)))
}
func (k *Kenwood) SetKenwoodNR(on bool) error {
return k.setPanel(fmt.Sprintf("NR%d;", boolDigit(on)))
}
func (k *Kenwood) SetKenwoodAGC(name string) error {
return k.setPanel(fmt.Sprintf("GT%03d;", kenwoodAGCValue(name)))
}
// SetKenwoodFilter sets the DSP bandwidth in Hz — sent in the K3's 10 Hz units.
func (k *Kenwood) SetKenwoodFilter(hz int) error {
if hz < 50 {
hz = 50
}
if hz > 4000 {
hz = 4000
}
return k.setPanel(fmt.Sprintf("BW%04d;", hz/10))
}
// SetKenwoodAntenna selects antenna 1 or 2 (a K3 with the internal ATU).
func (k *Kenwood) SetKenwoodAntenna(n int) error {
if n < 1 {
n = 1
}
if n > 2 {
n = 2
}
return k.setPanel(fmt.Sprintf("AN%d;", n))
}
func (k *Kenwood) SetKenwoodRIT(on bool) error {
return k.setPanel(fmt.Sprintf("RT%d;", boolDigit(on)))
}
func (k *Kenwood) SetKenwoodXIT(on bool) error {
return k.setPanel(fmt.Sprintf("XT%d;", boolDigit(on)))
}
// ClearKenwoodRIT zeroes the RIT/XIT offset, both at once — which is what RC
// does and what the operator means by "clear it".
func (k *Kenwood) ClearKenwoodRIT() error {
return k.setPanel("RC;")
}
// clampPercentTo maps 0-100 onto a rig's own full scale.
func clampPercentTo(p, full int) int {
if p < 0 {
p = 0
}
if p > 100 {
p = 100
}
return p * full / 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