Compare commits

..
7 Commits
Author SHA1 Message Date
rouggy 99d903eb44 fix(pgxl): stack the amplifier meters two by two
The card is two grid columns wide and sits beside a tall neighbour in Station
Control, so a single row of four bars left the height unused and squeezed each
bar into a quarter of the width. Two rows of two fill the room already there
and double the resolution of every bar.
2026-08-12 07:50:11 +02:00
rouggy 13515c58c0 fix(pgxl): draw the amplifier card's meters from the amp when there is no Flex
The PowerGenius XL card gated its whole meter row on flex.amp_available, so
on any station without a FlexRadio it rendered OPERATE and the fan selector
over an otherwise empty two-column card — reported from a TS-590 station.

Nothing was missing from the backend. internal/powergenius already parses
forward power, drain current, VSWR and temperature out of the GSCP status
frame, and the docked AmpWidget already prefers the radio's meter stream and
falls back to those. Only the full card never learnt the fallback.

Same source preference as the widget, and the same two exclusions: peakfwd
and peakid are latched maxima that are never reset and survive in the
last-known status after the amp disconnects, so the plain readings are used
and gated on the transmit state instead of freezing on the last over.
2026-08-12 07:40:45 +02:00
rouggy e4014e11d2 fix(kenwood): record a frequency set made while transmitting
WSJT-X "Fake It" shifts the dial for the duration of each over and puts it
back afterwards, but the restore is conditional: it reads the frequency back
and only moves the dial if it disagrees with where it believes the radio
should be.

That read lands inside the window where this backend stops polling on
purpose. A Kenwood answers "?;" to IF; while it is transmitting, and treating
that as a fault used to drop the shared CAT link entirely, so the cached
state answers instead. SetFrequency wrote the command to the rig without
touching that cache — so mid-over the cache still described the pre-over
dial, WSJT-X read its own receive frequency back, concluded there was nothing
to restore, and the radio stayed on the transmit frequency. Every later over
started from there.

Reported from a session where the dial stuck at 7075500 after a full FT8
over while a bare TUNE, which never sets a frequency, worked fine.

Only simplex updates the cache. Under split, FreqHz is the transmit frequency
while the write lands on whichever VFO the operator is on, and guessing which
side moved would put a wrong number in front of the operator — a stale one
survives until the next poll.

The test reproduces the reported sequence and fails without the fix with the
same frequency the log shows.
2026-08-12 07:34:21 +02:00
rouggy 025472820b fix(bandopen): the moon is not a band opening
A 2 m opening was announced at 9650 km towards Japan. The callsigns gave it
away — 7M4RRM, JA7RPC, JF1AWC — all working EME, which is routine on 2 m and
reported to PSK Reporter like anything else. Real contacts, real grids, and no
evidence whatsoever about the band: an operator turning a beam on that bearing
would hear nothing.

Removing the flat 2400 km ceiling was right; it threw away genuine multi-hop Es
on 6 m. "No limit anywhere" was the overcorrection. The limit is per band now,
and it is physics rather than a threshold: 2 m reaches a few thousand kilometres
by tropospheric duct or a chain of Es clouds and no further, so 3500 km, and
4 m 4000. Six and ten metres keep no ceiling — multi-hop Es and F2 genuinely do
go round the world, which is the case that started all this.

Pinned from both sides: the 9650 km Japanese burst produces nothing, and a
2000 km 2 m burst in one sector still counts.
2026-08-12 07:00:59 +02:00
rouggy 8b66030c89 fix(udp): WSJT-X QSOs were missing the solar data and the distance
The UDP path applies the station profile, the DXCC number, the Club Log
exceptions, the zone refinement and the QSL defaults — and its comment says
"same as the manual AddQSO path", which it was not. applySolar and fillDistance
were never called there.

For an operator running digital, and that is most of the traffic on most
stations, it meant SFI, A, K and distance were empty across the whole log while
a hand-logged contact carried all four. It also quietly undermines the
history-based propagation work, which needs those numbers to be there.

