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. // // SWR is settled: SW; answers three digits in tenths of a ratio ("SW023;" = // 2.3:1), from Elecraft's release note. The power meter is still read from the // bargraph and still 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 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"` // RITOffset is the RIT/XIT offset in Hz — the number the buttons move and // the one an operator is actually reading when they look at RIT at all. A // lit RIT button with no offset beside it says the feature is on and // nothing about where it has put the receiver. RITOffset int `json:"rit_offset"` 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 // 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 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 NudgeKenwoodRIT(int) error ClearKenwoodRIT() error SetKenwoodTX(bool) error TuneKenwoodATU() error ToggleKenwoodATU() 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, txNow bool) { k.panel.Mode = mode k.panel.Split = split k.panel.SplitTXHz = 0 if split { k.panel.SplitTXHz = txHz } k.panel.Transmitting = k.tx || txNow 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) } // 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 } // RO carries a sign, so it is read as a whole rather than through askNum. if r, err := k.ask("RO;"); err == nil { if hz, ok := parseKenwoodOffset(r); ok { k.panel.RITOffset = hz } } if v, ok := k.askNum("KS;", "KS", 3); ok { k.panel.KeySpeed = v } k.probeIcons() } // 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 // 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. // SW is no longer among them: it is known, read above, and asking again during // a transmission costs a round trip on the one link the carrier depends on. var kenwoodMeterProbes = []string{"SM;", "SMH;", "BG;", "PO;", "TQ;"} // readTXMeters reads the transmit meters. func (k *Kenwood) readTXMeters() { now := time.Now() // Refusals here are about WHEN the question was asked, not about what the // radio can do: a K3 says "?;" to plenty while the carrier is up. k.noLatch = true defer func() { k.noLatch = false }() if v, ok := k.askNum("BG;", "BG", 2); ok { k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now) } // SW; — SETTLED, from Elecraft's own release note: three digits, tenths of a // ratio. "SW023;" is 2.3:1, and "SW999;" is the 99.9:1 it reports instead of // infinity. This was reading FOUR digits, so every answer failed to parse // and the bar stayed empty — which is why a tester saw no SWR at all. if v, ok := k.askNum("SW;", "SW", 3); ok { k.panel.SWRRaw = v if v > 0 { k.panel.SWR = float64(k.swrPeak.update(v, now)) / 10 } } if k.metersLogged >= 20 { return } k.metersLogged++ // The RAW answers, not parsed numbers: a command that answers in a shape we // did not expect is the interesting case, and a parsed "-" hides it behind // the same dash as a command the radio refused. raw := make([]string, 0, len(kenwoodMeterProbes)) for _, cmd := range kenwoodMeterProbes { if r, err := k.ask(cmd); err == nil { raw = append(raw, strings.TrimSuffix(cmd, ";")+"→"+strings.TrimSuffix(r, ";")) } else { raw = append(raw, strings.TrimSuffix(cmd, ";")+"→refused") } } 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. // // A K3 takes PC000-110: 110 rather than 100 because the radio will make a // little over its rated output and the reference says so. A Kenwood of the // TS-890 sort goes to 200, so the ceiling follows the radio rather than being // one number that is wrong for one of them. func (k *Kenwood) SetKenwoodPower(w int) error { max := 200 if k.elecraft { max = 110 } if w < 0 { w = 0 } if w > max { w = max } 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)) } // probeIcons logs the K3's icon/status word whenever it changes. // // There is no command that asks "is the ATU in line": the reference says the // front-panel switch functions show up as icon changes readable through IC (or // DS), which means the answer is a bit in a word nobody here can name yet. So // the word is logged when it changes, and an operator toggling the ATU while // watching the log hands us the bit — after which the ATU button can light up // honestly instead of guessing from what it last sent. func (k *Kenwood) probeIcons() { if k.iconProbes >= 40 { return } r, err := k.ask("IC;") if err != nil { k.iconProbes = 40 // this rig has no IC; stop asking return } if r == k.lastIcons { return } k.lastIcons = r k.iconProbes++ debugLog.Printf("kenwood: icon status changed → %s (toggle ATU/PRE/ATT while watching this line to name the bits)", r) } // 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)) } // parseKenwoodOffset reads "RO+0100;" / "RO-0250;" into Hz. func parseKenwoodOffset(frame string) (int, bool) { body := strings.TrimSuffix(strings.TrimPrefix(frame, "RO"), ";") if len(body) < 2 { return 0, false } sign := 1 switch body[0] { case '-': sign = -1 body = body[1:] case '+': body = body[1:] } n, err := strconv.Atoi(strings.TrimSpace(body)) if err != nil { return 0, false } return sign * n, true } // NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz. // // The offset is SET rather than stepped, because RO is where the radio keeps // it and stepping commands differ across the family. The panel's own reading is // the starting point, so two quick presses do not both start from the same // stale value. func (k *Kenwood) NudgeKenwoodRIT(delta int) error { k.mu.Lock() cur := k.panel.RITOffset k.mu.Unlock() next := cur + delta // A pile-up is chased with a few hundred hertz of RIT; ±5 kHz is already // past anything an offset is for, and past what the rig accepts. if next > 5000 { next = 5000 } if next < -5000 { next = -5000 } sign := "+" if next < 0 { sign = "-" } mag := next if mag < 0 { mag = -mag } k.mu.Lock() k.panel.RITOffset = next // optimistic, like the sliders k.mu.Unlock() return k.setPanel(fmt.Sprintf("RO%s%04d;", sign, mag)) } 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 // 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) } // The K3 has no dedicated "tune" command: the reference exposes the front panel // instead, through SWT (tap) and SWH (hold) with a switch number from Table 7. // Switch 19 is the ATU row — TAP starts a tuning cycle, HOLD puts the tuner in // or out of line. // // It was written as SWT20 first, from memory of the reference rather than from // the table, and 20 is not an ATU switch at all. Corrected against Table 7 of // the K3 Programmer's Reference, which an operator read out for us. Every send // is still logged: a switch-emulation command presses a real button on a real // front panel, so what OpsLog sent must be recoverable afterwards. const ( kenwoodATUTune = "SWT19;" // tap ATU TUNE — start a tuning cycle kenwoodATUToggle = "SWH19;" // hold ATU — tuner in line / bypassed ) // TuneKenwoodATU starts an ATU tuning cycle. func (k *Kenwood) TuneKenwoodATU() error { debugLog.Printf("kenwood: ATU tune — sending %q (K3 Table 7: tap of the ATU TUNE switch)", kenwoodATUTune) return k.setPanel(kenwoodATUTune) } // ToggleKenwoodATU puts the tuner in line or bypasses it — the HOLD of the same // switch, which is how it is done on the radio itself. func (k *Kenwood) ToggleKenwoodATU() error { debugLog.Printf("kenwood: ATU in/out — sending %q (K3 Table 7: hold of the ATU switch)", kenwoodATUToggle) return k.setPanel(kenwoodATUToggle) } // 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 }