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 }