Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
99d903eb44 | ||
|
|
13515c58c0 | ||
|
|
e4014e11d2 | ||
|
|
025472820b | ||
|
|
8b66030c89 | ||
|
|
d4f23a52af | ||
|
|
b4b9674d8c |
@@ -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
|
||||
|
||||
@@ -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": "",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user