Compare commits

...
6 Commits
Author SHA1 Message Date
rouggy e4412955f0 chore: release v0.26.13 2026-08-24 22:00:15 +02:00
rouggy 78eb766789 style(flex): a little more room in the chaser's offset box
Narrowed to w-12 earlier, which is tight for a signed four-digit offset:
-1500 filled it edge to edge with no padding left.
2026-08-24 21:48:35 +02:00
rouggy d8064cc426 fix(udp): two copies of MSHV shared one dial frequency
FT8 decodes made on 14.095 were labelled 2190 m. That band is 135.7-137.8
kHz, and every affected decode's audio offset plus 136.1 kHz lands inside
it — while the ones shown with no band at all land just above 137.8. The
decodes were being stamped with another instance's dial.

WSJT-X refuses to start a second instance without --rig-name, so its ids
differ and keying on the id worked. MSHV has no such rule: both copies
call themselves 'MSHV', so the LF instance's Status overwrote the HF
one's dial, T/R period and mode.

Instances are now identified by their sending socket as well as their
name — the socket is stable for as long as the program runs, which is
exactly the lifetime this has to hold over. A second application claiming
an id already in use is shown as 'MSHV #2' and says so in the log, so the
decodes panel can still split them.

This also fixes replies: a Reply was addressed to whichever copy sent
Status last, which on a two-instance station is a coin toss.
2026-08-24 21:38:30 +02:00
rouggy bd4f1b2325 fix(decodes): new-band-mode had no badge, so its rows looked unfiltered
A station new on this band AND in this mode is one status, 'new-band-mode',
and the badge table has an entry for neither half. The lookup returned
nothing, so those rows showed only their grid or prefix badge — and with
NEW/BAND/MODE selected, a row whose visible badges were LOC and PFX read
as the filter letting through things nobody asked for. It was not: the
row belonged there and the panel failed to say why.

catsOf has always split that status into its two categories for filtering.
The badges now do the same on screen.
2026-08-24 21:21:02 +02:00
rouggy b38b9030f0 fix(elecraft): MOX would not unkey, and the TX meters never moved
Both are the same bug. ReadState returns early while transmitting — the
rig answers '?;' to IF; then, and treating that as a fault used to drop
the link — but that early return also skipped the panel update and the
meter read.

So the panel believed the radio was still receiving. Pressing MOX again
therefore sent ANOTHER transmit command instead of RX, and the K3 stayed
keyed, exactly as reported. And the power and SWR bars were only ever
read at the one moment they mean nothing.

Reading meters while transmitting needs one more guard: a '?;' then says
when the question was asked, not what the radio supports, so the
unsupported-command memory is suspended for that read. Without it, one
badly timed refusal would silence a meter for the rest of the session.