applySolar now refuses a QSO more than a day old, whichever path it arrives by.
The UDP feed and the ADIF monitor normally carry contacts seconds old but
neither promises it — a logger re-broadcasting its backlog, or an operator
typing last month's contact by hand, would be handed this morning's SFI as
though it had been measured at the time. A wrong reading is worse than a missing
one: afterwards nothing tells it apart from a real one.
2026-08-11 22:11:29 +02:00
rouggy d4f23a52af fix(details): QSL via gets the width, QSL message gives it up
Width should follow use, and these two are nowhere near equal: a manager's
callsign is typed constantly, a QSL message almost never. The message held 7
columns of 12 for text most operators never write, while the field beside it —
often a long "via" instruction — was the one running out of room.

Swapped, so QSL via takes 7 and the message 5. Also puts them in the order they
are reached for.
2026-08-11 21:35:41 +02:00
rouggy b4b9674d8c chore: open 0.24.7
Empty block at the top so the next change has somewhere to go. 0.24.6 keeps its
six entries; the release script stamps the version constants.
2026-08-11 21:15:52 +02:00
8 changed files with 296 additions and 8 deletions
+19
View File
@@ -2721,6 +2721,15 @@ func (a *App) applySolar(q *qso.QSO) {
if a.solar == nil {
return
}
// Today's space weather belongs on today's QSO. The ADIF monitor and the UDP
// path both feed contacts that are normally seconds old, but neither promises
// it: a logger re-broadcasting its backlog, or an operator typing in last
// month's contact by hand, would otherwise be given this morning's SFI as if
// it had been measured at the time. A wrong number is worse than none — it
// cannot be told from a real reading afterwards.
if !q.QSODate.IsZero() && time.Since(q.QSODate) > 24*time.Hour {
return
}
d := a.solar.Get()
if !d.OK {
return
@@ -11823,6 +11832,16 @@ func (a *App) LogUDPLoggedADIF(adifText string) (int64, error) {
a.refineDistrictZones(&q) // W6 → CQ3/ITU6 for zone-split countries
a.applyQSLDefaults(&q)
// ── Space weather and path length ──
// Also "same as the manual path", and they were missed when that comment was
// written. A QSO auto-logged from WSJT-X went in with no SFI, no A, no K and
// no distance, so an operator running digital — which is most of the traffic
// on most stations — had those fields empty across the whole log while a
// hand-logged contact carried them. Both are stamped only where the record
// left them empty, so an ADIF that supplied its own still wins.
a.applySolar(&q)
fillDistance(&q)
// ── Dedup (serialised) ──
// Match by call + band + mode within a ±2-minute window: a QSO logged
// manually in OpsLog and re-broadcast by Log4OM over UDP often differs by
+16
View File
@@ -1,4 +1,20 @@
[
{
"version": "0.24.7",
"date": "",
"en": [
"QSOs logged from WSJT-X now carry the space weather and the distance, like hand-logged ones. The UDP path stamped the station profile, the DXCC and the QSL defaults but not SFI, A, K or distance — so an operator running digital had those fields empty across the whole log. Space weather is only stamped on a contact less than a day old: a logger re-broadcasting its backlog would otherwise be handed this morning readings for last month contacts.",
"Band openings: EME contacts are no longer mistaken for an opening. A 2 m opening was announced at 9650 km towards Japan on stations working moonbounce — real contacts, but the moon says nothing about the band, and an antenna pointed that way finds nothing. Each band now has the longest path the atmosphere can actually carry: 3500 km on 2 m, 4000 on 4 m, and no limit at all on 6 and 10 m where multi-hop really does go round the world.",
"Kenwood: WSJT-X \"Fake It\" no longer leaves the dial on the transmit frequency. A frequency set while transmitting was not recorded, so WSJT-X was told the radio was already back on the receive frequency and never restored it.",
"PowerGenius XL: the Station Control card now shows power, current, SWR and temperature without a FlexRadio. The meters were only ever drawn from the radio's stream, so a station on any other rig got an empty card while the amplifier was reporting all four over its own link."
],
"fr": [
"Les QSO enregistrés depuis WSJT-X portent désormais la météo spatiale et la distance, comme ceux saisis à la main. Le chemin UDP posait le profil station, le DXCC et les défauts QSL mais ni SFI, ni A, ni K, ni distance — un opérateur en numérique avait donc ces champs vides sur tout son log. La météo spatiale n est posée que sur un contact de moins d un jour : sinon un logiciel qui rediffuse son historique se verrait attribuer les relevés de ce matin sur des contacts du mois dernier.",
"Ouvertures de bande : les contacts EME ne sont plus pris pour une ouverture. Une ouverture 2 m était annoncée à 9650 km vers le Japon sur des stations en rebond lunaire — de vrais contacts, mais la Lune ne dit rien de la bande, et une antenne pointée par là ne trouve rien. Chaque bande a désormais la distance maximale que l atmosphère peut réellement porter : 3500 km en 2 m, 4000 en 4 m, et aucune limite en 6 et 10 m où les sauts multiples font vraiment le tour du monde.",
"Kenwood : le « Fake It » de WSJT-X ne laisse plus le VFO sur la fréquence d émission. Un changement de fréquence pendant l émission n était pas enregistré, WSJT-X croyait donc la radio déjà revenue sur la fréquence de réception et ne la remettait jamais en place.",
"PowerGenius XL : la carte du Contrôle station affiche désormais puissance, courant, ROS et température sans FlexRadio. Les mesures n étaient tirées que du flux de la radio, si bien qu une station sur une autre radio n avait qu une carte vide alors que l amplificateur remontait les quatre sur sa propre liaison."
]
},
{
"version": "0.24.6",
"date": "",
+49 -3
View File
@@ -174,6 +174,45 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
const operate = viaFlex ? !!flex?.amp_operate : !!pg.operate;
const connected = !!pg.connected || viaFlex;
const fault = flex?.amp_fault;
// Meters built from the amplifier's own GSCP status frame, for when the radio
// is not feeding a meter stream.
//
// Whether there is power is the amp's state field; how much is the plain
// forward figure. NOT "peakfwd" — that is a latched maximum which is never
// reset and survives in the last-known status after the amp disconnects, so it
// once claimed 1350 W from an old transmission while 10 W was going out. Same
// reason peak_id is left alone. Both readings are gated on transmit so they
// fall back to zero between overs instead of freezing on the last one.
const pgxlMeters = () => {
if (!pg.connected) return null;
const txing = typeof flex?.transmitting === 'boolean' ? flex.transmitting : /TRANSMIT/i.test(pg.state || '');
const fwdW = peakHold('pgfwd', txing ? Number(pg.fwd_w) || 0 : 0);
const idA = peakHold('pgid', txing ? Number(pg.id) || 0 : 0);
const swr = peakHold('pgswr', txing ? Number(pg.vswr) || 0 : 0);
const tempC = Number(pg.temperature) || 0;
// Two columns, not four. The card sits beside a tall neighbour in Station
// Control, so a single row of four leaves the height empty and squeezes each
// bar into a quarter width — two rows of two use the room that is already
// there and give every bar twice the resolution.
return (
<div className="grid grid-cols-2 gap-2 mt-2 pt-2 border-t border-border/50">
<MeterBar label={t('flxp.outputPower')} value={fwdW} unit="W" lo={0} hi={2000}
display={`${Math.round(fwdW)} W`}
segColor={(f) => (f > 0.9 ? '#dc2626' : f > 0.75 ? '#f59e0b' : '#ea580c')} />
<MeterBar label={t('ampw.id')} value={idA} lo={0} hi={25} display={`${idA.toFixed(1)} A`} accent="#16a34a" />
{/* Below 1:1 the reading is meaningless, so an idle amp shows a flat bar
rather than a zero that looks like a perfect match. */}
<MeterBar label={t('ampw.swr')} value={swr >= 1 ? swr : 1} lo={1} hi={3}
display={swr >= 1 ? swr.toFixed(1) : '—'}
segColor={(f) => (f > 0.75 ? '#dc2626' : f > 0.4 ? '#f59e0b' : '#16a34a')} />
<MeterBar label={t('ampw.temp')} value={tempC} unit="°C" lo={0} hi={100}
display={tempC > 0 ? `${Math.round(tempC)} °C` : '—'}
segColor={(f) => (f > 0.8 ? '#dc2626' : f > 0.6 ? '#f59e0b' : '#ea580c')} />
</div>
);
};
return (
<Card icon={Flame} ckey="amplifier" title={`${t('flxp.amplifier')}${flex?.amp_model ? ' · ' + flex.amp_model : (pg.model ? ' · ' + pg.model : '')} · ${amp.name}`} accent="#ea580c">
<div className="flex items-center gap-3 flex-wrap">
@@ -204,13 +243,20 @@ export function AmpCard({ amp, flex, t }: { amp: Amp; flex: any; t: (k: string,
<span className="px-2 py-1 rounded bg-danger-muted text-danger-muted-foreground text-xs font-bold">{t('flxp.fault')}: {fault}</span>
)}
</div>
{/* Amplifier meters (FWD / ID / TEMP …) from the FlexRadio UDP stream. */}
{viaFlex && (() => {
{/* Amplifier meters (FWD / ID / TEMP …).
The FlexRadio UDP stream is the preferred source — it is fast and reads
the same as SmartSDR. When there is no Flex, or it is not streaming,
the amplifier's OWN link carries the same figures; falling back to them
is what the docked widget already does. Without that fallback this card
showed an operator on a Kenwood nothing but OPERATE and the fan mode,
while the amplifier was reporting power, current and temperature all
along. */}
{(() => {
const meters = (flex?.meters as any[]) || [];
const dbmToW = (d: number) => Math.pow(10, (d - 30) / 10);
const amps = meters.filter((m) => (m.src || '').toUpperCase().includes('AMP')
&& !/^(RL|DRV)$/i.test((m.name || '').trim()));
if (amps.length === 0) return null;
if (!viaFlex || amps.length === 0) return pgxlMeters();
// Power comes from the radio's meter stream and nothing else. The
// amplifier also reports a "peakfwd", and using it was a mistake twice
// over: it is a latched maximum that is never reset, and it survives in
+8 -4
View File
@@ -373,12 +373,16 @@ export function DetailsPanel({ callsign, prefix, operatorGrid, remoteGrid, qth,
<Input value={details.address} onChange={(e) => onChange({ address: e.target.value })} />
</Field>
</div>
<Field label={t('detp.qslMessage')} span={7}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
<Field label={t('detp.qslVia')} span={5}>
{/* QSL via gets the room, not the message. Width should follow use, and
these two are nowhere near equal: a manager's callsign is filled in
constantly and a QSL message almost never. The message had 7 columns
of 12 for text most operators never type. */}
<Field label={t('detp.qslVia')} span={7}>
<Input value={details.qsl_via} onChange={(e) => onChange({ qsl_via: e.target.value })} />
</Field>
<Field label={t('detp.qslMessage')} span={5}>
<Input value={details.qsl_msg} onChange={(e) => onChange({ qsl_msg: e.target.value })} />
</Field>
</div>
)}
+28
View File
@@ -81,6 +81,28 @@ var watched = map[string]bool{"10m": true, "6m": true, "4m": true, "2m": true}
// Watched reports whether a band is one the detector looks at.
func Watched(band string) bool { return watched[strings.ToLower(strings.TrimSpace(band))] }
// maxTerrestrialKm is the longest path a band can carry through the atmosphere.
// Zero means no limit.
//
// The flat 2400 km ceiling was removed because it threw away real multi-hop Es
// on 6 m, and that was right — but "no limit anywhere" then let something else
// through. A 2 m opening was announced at 9650 km towards Japan, on stations
// that were unmistakably working EME: the moon is not an opening, and pointing
// an antenna at that bearing would find nothing.
//
// So the limit is per band, and it is physics rather than a threshold. Two
// metres reaches a few thousand kilometres by tropospheric duct or a chain of Es
// clouds and no further; beyond that the path went via the moon or a satellite,
// neither of which says anything about the band. Six and ten metres have no
// ceiling at all — multi-hop Es and F2 genuinely go round the world.
var maxTerrestrialKm = map[string]int{
"2m": 3500,
"4m": 4000,
}
// MaxKmFor returns the plausibility ceiling for a band, 0 for none.
func MaxKmFor(band string) int { return maxTerrestrialKm[strings.ToLower(strings.TrimSpace(band))] }
// Opening is a detected opening, ready to be announced.
type Opening struct {
Band string `json:"band"`
@@ -130,6 +152,12 @@ func (d *Detector) Add(s Spot, lat float64) *Opening {
if s.DistKm < d.cfg.MinKm || (d.cfg.MaxKm > 0 && s.DistKm > d.cfg.MaxKm) {
return nil
}
// Past what the atmosphere can carry on this band, the path went via the moon
// or a satellite. Those are real contacts and real reports; they are simply
// not evidence about the band.
if m := MaxKmFor(band); m > 0 && s.DistKm > m {
return nil
}
d.recent = append(d.recent, s)
d.prune(s.At)
+51
View File
@@ -0,0 +1,51 @@
package bandopen
import (
"testing"
"time"
)
// A 2 m "opening" was announced at 9650 km towards Japan on stations that were
// plainly working EME. The moon is not an opening: an operator pointing an
// antenna at that bearing finds nothing.
func TestEMEIsNotAnOpeningOnTwoMetres(t *testing.T) {
d := New(DefaultConfig())
base := time.Date(2026, 8, 12, 6, 0, 0, 0, time.UTC)
for i, call := range []string{"7M4RRM", "JA7RPC", "JF1AWC", "JK1TPA", "JH1JCQ"} {
if op := d.Add(Spot{
Call: call, Band: "2m", DistKm: 9650, Bearing: 40 + i*3,
At: base.Add(time.Duration(i) * time.Minute),
}, 47.0); op != nil {
t.Fatalf("a 9650 km 2 m path was announced as an opening: %+v", op)
}
}
}
// But a real 2 m opening — an Es chain at a plausible distance — must survive.
func TestLongButPlausibleTwoMetresStillCounts(t *testing.T) {
d := New(DefaultConfig())
base := time.Date(2026, 6, 20, 18, 0, 0, 0, time.UTC)
var got *Opening
for i, call := range []string{"EA1AA", "CT1BB", "EA7CC", "CT7DD"} {
if op := d.Add(Spot{
Call: call, Band: "2m", DistKm: 2000 + i*30, Bearing: 200 + i*4,
At: base.Add(time.Duration(i) * time.Minute),
}, 47.0); op != nil {
got = op
}
}
if got == nil {
t.Fatal("a 2000 km 2 m burst in one sector was not reported")
}
}
// Six metres keeps no ceiling: multi-hop Es genuinely goes that far, which is
// why the flat limit was removed in the first place.
func TestSixMetresHasNoCeiling(t *testing.T) {
if MaxKmFor("6m") != 0 || MaxKmFor("10m") != 0 {
t.Error("6 m and 10 m must have no distance ceiling")
}
if MaxKmFor("2m") == 0 {
t.Error("2 m must have one")
}
}
+25 -1
View File
@@ -440,7 +440,31 @@ func (k *Kenwood) SetFrequency(hz int64) error {
if k.curVFO == "B" {
cmd = "FB"
}
return k.write(fmt.Sprintf("%s%011d;", cmd, hz))
if err := k.write(fmt.Sprintf("%s%011d;", cmd, hz)); err != nil {
return err
}
// Remember what we just commanded.
//
// While PTT is held the poll is skipped and State() hands back lastState — the
// rig answers "?;" to IF; mid-transmission, and reading that as a fault used
// to drop the whole link. But a frequency SET during that window then went
// unrecorded, so the cache kept describing the dial as it was before.
//
// WSJT-X's "Fake It" is exactly that sequence: move the dial, key, transmit,
// and afterwards put it back. Polling during the over, it was told the rig was
// still on the receive frequency — so there was nothing to put back, and the
// dial stayed on the transmit frequency for good. Every following over
// started from there, which is the drift that was reported.
//
// Only simplex is updated here. Under split, FreqHz means the transmit
// frequency while this write lands on whichever VFO the operator is on, and
// guessing which side moved would be worse than a stale value the next poll
// corrects on its own.
if !k.lastState.Split {
k.curFreq = hz
k.lastState.FreqHz = hz
}
return nil
}
func (k *Kenwood) SetMode(mode string) error {
+100
View File
@@ -0,0 +1,100 @@
package cat
import "testing"
// WSJT-X "Fake It" against the transmit-window cache.
//
// Fake It keeps the radio on one dial frequency and shifts it only for the
// duration of each over: set the transmit frequency, key, transmit, unkey, set
// it back. The restore is not unconditional — WSJT-X reads the frequency back
// and puts the dial where it believes it should be.
//
// That read lands inside the window where this backend deliberately stops
// polling, because a Kenwood answers "?;" to IF; while it is transmitting and
// treating that as a fault used to drop the whole shared link. The cache
// answers instead. So the cache has to account for frequency SETS made during
// the window, or it describes the dial as it was before the over — and WSJT-X,
// told the radio is already on the receive frequency, has nothing to restore.
//
// This reproduces the sequence from a reported session: the dial stayed on the
// transmit frequency after the first over and every later one started there.
func TestKenwoodFakeItRestoresAfterTransmit(t *testing.T) {
const (
rxHz = 7074000 // where the operator is listening
txHz = 7075500 // where Fake It moves the dial to transmit
)
rig := &ts2000{vfoA: rxHz, mode: '2'}
k := NewKenwood("COM-TEST", 9600, "FT8")
k.dialPort = dialTo(rig)
if err := k.Connect(); err != nil {
t.Fatalf("connect: %v", err)
}
defer k.Disconnect()
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
t.Fatalf("before the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
}
// The over: shift the dial, then key.
if err := k.SetFrequency(txHz); err != nil {
t.Fatalf("set transmit frequency: %v", err)
}
if err := k.SetPTT(true); err != nil {
t.Fatalf("ptt on: %v", err)
}
// WSJT-X reads back mid-over. The wire is not polled here — this is the
// cache talking, and it must not still be saying rxHz.
s, err := k.ReadState()
if err != nil {
t.Fatalf("read during the over: %v", err)
}
if s.FreqHz != txHz {
t.Errorf("during the over the backend reported %d, want %d — "+
"reporting the pre-over frequency is what stops Fake It restoring the dial", s.FreqHz, txHz)
}
if err := k.SetPTT(false); err != nil {
t.Fatalf("ptt off: %v", err)
}
// The restore, once the over is done.
if err := k.SetFrequency(rxHz); err != nil {
t.Fatalf("restore: %v", err)
}
if rig.vfoA != rxHz {
t.Errorf("dial left on %d after the over, want %d", rig.vfoA, rxHz)
}
if s, err := k.ReadState(); err != nil || s.FreqHz != rxHz {
t.Errorf("after the over: %d (err %v) — want %d", s.FreqHz, err, rxHz)
}
}
// Under split the same write must NOT touch the cache: FreqHz means the
// transmit frequency while the write lands on whichever VFO the operator is on,
// so guessing which side moved would put a wrong number in front of the
// operator. A stale one survives only until the next poll.
func TestKenwoodSplitCacheLeftToThePoll(t *testing.T) {
rig := &ts2000{vfoA: 14025000, vfoB: 14030000, mode: '3', split: true}
k := NewKenwood("COM-TEST", 9600, "CW")
k.dialPort = dialTo(rig)
if err := k.Connect(); err != nil {
t.Fatalf("connect: %v", err)
}
defer k.Disconnect()
s, err := k.ReadState()
if err != nil || !s.Split {
t.Fatalf("split not seen: %+v (err %v)", s, err)
}
before := s.FreqHz
if err := k.SetFrequency(14026000); err != nil {
t.Fatalf("set: %v", err)
}
if k.lastState.FreqHz != before {
t.Errorf("split cache moved to %d on a VFO write, want it left at %d for the poll",
k.lastState.FreqHz, before)
}
}