diff --git a/app_k3.go b/app_k3.go index c75378a..796e569 100644 --- a/app_k3.go +++ b/app_k3.go @@ -80,6 +80,11 @@ func (a *App) SetKenwoodXIT(on bool) error { return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) }) } +// NudgeKenwoodRIT moves the RIT/XIT offset by delta Hz. +func (a *App) NudgeKenwoodRIT(delta int) error { + return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.NudgeKenwoodRIT(delta) }) +} + // ClearKenwoodRIT zeroes the RIT and XIT offsets together, which is what the // radio's own RC does and what an operator means by "clear it". func (a *App) ClearKenwoodRIT() error { diff --git a/changelog.json b/changelog.json index bf36fdb..5cff80d 100644 --- a/changelog.json +++ b/changelog.json @@ -2,8 +2,16 @@ { "version": "0.26.13", "date": "", - "en": [], - "fr": [] + "en": [ + "Elecraft console: MOX unkeys again, and the power and SWR bars move while transmitting. The poll stops while the carrier is up — the rig refuses most questions then — and the panel was left believing the radio was still receiving, so pressing MOX a second time sent another transmit command instead of stopping.", + "Elecraft console: RIT and XIT can be moved — ±10 and ±100 Hz buttons with the offset shown beside them. A lit RIT button says the feature is on and nothing about where it has put the receiver.", + "Elecraft console: a 4.0 kHz filter button, the K3 maximum and the one FT8 wants." + ], + "fr": [ + "Console Elecraft : MOX repasse bien en réception, et les barres de puissance et de ROS bougent pendant l'émission. L'interrogation s'arrête quand la porteuse est levée — la radio refuse alors la plupart des questions — et le panneau croyait donc la radio toujours en réception : un second appui sur MOX envoyait une nouvelle commande d'émission au lieu d'arrêter.", + "Console Elecraft : le RIT et le XIT se règlent — boutons ±10 et ±100 Hz avec le décalage affiché à côté. Un bouton RIT allumé dit que la fonction est active et rien sur l'endroit où elle a mis le récepteur.", + "Console Elecraft : un bouton de filtre à 4,0 kHz, le maximum du K3 et celui qu'il faut pour le FT8." + ] }, { "version": "0.26.12", diff --git a/frontend/src/components/ElecraftPanel.tsx b/frontend/src/components/ElecraftPanel.tsx index 3b4f0b5..9d83b79 100644 --- a/frontend/src/components/ElecraftPanel.tsx +++ b/frontend/src/components/ElecraftPanel.tsx @@ -4,7 +4,7 @@ import { GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU, SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt, SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna, - SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, + SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT, GetCATState, } from '../../wailsjs/go/main/App'; import { cn } from '@/lib/utils'; @@ -20,7 +20,7 @@ type KenwoodState = { power_meter: number; swr: number; swr_raw: number; rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: number; preamp: boolean; att: boolean; nb: boolean; nr: boolean; agc?: string; - filter_hz: number; antenna: number; rit: boolean; xit: boolean; key_speed: number; + filter_hz: number; antenna: number; rit: boolean; xit: boolean; rit_offset: number; key_speed: number; meters_provisional: boolean; }; @@ -29,14 +29,17 @@ const ZERO: KenwoodState = { s_meter: 0, s_meter_raw: 0, power_meter: 0, swr: 0, swr_raw: 0, rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0, preamp: false, att: false, nb: false, nr: false, - filter_hz: 0, antenna: 0, rit: false, xit: false, key_speed: 0, + filter_hz: 0, antenna: 0, rit: false, xit: false, rit_offset: 0, key_speed: 0, meters_provisional: true, }; // Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8 // kHz; the rest of the range is reachable from the radio's own knob, and a // panel offering thirty widths is slower to use than the knob it replaces. -const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800]; +// 4.0k is the K3 maximum and the one FT8 wants: a 2.8 kHz filter clips the +// top of the FT8 sub-band, and the decodes that go missing are the ones +// nobody notices are missing. +const FILTERS = [200, 400, 700, 1000, 1800, 2400, 2800, 4000]; // Raw S-meter → S units. The K3 answers 0-21 across S0…S9+60; S9 is taken at // raw 9 and each step above it as 6 dB. PROVISIONAL, like the rest of the @@ -281,6 +284,15 @@ export function ElecraftPanel({ onReportRST }: { onReportRST?: (rst: string) =>
SetKenwoodRIT(!view.rit).catch(setErrMsg)} /> SetKenwoodXIT(!view.xit).catch(setErrMsg)} /> + {/* The offset itself. A lit RIT button says the feature is on and + nothing about where it has put the receiver. */} + {[-100, -10, 10, 100].map((d) => ( + 0 ? '+' : '') + d} on={false} off={off} + onClick={() => NudgeKenwoodRIT(d).catch(setErrMsg)} /> + ))} + + {view.rit_offset > 0 ? '+' : ''}{view.rit_offset || 0} Hz + ClearKenwoodRIT().catch(setErrMsg)} /> {/* Antenna only when the radio answered AN — a K3 without the internal ATU has one socket and no switch to offer. */} diff --git a/frontend/wailsjs/go/main/App.d.ts b/frontend/wailsjs/go/main/App.d.ts index 72eaefe..4591844 100644 --- a/frontend/wailsjs/go/main/App.d.ts +++ b/frontend/wailsjs/go/main/App.d.ts @@ -803,6 +803,8 @@ export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string): export function NetUpdateActive(arg1:qso.QSO):Promise; +export function NudgeKenwoodRIT(arg1:number):Promise; + export function OpenADIFFile():Promise; export function OpenAwardsFolder():Promise; diff --git a/frontend/wailsjs/go/main/App.js b/frontend/wailsjs/go/main/App.js index ecb3558..c2e0c66 100644 --- a/frontend/wailsjs/go/main/App.js +++ b/frontend/wailsjs/go/main/App.js @@ -1546,6 +1546,10 @@ export function NetUpdateActive(arg1) { return window['go']['main']['App']['NetUpdateActive'](arg1); } +export function NudgeKenwoodRIT(arg1) { + return window['go']['main']['App']['NudgeKenwoodRIT'](arg1); +} + export function OpenADIFFile() { return window['go']['main']['App']['OpenADIFFile'](); } diff --git a/frontend/wailsjs/go/models.ts b/frontend/wailsjs/go/models.ts index 1b3db30..21ec84e 100644 --- a/frontend/wailsjs/go/models.ts +++ b/frontend/wailsjs/go/models.ts @@ -1094,6 +1094,7 @@ export namespace cat { antenna: number; rit: boolean; xit: boolean; + rit_offset: number; key_speed: number; meters_provisional: boolean; @@ -1129,6 +1130,7 @@ export namespace cat { this.antenna = source["antenna"]; this.rit = source["rit"]; this.xit = source["xit"]; + this.rit_offset = source["rit_offset"]; this.key_speed = source["key_speed"]; this.meters_provisional = source["meters_provisional"]; } diff --git a/internal/cat/kenwood.go b/internal/cat/kenwood.go index 5af8b84..138dab1 100644 --- a/internal/cat/kenwood.go +++ b/internal/cat/kenwood.go @@ -91,11 +91,18 @@ type Kenwood struct { keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go) // Commands this rig answered "?;" to — asked once, then never again. unsupported map[string]bool + // noLatch suspends that memory while a refusal is expected to be about + // timing rather than capability (see ask). + noLatch bool // Panel state — the K3/K4 control panel, see kenwood_panel.go. Read on the // same serialised link as everything else, on a slow beat for the settings // and every poll for the meters. panel KenwoodTXState + // The icon/status word, for working out which bit says "ATU in line" — see + // probeIcons. Kept so only CHANGES are logged. + lastIcons string + iconProbes int panelCycle int panelLoaded bool metersLogged int @@ -309,6 +316,15 @@ func (k *Kenwood) ReadState() (RigState, error) { if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second { s := k.lastState s.Connected = true + // The panel still has to be told the radio is transmitting, and the + // transmit meters still have to be read — this early return used to skip + // both. The panel therefore showed MOX unlit while the rig was keyed, so + // pressing it again sent ANOTHER transmit command instead of unkeying, + // and the K3 stayed in TX. The power and SWR bars never moved either, + // for the same reason: the only moment they mean anything is the one + // moment they were not being read. + k.panel.Transmitting = true + k.readTXMeters() return s, nil } raw, err := k.ask("IF;") @@ -663,7 +679,12 @@ func (k *Kenwood) ask(cmd string) (string, error) { // NOT "unsupported". Latching them off would blind the poll loop for // good and read as "lost the rig". Only remember the OPTIONAL commands // (FR/FT/…) so the poll loop stops paying a 600 ms timeout for those. - if want != "IF" && want != "ID" { + // While TRANSMITTING the rig refuses a great deal that it answers + // perfectly well on receive, so a "?;" then says nothing about what + // the radio supports. Latching it would silence a meter for the rest + // of the session on the strength of one badly timed question — and + // the meters are read precisely while transmitting. + if want != "IF" && want != "ID" && !k.noLatch { k.unsupported[want] = true debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd) } diff --git a/internal/cat/kenwood_panel.go b/internal/cat/kenwood_panel.go index fa1a6f0..1278de6 100644 --- a/internal/cat/kenwood_panel.go +++ b/internal/cat/kenwood_panel.go @@ -68,9 +68,14 @@ type KenwoodTXState struct { // offer a switch that goes nowhere. Antenna int `json:"antenna"` - RIT bool `json:"rit"` - XIT bool `json:"xit"` - KeySpeed int `json:"key_speed"` // WPM + 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 @@ -97,6 +102,7 @@ type KenwoodPanelController interface { SetKenwoodAntenna(int) error SetKenwoodRIT(bool) error SetKenwoodXIT(bool) error + NudgeKenwoodRIT(int) error ClearKenwoodRIT() error SetKenwoodTX(bool) error TuneKenwoodATU() error @@ -222,9 +228,16 @@ func (k *Kenwood) readPanelSettings() { 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 @@ -310,6 +323,10 @@ var kenwoodMeterProbes = []struct { // 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) } @@ -396,6 +413,31 @@ func (k *Kenwood) SetKenwoodAFGain(p int) error { 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()))) @@ -456,6 +498,60 @@ func (k *Kenwood) SetKenwoodAntenna(n int) error { 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))) }