Adds RIT/XIT offset control (±10/±100 Hz, offset shown), a 4.0 kHz filter
button for FT8, and logs the K3's icon word when it changes: there is no
command for 'is the ATU in line', the reference says the switch functions
show up as icon changes, so an operator toggling the ATU while watching
the log will name the bit and the button can then light up honestly.
2026-08-24 21:06:21 +02:00
rouggy ea302966d5 chore(changelog): open the 0.26.13 block
v0.26.12 shipped with everything written for it — the block is empty
because nothing has been done since, not because entries were missed.
2026-08-24 20:12:00 +02:00
14 changed files with 307 additions and 29 deletions
+5
View File
@@ -80,6 +80,11 @@ func (a *App) SetKenwoodXIT(on bool) error {
return a.kenwoodPanelDo(func(k cat.KenwoodPanelController) error { return k.SetKenwoodXIT(on) }) 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 // 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". // radio's own RC does and what an operator means by "clear it".
func (a *App) ClearKenwoodRIT() error { func (a *App) ClearKenwoodRIT() error {
+18
View File
@@ -1,4 +1,22 @@
[ [
{
"version": "0.26.13",
"date": "",
"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.",
"FT decodes: a station that is new on both the band and the mode now shows both badges. That status had no badge of its own, so those rows passed the BAND and MODE filters and then displayed no reason for being in the list — only their grid or prefix badge, which looked like the filter leaking.",
"Two copies of MSHV are now told apart. MSHV calls every instance 'MSHV', and the dial frequency was filed under that name alone — so a second copy on another band overwrote the first, and decodes made on 14.095 came out labelled 2190 m. Instances are identified by their sending socket as well as their name, and a second one is shown as 'MSHV #2'."
],
"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.",
"Décodes FT : une station nouvelle à la fois sur la bande et dans le mode affiche maintenant les deux badges. Ce statut n'avait pas de badge à lui : ces lignes passaient les filtres BANDE et MODE puis n'affichaient aucune raison d'être là — seulement leur badge de locator ou de préfixe, ce qui donnait l'impression d'un filtre qui fuyait.",
"Deux copies de MSHV sont maintenant distinguées. MSHV nomme chaque instance « MSHV », et la fréquence d'affichage était rangée sous ce seul nom : une seconde copie sur une autre bande écrasait la première, et des décodes faits sur 14.095 ressortaient étiquetés 2190 m. Les instances sont désormais identifiées par leur socket autant que par leur nom, et la seconde s'affiche « MSHV #2 »."
]
},
{ {
"version": "0.26.12", "version": "0.26.12",
"date": "", "date": "",
+22 -6
View File
@@ -316,6 +316,22 @@ const ENTITY_BADGE: Record<string, { label: string; cls: string }> = {
'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' }, 'new-call': { label: 'dec.stCall', cls: 'bg-muted text-muted-foreground' },
}; };
// entityBadgesFor turns a status into the badges that describe it.
//
// A LIST, because "new-band-mode" is two facts at once — new on this band and
// new in this mode — and the map above has an entry for neither. It was read
// with a single lookup, which returned nothing for that status: those rows
// passed the BAND and MODE filters and then showed no reason for being there,
// which is precisely what was reported. catsOf has always split the status into
// its two categories; this is the same split, on the screen.
function entityBadgesFor(status: string): { label: string; cls: string }[] {
if (status === 'new-band-mode') {
return [ENTITY_BADGE['new-band'], ENTITY_BADGE['new-mode']];
}
const one = ENTITY_BADGE[status];
return one ? [one] : [];
}
// The orthogonal ones: a station already worked for its entity can still be a // The orthogonal ones: a station already worked for its entity can still be a
// new grid, a new prefix or a park never logged. // new grid, a new prefix or a park never logged.
// //
@@ -1004,10 +1020,10 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{g.decodes.map((d, i) => { {g.decodes.map((d, i) => {
const e = statusOf(d); const e = statusOf(d);
const st = e?.status && e.status !== 'worked' ? e.status : ''; const st = e?.status && e.status !== 'worked' ? e.status : '';
const entity = st ? ENTITY_BADGE[st] : undefined; const entities = st ? entityBadgesFor(st) : [];
const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]); const extras = EXTRA_BADGES.filter((b) => !!e?.[b.key]);
const mine = !!me && d.call === me; const mine = !!me && d.call === me;
const hot = !!entity || extras.length > 0; const hot = entities.length > 0 || extras.length > 0;
// Someone answering us outranks everything else on the screen. // Someone answering us outranks everything else on the screen.
const replying = answersMe(d.msg); const replying = answersMe(d.msg);
// The station we are calling, so it can be picked out of a slot // The station we are calling, so it can be picked out of a slot
@@ -1096,11 +1112,11 @@ export function DecodesPanel({ decodes, txMsgs, txState, txStates, spotStatus, o
{t('dec.wkd')} {t('dec.wkd')}
</span> </span>
)} )}
{entity && ( {entities.map((b) => (
<span className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', entity.cls)}> <span key={b.label} className={cn('rounded px-1 py-px text-[10px] font-bold uppercase tracking-wide shrink-0', b.cls)}>
{t(entity.label)} {t(b.label)}
</span> </span>
)} ))}
{extras.map((b) => { {extras.map((b) => {
// A square WORKED but not yet confirmed is a different job // A square WORKED but not yet confirmed is a different job
// from one never worked: a QSL to chase, not a QSO to make. // from one never worked: a QSL to chase, not a QSO to make.
+16 -4
View File
@@ -4,7 +4,7 @@ import {
GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU, GetKenwoodState, RefreshKenwood, SetKenwoodPower, SetKenwoodAFGain, SetKenwoodTX, TuneKenwoodATU,
SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt, SetKenwoodRFGain, SetKenwoodMicGain, SetKenwoodSquelch, SetKenwoodPreamp, SetKenwoodAtt,
SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna, SetKenwoodNB, SetKenwoodNR, SetKenwoodAGC, SetKenwoodFilter, SetKenwoodAntenna,
SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, SetKenwoodRIT, SetKenwoodXIT, ClearKenwoodRIT, SetKenwoodKeySpeed, ToggleKenwoodATU, NudgeKenwoodRIT,
GetCATState, GetCATState,
} from '../../wailsjs/go/main/App'; } from '../../wailsjs/go/main/App';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
@@ -20,7 +20,7 @@ type KenwoodState = {
power_meter: number; swr: number; swr_raw: number; power_meter: number; swr: number; swr_raw: number;
rf_power: number; af_gain: number; rf_gain: number; mic_gain: number; squelch: 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; 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; 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, 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, rf_power: 0, af_gain: 0, rf_gain: 0, mic_gain: 0, squelch: 0,
preamp: false, att: false, nb: false, nr: false, 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, meters_provisional: true,
}; };
// Filter widths worth a button. CW work happens at 200-500 Hz, SSB at 2.4-2.8 // 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 // 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. // 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 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 // 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) =>
<div className="flex flex-wrap items-center gap-1.5"> <div className="flex flex-wrap items-center gap-1.5">
<Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} /> <Toggle label="RIT" on={view.rit} off={off} onClick={() => SetKenwoodRIT(!view.rit).catch(setErrMsg)} />
<Toggle label="XIT" on={view.xit} off={off} onClick={() => SetKenwoodXIT(!view.xit).catch(setErrMsg)} /> <Toggle label="XIT" on={view.xit} off={off} onClick={() => 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) => (
<Toggle key={d} label={(d > 0 ? '+' : '') + d} on={false} off={off}
onClick={() => NudgeKenwoodRIT(d).catch(setErrMsg)} />
))}
<span className="text-[11px] font-mono tabular-nums text-muted-foreground w-16">
{view.rit_offset > 0 ? '+' : ''}{view.rit_offset || 0} Hz
</span>
<Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} /> <Toggle label={t('k3.clear')} on={false} off={off} onClick={() => ClearKenwoodRIT().catch(setErrMsg)} />
{/* Antenna only when the radio answered AN — a K3 without the internal {/* Antenna only when the radio answered AN — a K3 without the internal
ATU has one socket and no switch to offer. */} ATU has one socket and no switch to offer. */}
+1 -1
View File
@@ -725,7 +725,7 @@ export function FlexPanel({ onCWSpeed, onReportRST }: { onCWSpeed?: (wpm: number
const next = { ...rstChase, offset_hz: v }; const next = { ...rstChase, offset_hz: v };
setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {}); setRstChase(next); SaveFlexRSTChase(next as any).catch(() => {});
}} }}
className="w-12 h-6 rounded border border-input bg-background px-1 text-[11px] font-mono" /> className="w-16 h-6 rounded border border-input bg-background px-1.5 text-[11px] font-mono" />
Hz Hz
</label> </label>
)} )}
+1 -1
View File
@@ -1,6 +1,6 @@
// Single source of truth for the app version shown in the UI (header + About). // Single source of truth for the app version shown in the UI (header + About).
// Bump this on a release (the release script updates it alongside telemetry.go). // Bump this on a release (the release script updates it alongside telemetry.go).
export const APP_VERSION = '0.26.12'; export const APP_VERSION = '0.26.13';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+2
View File
@@ -803,6 +803,8 @@ export function NetSetDefaults(arg1:string,arg2:string,arg3:string,arg4:string):
export function NetUpdateActive(arg1:qso.QSO):Promise<void>; export function NetUpdateActive(arg1:qso.QSO):Promise<void>;
export function NudgeKenwoodRIT(arg1:number):Promise<void>;
export function OpenADIFFile():Promise<string>; export function OpenADIFFile():Promise<string>;
export function OpenAwardsFolder():Promise<void>; export function OpenAwardsFolder():Promise<void>;
+4
View File
@@ -1546,6 +1546,10 @@ export function NetUpdateActive(arg1) {
return window['go']['main']['App']['NetUpdateActive'](arg1); return window['go']['main']['App']['NetUpdateActive'](arg1);
} }
export function NudgeKenwoodRIT(arg1) {
return window['go']['main']['App']['NudgeKenwoodRIT'](arg1);
}
export function OpenADIFFile() { export function OpenADIFFile() {
return window['go']['main']['App']['OpenADIFFile'](); return window['go']['main']['App']['OpenADIFFile']();
} }
+2
View File
@@ -1094,6 +1094,7 @@ export namespace cat {
antenna: number; antenna: number;
rit: boolean; rit: boolean;
xit: boolean; xit: boolean;
rit_offset: number;
key_speed: number; key_speed: number;
meters_provisional: boolean; meters_provisional: boolean;
@@ -1129,6 +1130,7 @@ export namespace cat {
this.antenna = source["antenna"]; this.antenna = source["antenna"];
this.rit = source["rit"]; this.rit = source["rit"];
this.xit = source["xit"]; this.xit = source["xit"];
this.rit_offset = source["rit_offset"];
this.key_speed = source["key_speed"]; this.key_speed = source["key_speed"];
this.meters_provisional = source["meters_provisional"]; this.meters_provisional = source["meters_provisional"];
} }
+22 -1
View File
@@ -91,11 +91,18 @@ type Kenwood struct {
keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go) keyWPM int // CW keyer speed, for pacing the KY buffer (see kenwood_cw.go)
// Commands this rig answered "?;" to — asked once, then never again. // Commands this rig answered "?;" to — asked once, then never again.
unsupported map[string]bool 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 // 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 // same serialised link as everything else, on a slow beat for the settings
// and every poll for the meters. // and every poll for the meters.
panel KenwoodTXState 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 panelCycle int
panelLoaded bool panelLoaded bool
metersLogged int 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 { if k.tx && !k.txAt.IsZero() && time.Since(k.txAt) < 30*time.Second {
s := k.lastState s := k.lastState
s.Connected = true 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 return s, nil
} }
raw, err := k.ask("IF;") 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 // NOT "unsupported". Latching them off would blind the poll loop for
// good and read as "lost the rig". Only remember the OPTIONAL commands // 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. // (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 k.unsupported[want] = true
debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd) debugLog.Printf("kenwood: this rig does not support %q — not asking again", cmd)
} }
+96
View File
@@ -70,6 +70,11 @@ type KenwoodTXState struct {
RIT bool `json:"rit"` RIT bool `json:"rit"`
XIT bool `json:"xit"` 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 KeySpeed int `json:"key_speed"` // WPM
// MetersProvisional says the meter scaling has not been confirmed against a // MetersProvisional says the meter scaling has not been confirmed against a
@@ -97,6 +102,7 @@ type KenwoodPanelController interface {
SetKenwoodAntenna(int) error SetKenwoodAntenna(int) error
SetKenwoodRIT(bool) error SetKenwoodRIT(bool) error
SetKenwoodXIT(bool) error SetKenwoodXIT(bool) error
NudgeKenwoodRIT(int) error
ClearKenwoodRIT() error ClearKenwoodRIT() error
SetKenwoodTX(bool) error SetKenwoodTX(bool) error
TuneKenwoodATU() error TuneKenwoodATU() error
@@ -222,9 +228,16 @@ func (k *Kenwood) readPanelSettings() {
if v, ok := k.askNum("XT;", "XT", 1); ok { if v, ok := k.askNum("XT;", "XT", 1); ok {
k.panel.XIT = v != 0 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 { if v, ok := k.askNum("KS;", "KS", 3); ok {
k.panel.KeySpeed = v k.panel.KeySpeed = v
} }
k.probeIcons()
} }
// The full-scale value of the analogue controls differs between an Elecraft and // 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. // readTXMeters reads the transmit meters.
func (k *Kenwood) readTXMeters() { func (k *Kenwood) readTXMeters() {
now := time.Now() 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 { if v, ok := k.askNum("BG;", "BG", 2); ok {
k.panel.PowerMeter = k.powerPeak.update(kenwoodBargraphPercent(v), now) 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)) 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. // SetKenwoodRFGain sets the RF gain, 0-100 of the rig's own scale.
func (k *Kenwood) SetKenwoodRFGain(p int) error { func (k *Kenwood) SetKenwoodRFGain(p int) error {
return k.setPanel(fmt.Sprintf("RG%03d;", clampPercentTo(p, k.rfGainFull()))) 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)) 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 { func (k *Kenwood) SetKenwoodRIT(on bool) error {
return k.setPanel(fmt.Sprintf("RT%d;", boolDigit(on))) return k.setPanel(fmt.Sprintf("RT%d;", boolDigit(on)))
} }
@@ -0,0 +1,52 @@
package udp
import (
"net"
"testing"
)
// Two copies of MSHV call themselves "MSHV". WSJT-X refuses to start a second
// instance without --rig-name, so its ids differ; MSHV has no such rule, and
// everything a decode needs — the dial frequency, the T/R period, the mode —
// used to be filed under the id alone.
//
// The consequence was reported from a real station: the second copy, on LF,
// overwrote the dial of the first, and FT8 decodes made on 14.095 came out
// labelled 2190 m — which is 136 kHz plus an audio offset, to the hertz.
func TestSameProgramIDFromTwoAddressesAreTwoInstances(t *testing.T) {
s := &Server{}
a := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237}
b := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2238}
first := s.instanceLabel("MSHV", a)
second := s.instanceLabel("MSHV", b)
if first == second {
t.Fatalf("two applications on different sockets share the label %q", first)
}
if first != "MSHV" {
t.Fatalf("the first instance should keep the plain id, got %q", first)
}
// Stable: the same socket must keep its name for as long as it runs, or the
// decodes panel would grow a new column every time a packet arrived.
if again := s.instanceLabel("MSHV", a); again != first {
t.Fatalf("label changed for the same address: %q then %q", first, again)
}
if again := s.instanceLabel("MSHV", b); again != second {
t.Fatalf("label changed for the same address: %q then %q", second, again)
}
}
// Distinct ids stay distinct without decoration — a station running WSJT-X and
// MSHV should not see either renamed.
func TestDifferentProgramIDsAreLeftAlone(t *testing.T) {
s := &Server{}
a := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2237}
b := &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 2238}
if got := s.instanceLabel("WSJT-X", a); got != "WSJT-X" {
t.Fatalf("WSJT-X was renamed to %q", got)
}
if got := s.instanceLabel("MSHV", b); got != "MSHV" {
t.Fatalf("MSHV was renamed to %q", got)
}
}
+61 -11
View File
@@ -212,6 +212,21 @@ type Server struct {
// lastFrom is the address each program's packets arrive from — where a Reply // lastFrom is the address each program's packets arrive from — where a Reply
// has to be sent. See SendReply. // has to be sent. See SendReply.
lastFrom map[string]*net.UDPAddr lastFrom map[string]*net.UDPAddr
// instLabel names each running application, keyed by id AND sending address.
//
// WSJT-X requires --rig-name for a second instance, so its ids differ. MSHV
// does not: two copies both call themselves "MSHV", and everything below was
// keyed on that name alone — so the second copy's Status overwrote the
// first's dial frequency, and decodes from the 20 m instance were placed at
// the LF instance's frequency. Reported as FT8 decodes on 14.095 labelled
// 2190 m, which is exactly 136 kHz plus an audio offset.
//
// The label is what the rest of OpsLog sees: the plain id for the first
// instance, then "MSHV #2", "MSHV #3"… so the decodes panel can still tell
// them apart on screen.
instLabel map[string]string
// instSeq counts how many distinct instances have claimed each id.
instSeq map[string]int
// lastMode is the mode NAME from each program's last Status, used to resolve // lastMode is the mode NAME from each program's last Status, used to resolve
// a Decode's one-character mode marker. // a Decode's one-character mode marker.
lastMode map[string]string lastMode map[string]string
@@ -374,6 +389,40 @@ func (s *Server) run() {
} }
} }
// instanceLabel returns the name for one running application, allocating it on
// first sight. Caller holds s.mu.
//
// The address is part of the identity because the id is not enough: two copies
// of MSHV send the same id from different sockets, and the two are different
// radios on different bands. The port is included — a program keeps its socket
// for as long as it runs, which is exactly the lifetime this has to be stable
// over.
func (s *Server) instanceLabel(id string, remote *net.UDPAddr) string {
if id == "" {
return ""
}
if remote == nil {
return id
}
key := id + "|" + remote.String()
if s.instLabel == nil {
s.instLabel = map[string]string{}
s.instSeq = map[string]int{}
}
if lbl, ok := s.instLabel[key]; ok {
return lbl
}
s.instSeq[id]++
lbl := id
if n := s.instSeq[id]; n > 1 {
lbl = fmt.Sprintf("%s #%d", id, n)
applog.Printf("udp: [%s] a second application calls itself %q (from %s) — it will be shown as %q",
s.cfg.Name, id, remote, lbl)
}
s.instLabel[key] = lbl
return lbl
}
// logBadPacket reports a datagram this listener could not parse, with enough of // logBadPacket reports a datagram this listener could not parse, with enough of
// it to identify the sender — then falls silent. // it to identify the sender — then falls silent.
// //
@@ -464,14 +513,15 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
// to it. Per PROGRAM, not per listener: two receivers share one multicast // to it. Per PROGRAM, not per listener: two receivers share one multicast
// group, and a reply must reach the one that heard the station — and it // group, and a reply must reach the one that heard the station — and it
// must go to the sender's own address, never to the group. // must go to the sender's own address, never to the group.
if w.ProgramID != "" && remote != nil {
s.mu.Lock() s.mu.Lock()
inst := s.instanceLabel(w.ProgramID, remote)
if inst != "" && remote != nil {
if s.lastFrom == nil { if s.lastFrom == nil {
s.lastFrom = map[string]*net.UDPAddr{} s.lastFrom = map[string]*net.UDPAddr{}
} }
s.lastFrom[w.ProgramID] = remote s.lastFrom[inst] = remote
s.mu.Unlock()
} }
s.mu.Unlock()
// Status carries the current dial frequency; remember it so Decode audio // Status carries the current dial frequency; remember it so Decode audio
// offsets can be turned into RF frequencies for the panadapter. // offsets can be turned into RF frequencies for the panadapter.
if w.FreqHz > 0 && !w.IsDecode { if w.FreqHz > 0 && !w.IsDecode {
@@ -479,7 +529,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.dialHz == nil { if s.dialHz == nil {
s.dialHz = map[string]int64{} s.dialHz = map[string]int64{}
} }
s.dialHz[w.ProgramID] = w.FreqHz s.dialHz[inst] = w.FreqHz
// The T/R period travels with Status, and a decode has to be told // The T/R period travels with Status, and a decode has to be told
// which slot it belongs to — so it is remembered per program the // which slot it belongs to — so it is remembered per program the
// same way the dial is. // same way the dial is.
@@ -487,7 +537,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.trPeriod == nil { if s.trPeriod == nil {
s.trPeriod = map[string]int{} s.trPeriod = map[string]int{}
} }
s.trPeriod[w.ProgramID] = w.TRPeriod s.trPeriod[inst] = w.TRPeriod
} }
// The mode NAME, which only Status carries: a Decode gives the // The mode NAME, which only Status carries: a Decode gives the
// one-character marker instead. See DecodeModeName. // one-character marker instead. See DecodeModeName.
@@ -495,7 +545,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
if s.lastMode == nil { if s.lastMode == nil {
s.lastMode = map[string]string{} s.lastMode = map[string]string{}
} }
s.lastMode[w.ProgramID] = w.Mode s.lastMode[inst] = w.Mode
} }
s.mu.Unlock() s.mu.Unlock()
} }
@@ -507,13 +557,13 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.TxMessage = w.TxMessage ev.TxMessage = w.TxMessage
ev.Transmitting = w.Transmitting ev.Transmitting = w.Transmitting
ev.DECall = w.DECall ev.DECall = w.DECall
ev.ProgramID = w.ProgramID ev.ProgramID = inst
} }
if w.IsDecode { if w.IsDecode {
s.mu.Lock() s.mu.Lock()
dial := s.dialHz[w.ProgramID] dial := s.dialHz[inst]
tr := s.trPeriod[w.ProgramID] tr := s.trPeriod[inst]
statusMode := s.lastMode[w.ProgramID] statusMode := s.lastMode[inst]
s.mu.Unlock() s.mu.Unlock()
if dial <= 0 { if dial <= 0 {
// No Status from THIS instance yet. Guessing with another // No Status from THIS instance yet. Guessing with another
@@ -534,7 +584,7 @@ func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
ev.DecodeTRPeriod = tr ev.DecodeTRPeriod = tr
ev.DecodeDial = dial ev.DecodeDial = dial
ev.DecodeOffAir = w.OffAir ev.DecodeOffAir = w.OffAir
ev.ProgramID = w.ProgramID ev.ProgramID = inst
ev.DecodeDT = w.DeltaTime ev.DecodeDT = w.DeltaTime
ev.DecodeAudioHz = w.DeltaFreqHz ev.DecodeAudioHz = w.DeltaFreqHz
ev.DecodeMs = w.DecodeMsSinceMidnight ev.DecodeMs = w.DecodeMsSinceMidnight
+1 -1
View File
@@ -21,7 +21,7 @@ import (
const ( const (
// appVersion is stamped on every heartbeat (and could feed the About box). // appVersion is stamped on every heartbeat (and could feed the About box).
appVersion = "0.26.12" appVersion = "0.26.13"
// posthogHost is the PostHog ingestion endpoint. EU cloud by default; change // posthogHost is the PostHog ingestion endpoint. EU cloud by default; change
// to https://us.i.posthog.com for a US project. // to https://us.i.posthog.com for a US project.