Compare commits

...
11 Commits
Author SHA1 Message Date
rouggy 01bcf256e2 fix: hand-corrected CQ/ITU zones came back wrong at every restart
The profile's MY_* metadata is derived from the callsign through cty.dat, and the
effect that derives it ran on every load — so opening the settings counted as
"the source changed" and overwrote whatever the operator had typed. Reported by
an operator in CQ 4 / ITU 4 handed 5 and 9: he corrected them, and each restart
put 5 and 9 back.

cty.dat gives the zones of the ENTITY, and a large country spans several, so the
automatic value cannot be treated as authoritative — it is a starting point. A
load now fills only fields that are EMPTY; a recompute that overwrites happens
only when the callsign or grid itself changes, which is the case the derivation
exists for.

The load-vs-edit distinction is a ref holding the profile id the values were last
derived from — first sight of a profile is a load.
2026-07-28 20:53:10 +02:00
rouggy c9d1fc1cc0 chore: release v0.21.8 2026-07-28 19:10:14 +02:00
rouggy f43d41892e docs: changelog — the QRZ date window covers both cases 2026-07-28 15:56:37 +02:00
rouggy 1f2e86c04d feat: a typed QRZ start date fetches that period, not what QRZ edited in it
"Last download" and an operator-chosen date are different questions, and they
now ask QRZ different ones. The automatic run wants everything TOUCHED since it
last ran (MODSINCE) — that is what returns a 2015 QSO confirmed yesterday. A date
typed by the operator names a period of OPERATING, so it maps to BETWEEN date and
today, on the QSO date.

The client-side date filter is skipped only under MODSINCE, where it would throw
away the older-but-newly-confirmed records the query exists to fetch. Under
BETWEEN it agrees with the server and stays on as a backstop.
2026-07-28 15:48:00 +02:00
rouggy 305da583e1 fix: QRZ download used an option QRZ does not accept
The date window was sent as OPTION=AFTER:<date>. That was a guess, and QRZ
rejects the request outright — RESULT=FAIL with no reason — so the confirmation
download failed completely for everyone, whatever the account.

The documented option is MODSINCE, and it is the better window anyway: it selects
records MODIFIED since the date, so a QSO from years ago that was confirmed
yesterday comes back. A QSO-date window structurally cannot return those, and
confirmations are the whole point of this download — which is why the client-side
date filter is now skipped when the server did the windowing, instead of throwing
those same records away on arrival.

A refusal now falls back to ALL rather than leaving the operator with nothing.
2026-07-28 15:43:47 +02:00
rouggy 71bde30e24 chore: log the window geometry that is saved and restored
Reported as a regression — reopens on the wrong screen — but the window code is
untouched since it was verified working: the only change to main.go since is the
background colour. So the fault is in the DATA, and there was no way to see it.

Both ends now log their coordinates, plus where the window actually landed after
the un-maximise / move / re-maximise dance. That separates the cases, which need
opposite fixes: nothing was captured, something wrong was captured, or the right
corner was captured and Windows moved the window anyway.
2026-07-28 15:35:57 +02:00
rouggy 31b13cfbc0 Revert "fix: no right-click Cut/Copy/Paste in release builds"
Reverts 30d8827. Ctrl+C / Ctrl+V already work in the app, so the WebView's
default menu only added Refresh / Save as on the neutral areas — noise in an
application window, for nothing gained. Changelog entry dropped with it.
2026-07-28 15:33:30 +02:00
rouggy 30d88276f3 fix: no right-click Cut/Copy/Paste in release builds
Wails disables the WebView context menu in production, so an operator could not
paste a cluster address, an API key or a callsign anywhere in the app. Ctrl+V
did work — nothing intercepts it — but the menu is where people look, and a key
copied off a web page is exactly what you paste rather than retype.

EnableDefaultContextMenu turns it back on.
2026-07-28 15:28:51 +02:00
rouggy b18db7acfc fix: a port field could not be cleared
Every port box was parseInt(e.target.value) || <default>. Delete the contents
and parseInt gives NaN, so the default is written straight back: the digits
reappear under the cursor and the only way to enter a different port is to
overwrite it character by character. Reported on the cluster editor; the same
line existed in eight other places.

One PortInput component now holds the raw TEXT as its state and only tells the
parent about a value that is actually a port. An empty box stays empty while you
type; on blur an invalid one falls back to the default, so nothing is ever saved
as 0 or NaN. It adopts a value arriving from the backend only while the operator
has not started typing — re-syncing mid-edit is the behaviour being fixed.

This is the controlled-input trap the project notes already warn about: keep the
raw text in local state and derive the stored value from it.
2026-07-28 15:25:21 +02:00
rouggy d303dee768 chore: microwave-band entry belongs to 0.21.8, not the published 0.21.7
Same slip as before the 0.21.6 release: I appended to a block that had already
shipped. 0.21.7 is back to its 12 published entries; the microwave-band fix
opens 0.21.8.
2026-07-28 15:15:37 +02:00
rouggy ad02583ef7 fix: recognise the microwave bands — 10 GHz resolved to no band at all
Reported on 3 cm. cat.BandFromHz stopped at 23 cm, so a 10 GHz frequency came
back EMPTY — and an empty band is not cosmetic: the QSO is logged without one,
counts in no award slot, and exports with no BAND field. The low end was short
too (2190m / 630m / 560m).

Four band tables had to be extended because four exist: the CAT one, the award
plan in app.go, the cluster spot classifier, and the frontend's. Plus the places
that LIST bands — the QSO editor and award-definition pickers (you could not set
3 cm by hand either), the statistics axis, and the award band ORDER, where a
missing band sorts to the end instead of in frequency order.

Ranges and names are ADIF 3.1.7, which is what an export has to carry.

A test now pins one frequency per band across the Go tables and asserts they
agree — that is what was missing, four hand-maintained copies with nothing
checking them. It also pins that an out-of-band frequency stays empty rather
than snapping to the nearest band, which would file a QSO under a band the
operator never used.
2026-07-28 14:50:17 +02:00
12 changed files with 364 additions and 85 deletions
+64 -12
View File
@@ -1653,6 +1653,11 @@ func (a *App) saveWindowState() {
x, y := wruntime.WindowGetPosition(a.ctx) x, y := wruntime.WindowGetPosition(a.ctx)
ws.X, ws.Y = x, y ws.X, ws.Y = x, y
} }
// Logged because this is the only evidence of WHAT was stored: an operator
// reporting "it reopens on the wrong screen" cannot tell a position that was
// never captured from one that was captured wrong, and the two need opposite
// fixes.
applog.Printf("window: saving %d,%d %dx%d maximised=%v compact=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised, a.compact)
writeWindowState(a.dataDir, ws) writeWindowState(a.dataDir, ws)
} }
@@ -1667,15 +1672,20 @@ func (a *App) restoreWindowPosition() {
} }
ws, ok := readWindowState(a.dataDir) ws, ok := readWindowState(a.dataDir)
if !ok { if !ok {
applog.Printf("window: no saved geometry — opening where Windows puts it")
return return
} }
applog.Printf("window: restoring %d,%d %dx%d maximised=%v", ws.X, ws.Y, ws.Width, ws.Height, ws.Maximised)
// Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise, // Maximised: Windows reopens it on the PRIMARY screen unless we say otherwise,
// so a two-screen operator lost OpsLog to the wrong monitor at every launch. // so a two-screen operator lost OpsLog to the wrong monitor at every launch.
// Un-maximise, move to the saved corner (which names the monitor), maximise // Un-maximise, move to the saved corner (which names the monitor), maximise
// again — all while the window is still hidden, so nothing flickers. // again — all while the window is still hidden, so nothing flickers.
if ws.Maximised { if ws.Maximised {
if ws.X == 0 && ws.Y == 0 { if ws.X == 0 && ws.Y == 0 {
return // never recorded (or genuinely the primary corner) nothing to do // Either never recorded, or genuinely the primary monitor's corner —
// indistinguishable, and both mean "leave it to Windows".
applog.Printf("window: maximised with corner 0,0 — nothing to restore")
return
} }
if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) { if !onSomeMonitor(ws.X, ws.Y, normalMinW, normalMinH) {
applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y) applog.Printf("window: saved maximised corner %d,%d is off every monitor — opening where Windows puts it", ws.X, ws.Y)
@@ -1684,6 +1694,8 @@ func (a *App) restoreWindowPosition() {
wruntime.WindowUnmaximise(a.ctx) wruntime.WindowUnmaximise(a.ctx)
wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y) wruntime.WindowSetPosition(a.ctx, ws.X, ws.Y)
wruntime.WindowMaximise(a.ctx) wruntime.WindowMaximise(a.ctx)
gx, gy := wruntime.WindowGetPosition(a.ctx)
applog.Printf("window: re-maximised at the saved corner — now at %d,%d", gx, gy)
return return
} }
if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH { if ws.Width < normalMinW || ws.Height < normalMinH || ws.Width > maxW || ws.Height > maxH {
@@ -3846,7 +3858,9 @@ type AwardStatsResult struct {
Rows []AwardStatRow `json:"rows"` Rows []AwardStatRow `json:"rows"`
} }
var statsBands = []string{"160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "2m", "1.25m", "70cm", "23cm", "13cm"} var statsBands = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm", "9cm", "6cm", "3cm", "1.25cm",
"6mm", "4mm", "2.5mm", "2mm", "1mm"}
// awardSnapshot returns the logbook as a light-scanned, award-enriched slice, // awardSnapshot returns the logbook as a light-scanned, award-enriched slice,
// reused across award computations. Pulling the whole logbook is the dominant // reused across award computations. Pulling the whole logbook is the dominant
@@ -4176,7 +4190,11 @@ var awardBandPlan = []struct {
{"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000}, {"15m", 21000000, 21450000}, {"12m", 24890000, 24990000}, {"10m", 28000000, 29700000},
{"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000}, {"6m", 50000000, 54000000}, {"4m", 70000000, 71000000}, {"2m", 144000000, 148000000},
{"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000}, {"1.25m", 222000000, 225000000}, {"70cm", 420000000, 450000000}, {"23cm", 1240000000, 1300000000},
{"13cm", 2300000000, 2450000000}, {"13cm", 2300000000, 2450000000}, {"9cm", 3300000000, 3500000000},
{"6cm", 5650000000, 5925000000}, {"3cm", 10000000000, 10500000000},
{"1.25cm", 24000000000, 24250000000}, {"6mm", 47000000000, 47200000000},
{"4mm", 75500000000, 81000000000}, {"2.5mm", 119980000000, 123000000000},
{"2mm", 134000000000, 149000000000}, {"1mm", 241000000000, 250000000000},
} }
func bandForHz(hz int64) string { func bandForHz(hz int64) string {
@@ -9557,21 +9575,52 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
// QSO date. sinceDate is "YYYY-MM-DD". // QSO date. sinceDate is "YYYY-MM-DD".
sinceDate := resolveSince(keyExtQRZLastDownload) sinceDate := resolveSince(keyExtQRZLastDownload)
emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload)) emit(fmt.Sprintf("Window: since=%q → resolved date=%q (key %s%s)", since, sinceDate, a.profileScope(), keyExtQRZLastDownload))
if sinceDate != "" { // An automatic run ("last") and an operator-chosen date do not mean the
emit("Fetching QRZ.com logbook (will skip QSOs before " + sinceDate + ")…") // same thing, so they ask QRZ two different questions:
} else { // • "last" → MODSINCE: everything TOUCHED since the last run,
emit("Fetching QRZ.com logbook (full — no since date)…") // which is what catches a 2015 QSO confirmed yesterday.
} // • a typed date → BETWEEN: the QSOs MADE from that date to today. The
// operator asked for a period of operating, not for
// whatever QRZ happened to edit in that period.
autoWindow := strings.EqualFold(strings.TrimSpace(since), "last")
// Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the // Ask QRZ for the WINDOW rather than the whole logbook. "ALL" made the
// server serialise every QSO — 56 MB for a 117k-record log — which it // server serialise every QSO — 56 MB for a 117k-record log — which it
// truncates mid-record, so the parse died two thirds of the way through // truncates mid-record, so the parse died two thirds of the way through
// and everything past that point was never seen. AFTER: narrows it at the // and everything past that point was never seen. AFTER: narrows it at the
// source; the client-side date filter below stays as the backstop. // source; the client-side date filter below stays as the backstop.
fetchOpt := "ALL" //
if sinceDate != "" { // The option is MODSINCE (QRZ FETCH documentation) — "AFTER:" was a guess
fetchOpt = "AFTER:" + sinceDate // and QRZ rejected the whole fetch, which broke the download outright.
// MODSINCE is also the RIGHT window for this job: it selects records
// MODIFIED since the date, so a ten-year-old QSO confirmed yesterday comes
// back — a QSO-date window would never return it, and confirmations are
// exactly what this download is for.
//
// It stays an optimisation, never a requirement: if QRZ refuses it, fall
// back to ALL rather than leaving the operator with no download at all.
//
// serverWindowed is set only for MODSINCE, because it alone returns QSOs
// OLDER than the date — the client-side date filter would drop exactly
// those. Under BETWEEN the filter agrees with the server and stays on as a
// backstop.
fetchOpt, serverWindowed := "ALL", false
switch {
case sinceDate == "":
emit("Fetching QRZ.com logbook (full — no since date)…")
case autoWindow:
fetchOpt, serverWindowed = "MODSINCE:"+sinceDate, true
emit("Fetching QRZ.com logbook (records modified since " + sinceDate + ")…")
default:
today := time.Now().UTC().Format("2006-01-02")
fetchOpt = "BETWEEN:" + sinceDate + "+" + today
emit("Fetching QRZ.com logbook (QSOs from " + sinceDate + " to " + today + ")…")
} }
fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt) fr, err := extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
if err != nil && fetchOpt != "ALL" {
emit("QRZ.com refused " + fetchOpt + " (" + err.Error() + ") — fetching the full logbook instead…")
fetchOpt, serverWindowed = "ALL", false
fr, err = extsvc.FetchQRZ(ctx, nil, cfg.QRZ.APIKey, fetchOpt)
}
if err != nil { if err != nil {
emit("Fetch failed: " + err.Error()) emit("Fetch failed: " + err.Error())
done(matched, total) done(matched, total)
@@ -9631,7 +9680,10 @@ func (a *App) runDownloadConfirmations(svc extsvc.Service, cfg extsvc.ExternalSe
return nil return nil
} }
// Date window (client-side): skip QSOs older than the requested date. // Date window (client-side): skip QSOs older than the requested date.
if sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate { // ONLY when the server did not window the fetch itself — MODSINCE
// returns old QSOs whose CONFIRMATION is recent, and filtering those
// out by QSO date would throw away the very records asked for.
if !serverWindowed && sinceDate != "" && !q.QSODate.IsZero() && q.QSODate.UTC().Format("2006-01-02") < sinceDate {
return nil return nil
} }
// Skip a QSO logged under a DIFFERENT one of the operator's callsigns. // Skip a QSO logged under a DIFFERENT one of the operator's callsigns.
+73
View File
@@ -0,0 +1,73 @@
package main
import (
"testing"
"hamlog/internal/cat"
)
// Three band tables exist in Go — cat.BandFromHz (the rig/CAT one), bandForHz
// (awards) and the cluster's own — plus one in App.tsx. They must agree, and
// nothing checked it: the CAT one stopped at 23 cm, so a 10 GHz QSO came back
// with an EMPTY band. An empty band is not a cosmetic problem — it is not
// logged, not counted in any award slot, and exports without a BAND field.
//
// One frequency per band, taken inside the range, plus the edges that used to be
// missing entirely.
func TestBandPlansAgree(t *testing.T) {
cases := []struct {
hz int64
want string
}{
{137000, "2190m"},
{475000, "630m"},
{1840000, "160m"},
{3650000, "80m"},
{7100000, "40m"},
{10120000, "30m"},
{14200000, "20m"},
{18100000, "17m"},
{21200000, "15m"},
{24940000, "12m"},
{28400000, "10m"},
{50150000, "6m"},
{70200000, "4m"},
{144300000, "2m"},
{223500000, "1.25m"},
{432000000, "70cm"},
{1296000000, "23cm"},
// The microwave bands that were missing. 3 cm is the one an operator
// reported: 10 GHz is worked and spotted, and resolved to nothing.
{2320000000, "13cm"},
{3400000000, "9cm"},
{5760000000, "6cm"},
{10368000000, "3cm"},
{24048000000, "1.25cm"},
{47088000000, "6mm"},
{76032000000, "4mm"},
{122250000000, "2.5mm"},
{134928000000, "2mm"},
{241920000000, "1mm"},
}
for _, c := range cases {
if got := cat.BandFromHz(c.hz); got != c.want {
t.Errorf("cat.BandFromHz(%d) = %q, want %q", c.hz, got, c.want)
}
if got := bandForHz(c.hz); got != c.want {
t.Errorf("bandForHz(%d) = %q, want %q — the award band plan disagrees with the CAT one", c.hz, got, c.want)
}
}
}
// A frequency in no amateur band must resolve to "" everywhere, not to the
// nearest band: guessing here would put a QSO in a band the operator never used.
func TestBandPlanRejectsOutOfBand(t *testing.T) {
for _, hz := range []int64{100_000_000, 1_000_000_000, 15_000_000_000} {
if got := cat.BandFromHz(hz); got != "" {
t.Errorf("cat.BandFromHz(%d) = %q, want empty", hz, got)
}
if got := bandForHz(hz); got != "" {
t.Errorf("bandForHz(%d) = %q, want empty", hz, got)
}
}
}
+24
View File
@@ -1,4 +1,28 @@
[ [
{
"version": "0.21.9",
"date": "2026-07-28",
"en": [
"CQ and ITU zones you correct by hand in your profile stay corrected. They are derived from cty.dat, which gives the zones of the whole entity — in a country spanning several, the automatic value is wrong and it came back at every restart. They now only fill in when empty, and recompute when the callsign or grid changes."
],
"fr": [
"Les zones CQ et ITU corrigées à la main dans votre profil restent corrigées. Elles proviennent de cty.dat, qui donne les zones de l'entité entière — dans un pays qui en couvre plusieurs, la valeur automatique est fausse et revenait à chaque redémarrage. Elles ne se remplissent désormais que si le champ est vide, et se recalculent quand l'indicatif ou le locator change."
]
},
{
"version": "0.21.8",
"date": "2026-07-28",
"en": [
"Microwave bands are recognised: 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm and above, plus 33cm and the 2190m/630m/560m low bands. A 10 GHz QSO used to come back with NO band at all — so it was logged without one, counted in no award slot, and exported without a BAND field. Band pickers, statistics and award band columns follow.",
"QRZ.com confirmation download works again: the date window used an option QRZ rejects, so every fetch failed. An automatic run now asks for everything modified since the last one — so an old QSO confirmed recently comes back — while a date you type fetches the QSOs made between that date and today.",
"Port fields can be cleared. Deleting the contents put the default straight back, so entering a different port meant overwriting it digit by digit. Fixed on the cluster editor, where it was reported, and in the eight other places with the same fault (CAT, amplifier, antenna, rotator, database, SMTP)."
],
"fr": [
"Les bandes hyperfréquences sont reconnues : 13cm, 9cm, 6cm, 3cm (10 GHz), 1.25cm et au-delà, ainsi que 33cm et les bandes basses 2190m/630m/560m. Un QSO à 10 GHz revenait sans AUCUNE bande — donc enregistré sans bande, compté dans aucun diplôme, et exporté sans champ BAND. Les listes de bandes, les statistiques et les colonnes de diplômes suivent.",
"Le téléchargement des confirmations QRZ.com refonctionne : la fenêtre de date utilisait une option refusée par QRZ, donc chaque récupération échouait. Un téléchargement automatique demande désormais tout ce qui a été modifié depuis le précédent — un QSO ancien confirmé récemment revient donc — tandis qu'une date saisie récupère les QSO faits entre cette date et aujourd'hui.",
"Les champs de port peuvent être vidés. Effacer le contenu réinscrivait aussitôt la valeur par défaut, si bien qu'entrer un autre port obligeait à l'écraser chiffre par chiffre. Corrigé dans l'éditeur de cluster, où c'était signalé, et dans les huit autres endroits présentant le même défaut (CAT, amplificateur, antenne, rotator, base de données, SMTP)."
]
},
{ {
"version": "0.21.7", "version": "0.21.7",
"date": "2026-07-28", "date": "2026-07-28",
+5 -1
View File
@@ -272,7 +272,11 @@ function bandForMHz(mhz: number): string {
[1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'], [1.8, 2.0, '160m'], [3.5, 4.0, '80m'], [5.06, 5.45, '60m'], [7.0, 7.3, '40m'],
[10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'], [10.1, 10.15, '30m'], [14.0, 14.35, '20m'], [18.068, 18.168, '17m'], [21.0, 21.45, '15m'],
[24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'], [24.89, 24.99, '12m'], [28.0, 29.7, '10m'], [50, 54, '6m'], [70, 71, '4m'],
[144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [1240, 1300, '23cm'], [144, 148, '2m'], [222, 225, '1.25m'], [420, 450, '70cm'], [902, 928, '33cm'], [1240, 1300, '23cm'],
// Microwave, ADIF 3.1.7 ranges — kept in step with BandFromHz on the Go side.
[2300, 2450, '13cm'], [3300, 3500, '9cm'], [5650, 5925, '6cm'], [10000, 10500, '3cm'],
[24000, 24250, '1.25cm'], [47000, 47200, '6mm'], [75500, 81000, '4mm'],
[119980, 123000, '2.5mm'], [134000, 149000, '2mm'], [241000, 250000, '1mm'],
]; ];
for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b; for (const [lo, hi, b] of plan) if (mhz >= lo && mhz <= hi) return b;
return ''; return '';
+1 -1
View File
@@ -63,7 +63,7 @@ const CONFIRM_SRC = [
{ id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' }, { id: 'lotw', label: 'LoTW' }, { id: 'qsl', label: 'QSL' }, { id: 'eqsl', label: 'eQSL' },
{ id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' }, { id: 'qrzcom', label: 'QRZ.com' }, { id: 'custom', label: 'Custom' },
]; ];
const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','23cm','13cm']; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE']; const MODES = ['CW','SSB','USB','LSB','AM','FM','RTTY','PSK31','FT8','FT4','JT65','JT9','MFSK','OLIVIA','DIGITALVOICE'];
const EMISSIONS = ['CW', 'PHONE', 'DIGITAL']; const EMISSIONS = ['CW', 'PHONE', 'DIGITAL'];
+1 -1
View File
@@ -37,7 +37,7 @@ function pfxOf(call: string): string {
return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base; return lastDigit >= 0 ? base.slice(0, lastDigit + 1) : base;
} }
const BANDS = ['160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','70cm','23cm']; const BANDS = ['2190m','630m','160m','80m','60m','40m','30m','20m','17m','15m','12m','10m','6m','4m','2m','1.25m','70cm','33cm','23cm','13cm','9cm','6cm','3cm','1.25cm','6mm','4mm','2.5mm','2mm','1mm'];
const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9']; const MODES = ['SSB','CW','FT8','FT4','RTTY','PSK31','AM','FM','DIGITALVOICE','MFSK','OLIVIA','JS8','JT65','JT9'];
// label holds an i18n key (resolved with t() at render time). // label holds an i18n key (resolved with t() at render time).
const QSL_STATUSES = [ const QSL_STATUSES = [
+93 -26
View File
@@ -1,4 +1,4 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { import {
ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2, ArrowDown, ArrowUp, ArrowLeft, ArrowRight, Copy, Plus, Star, StarOff, Trash2,
ChevronDown, ChevronRight, ChevronDown, ChevronRight,
@@ -1483,27 +1483,49 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
return () => { off(); }; return () => { off(); };
}, []); }, []);
// Which (profile, callsign, grid) the auto-fill below last derived from.
// Without it, simply OPENING the settings counted as "the source changed" and
// overwrote the operator's own values.
const derivedFrom = useRef<string>('');
// Auto-fill the active profile's MY_* DXCC metadata from the station // Auto-fill the active profile's MY_* DXCC metadata from the station
// callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon). These // callsign (country, DXCC#, CQ/ITU zones) and the grid (lat/lon).
// are derived values, so they always recompute when the callsign or grid //
// changes — the user can still edit a field, it just re-populates when the // These are derived values, so they recompute when the callsign or grid
// source changes. Debounced so we don't hammer cty.dat while typing. // changes. What they must NOT do is recompute on a plain load: cty.dat gives
// the zones of the ENTITY, and a large country spans several — an operator in
// CQ 14 / ITU 27 was handed 15 and 28 and corrected them by hand, and every
// restart put the wrong pair back. cty.dat is a starting point, not an
// authority, so on a load we only fill fields that are EMPTY; a value the
// operator typed stays until the callsign or grid itself changes.
// Debounced so we don't hammer cty.dat while typing.
useEffect(() => { useEffect(() => {
const call = (activeProfile?.callsign ?? '').trim(); const call = (activeProfile?.callsign ?? '').trim();
if (!call) return; if (!call) return;
const grid = (activeProfile?.my_grid ?? '').trim(); const grid = (activeProfile?.my_grid ?? '').trim();
const source = `${activeProfile?.id ?? 0}|${call.toUpperCase()}|${grid.toUpperCase()}`;
// First sight of this profile — a load, not an edit.
const isLoad = derivedFrom.current === '' || derivedFrom.current.split('|')[0] !== String(activeProfile?.id ?? 0);
const t = window.setTimeout(async () => { const t = window.setTimeout(async () => {
try { try {
const i: any = await ComputeStationInfo(call, grid); const i: any = await ComputeStationInfo(call, grid);
derivedFrom.current = source;
setActiveProfile((p) => { setActiveProfile((p) => {
if (!p) return p; if (!p) return p;
const patch: any = {}; const patch: any = {};
if (i.country) patch.my_country = i.country; // On a load, keep in patch only what the profile does not already have.
if (i.dxcc) patch.my_dxcc = i.dxcc; const put = (k: string, v: any) => {
if (i.cqz) patch.my_cqz = i.cqz; if (!v) return;
if (i.ituz) patch.my_ituz = i.ituz; const cur = (p as any)[k];
if (i.lat) patch.my_lat = i.lat; if (isLoad && cur !== undefined && cur !== null && cur !== '' && cur !== 0) return;
if (i.lon) patch.my_lon = i.lon; patch[k] = v;
};
put('my_country', i.country);
put('my_dxcc', i.dxcc);
put('my_cqz', i.cqz);
put('my_ituz', i.ituz);
put('my_lat', i.lat);
put('my_lon', i.lon);
// Only re-render when a value actually changed (prevents loops). // Only re-render when a value actually changed (prevents loops).
const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]); const changed = Object.keys(patch).some((k) => (p as any)[k] !== patch[k]);
return changed ? { ...p, ...patch } : p; return changed ? { ...p, ...patch } : p;
@@ -2358,8 +2380,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.port')}</Label> <Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.flex_port || 4992} <PortInput value={catCfg.flex_port || 4992}
onChange={(e) => setCatCfg((s) => ({ ...s, flex_port: parseInt(e.target.value) || 4992 }))} /> onChange={(n) => setCatCfg((s) => ({ ...s, flex_port: n }))} fallback={4992} />
</div> </div>
<div className="col-span-2"> <div className="col-span-2">
<FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} /> <FlexDiscover onPick={(ip, port) => setCatCfg((s) => ({ ...s, flex_host: ip, flex_port: port }))} />
@@ -2482,8 +2504,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{t('cat.port')}</Label> <Label>{t('cat.port')}</Label>
<Input type="number" value={catCfg.tci_port || 40001} <PortInput value={catCfg.tci_port || 40001}
onChange={(e) => setCatCfg((s) => ({ ...s, tci_port: parseInt(e.target.value) || 40001 }))} /> onChange={(n) => setCatCfg((s) => ({ ...s, tci_port: n }))} fallback={40001} />
</div> </div>
<p className="col-span-2 text-xs text-muted-foreground"> <p className="col-span-2 text-xs text-muted-foreground">
{t('cat.tciHint')} {t('cat.tciHint')}
@@ -2654,10 +2676,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input <PortInput
type="number" min={1} max={65535}
value={ultrabeam.port} value={ultrabeam.port}
onChange={(e) => setUltrabeam((s) => ({ ...s, port: parseInt(e.target.value) || 23 }))} onChange={(n) => setUltrabeam((s) => ({ ...s, port: n }))} fallback={23}
className="font-mono" className="font-mono"
/> />
</div> </div>
@@ -2943,8 +2964,8 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>TCP port</Label> <Label>TCP port</Label>
<Input type="number" min={1} max={65535} value={amp.port} <PortInput value={amp.port}
onChange={(e) => patchAmp(i, { port: parseInt(e.target.value) || 9008 })} className="font-mono" /> onChange={(n) => patchAmp(i, { port: n })} fallback={9008} className="font-mono" />
</div> </div>
</div> </div>
)} )}
@@ -3114,10 +3135,9 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label> <Label>{isRG || isARCO ? 'TCP port' : 'UDP port'}</Label>
<Input <PortInput
type="number" min={1} max={65535}
value={rotator.port} value={rotator.port}
onChange={(e) => setRotator((s) => ({ ...s, port: parseInt(e.target.value) || (isRG ? 9006 : isARCO ? 4001 : 12000) }))} onChange={(n) => setRotator((s) => ({ ...s, port: n }))} fallback={isRG ? 9006 : isARCO ? 4001 : 12000}
className="font-mono" className="font-mono"
/> />
</div> </div>
@@ -4678,7 +4698,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Label className="text-sm">{t('db.host')}</Label> <Label className="text-sm">{t('db.host')}</Label>
<Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} /> <Input className="h-8" placeholder="192.168.1.10 or db.example.com" value={mysqlCfg.host} onChange={(e) => setMysqlField({ host: e.target.value })} />
<Label className="text-sm">{t('db.port')}</Label> <Label className="text-sm">{t('db.port')}</Label>
<Input type="number" className="h-8 w-28 font-mono" value={mysqlCfg.port} onChange={(e) => setMysqlField({ port: parseInt(e.target.value, 10) || 0 })} /> <PortInput className="h-8 w-28 font-mono" value={mysqlCfg.port} fallback={3306} onChange={(n) => setMysqlField({ port: n })} />
<Label className="text-sm">{t('db.database')}</Label> <Label className="text-sm">{t('db.database')}</Label>
<Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} /> <Input className="h-8" placeholder="opslog" value={mysqlCfg.database} onChange={(e) => setMysqlField({ database: e.target.value })} />
<Label className="text-sm">{t('db.user')}</Label> <Label className="text-sm">{t('db.user')}</Label>
@@ -5154,7 +5174,7 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
<Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} /> <Input className="h-8" placeholder="ex5.mail.ovh.net" value={emailCfg.smtp_host} onChange={(e) => setEmailField({ smtp_host: e.target.value })} />
<Label className="text-sm">Port / encryption</Label> <Label className="text-sm">Port / encryption</Label>
<div className="flex gap-2 items-center"> <div className="flex gap-2 items-center">
<Input type="number" className="h-8 w-24 font-mono" value={emailCfg.smtp_port} onChange={(e) => setEmailField({ smtp_port: parseInt(e.target.value, 10) || 0 })} /> <PortInput className="h-8 w-24 font-mono" value={emailCfg.smtp_port} fallback={587} onChange={(n) => setEmailField({ smtp_port: n })} />
<Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}> <Select value={emailCfg.encryption} onValueChange={(v) => setEmailField({ encryption: v as any })}>
<SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger> <SelectTrigger className="h-8 w-44"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
@@ -5358,6 +5378,53 @@ export function SettingsModal({ onClose, onSaved, initialSection, onMainPaneChan
); );
} }
// PortInput — a TCP port field you can actually clear.
//
// Every port box was written as `parseInt(e.target.value) || <default>`. Delete
// the contents and parseInt gives NaN, so the default is written straight back:
// the digits reappear under the cursor and the only way to enter a different
// port is to overwrite character by character. Reported on the cluster editor;
// the same line existed in eight other places.
//
// The raw TEXT is the state here, and the parent is only told about a value that
// is actually a port. An empty box stays empty while you type; on blur it falls
// back to the last good value, so nothing is ever saved as 0 or NaN.
function PortInput({ value, onChange, fallback, className, min = 1, max = 65535 }: {
value: number; onChange: (n: number) => void; fallback: number; className?: string; min?: number; max?: number;
}) {
const [text, setText] = useState(value ? String(value) : '');
const typed = useRef(false);
// Only adopt the incoming value while the operator has NOT started typing —
// settings arrive from the backend after mount, and re-syncing mid-edit is
// precisely the behaviour being fixed.
useEffect(() => {
if (!typed.current) setText(value ? String(value) : '');
}, [value]);
return (
<Input
type="text"
inputMode="numeric"
className={className}
value={text}
onChange={(e) => {
typed.current = true;
const digits = e.target.value.replace(/[^0-9]/g, '').slice(0, 5);
setText(digits);
const n = parseInt(digits, 10);
if (n >= min && n <= max) onChange(n);
}}
onBlur={() => {
typed.current = false;
const n = parseInt(text, 10);
if (!(n >= min && n <= max)) {
setText(String(fallback));
onChange(fallback);
}
}}
/>
);
}
// ClusterServerEditor edits one row of cluster_servers. Init commands are // ClusterServerEditor edits one row of cluster_servers. Init commands are
// free-form (one per line); the backend strips blanks and "//" comments. // free-form (one per line); the backend strips blanks and "//" comments.
interface ClusterEditorProps { interface ClusterEditorProps {
@@ -5389,7 +5456,7 @@ function ClusterServerEditor({ value, onCancel, onSave }: ClusterEditorProps) {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Port</Label> <Label>Port</Label>
<Input type="number" min={1} max={65535} className="font-mono" value={s.port} onChange={(e) => update({ port: parseInt(e.target.value) || 7300 })} /> <PortInput className="font-mono" value={s.port} fallback={7300} onChange={(n) => update({ port: n })} />
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<Label>Login callsign (optional)</Label> <Label>Login callsign (optional)</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.21.7'; export const APP_VERSION = '0.21.8';
// Author / credits, shown in Help -> About. // Author / credits, shown in Help -> About.
export const APP_AUTHOR = 'F4BPO'; export const APP_AUTHOR = 'F4BPO';
+22 -17
View File
@@ -6,10 +6,11 @@
// QSO may yield several references (e.g. a Note holding "D74 D73"). // QSO may yield several references (e.g. a Note holding "D74 D73").
// //
// Examples: // Examples:
// DXCC : field "dxcc" (no pattern) → entity number //
// WAS : field "state", DXCCFilter [291,110,6] → US state // DXCC : field "dxcc" (no pattern) → entity number
// DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes // WAS : field "state", DXCCFilter [291,110,6] → US state
// WPX : field "prefix" (computed from callsign) // DDFM : field "note", pattern "D(\d{1,2}[AB]?)" → French department from notes
// WPX : field "prefix" (computed from callsign)
package award package award
import ( import (
@@ -48,16 +49,16 @@ const (
// behaves as before. // behaves as before.
type Def struct { type Def struct {
// --- Identity --- // --- Identity ---
Code string `json:"code"` // unique key, e.g. "DXCC" Code string `json:"code"` // unique key, e.g. "DXCC"
Name string `json:"name"` // friendly name Name string `json:"name"` // friendly name
Description string `json:"description,omitempty"` // free text Description string `json:"description,omitempty"` // free text
Valid bool `json:"valid"` // award enabled Valid bool `json:"valid"` // award enabled
Protected bool `json:"protected,omitempty"` // shipped/locked award Protected bool `json:"protected,omitempty"` // shipped/locked award
URL string `json:"url,omitempty"` // award home page URL string `json:"url,omitempty"` // award home page
DownloadURL string `json:"download_url,omitempty"` // reference-list source DownloadURL string `json:"download_url,omitempty"` // reference-list source
RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder RefURL string `json:"ref_url,omitempty"` // per-ref link, <REF> placeholder
ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count) ValidFrom string `json:"valid_from,omitempty"` // ISO date (QSOs before don't count)
ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count) ValidTo string `json:"valid_to,omitempty"` // ISO date (QSOs after don't count)
Alias string `json:"alias,omitempty"` Alias string `json:"alias,omitempty"`
// RefDisplay picks what the grid's award column shows for a match: "" or "ref" // RefDisplay picks what the grid's award column shows for a match: "" or "ref"
// = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g. // = the reference (e.g. WAJA "36"), "name" = the reference's description (e.g.
@@ -66,7 +67,7 @@ type Def struct {
RefDisplay string `json:"ref_display,omitempty"` RefDisplay string `json:"ref_display,omitempty"`
// --- Type & matching --- // --- Type & matching ---
Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS) Type AwardType `json:"type,omitempty"` // matching strategy (default QSOFIELDS)
Field string `json:"field"` // QSO field to scan (see fieldRaw) Field string `json:"field"` // QSO field to scan (see fieldRaw)
MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern" MatchBy string `json:"match_by,omitempty"` // "code" | "description" | "pattern"
ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring ExactMatch bool `json:"exact_match,omitempty"` // match the whole field vs substring
@@ -89,14 +90,14 @@ type Def struct {
OrRules []OrRule `json:"or_rules,omitempty"` OrRules []OrRule `json:"or_rules,omitempty"`
// --- Scope --- // --- Scope ---
DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any) DXCCFilter []int `json:"dxcc_filter"` // limit to these DXCC entities (nil = any)
ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands ValidBands []string `json:"valid_bands,omitempty"` // empty = all bands
ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes ValidModes []string `json:"valid_modes,omitempty"` // empty = all modes
Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all) Emission []string `json:"emission,omitempty"` // CW | DIGITAL | PHONE (empty = all)
// --- Confirmation --- // --- Confirmation ---
Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom Confirm []string `json:"confirm"` // worked-confirmed: lotw|qsl|eqsl|qrzcom|custom
Validate []string `json:"validate,omitempty"` // validated/granted sources Validate []string `json:"validate,omitempty"` // validated/granted sources
// NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but // NOT IMPLEMENTED. Kept so the values operators already typed are not lost, but
// nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their // nothing reads them: no ADIF export has ever written CREDIT_GRANTED. Their
// controls have been removed from the editor — a checkbox that quietly does // controls have been removed from the editor — a checkbox that quietly does
@@ -1326,7 +1327,11 @@ func setToSorted(m map[string]struct{}) []string {
return out return out
} }
var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m", "6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm"} var bandOrder = []string{"2190m", "630m", "160m", "80m", "60m", "40m", "30m", "20m", "17m", "15m", "12m", "10m",
"6m", "4m", "2m", "1.25m", "70cm", "33cm", "23cm", "13cm",
// Microwave. A band missing from this order sorts to the end of an award's
// band columns rather than in frequency order — and 3 cm was missing.
"9cm", "6cm", "3cm", "1.25cm", "6mm", "4mm", "2.5mm", "2mm", "1mm"}
func sortedBands(m map[string]int) []string { func sortedBands(m map[string]int) []string {
idx := map[string]int{} idx := map[string]int{}
+32
View File
@@ -776,6 +776,14 @@ func stateUserEqual(a, b RigState) bool {
func BandFromHz(hz int64) string { func BandFromHz(hz int64) string {
mhz := float64(hz) / 1_000_000 mhz := float64(hz) / 1_000_000
switch { switch {
// LF / MF, below 160 m. An operator on 630 m had their band come back empty,
// which then cost them the band on the QSO and in every award count.
case mhz >= 0.1357 && mhz <= 0.1378:
return "2190m"
case mhz >= 0.472 && mhz <= 0.479:
return "630m"
case mhz >= 0.501 && mhz <= 0.504:
return "560m"
case mhz >= 1.8 && mhz <= 2.0: case mhz >= 1.8 && mhz <= 2.0:
return "160m" return "160m"
case mhz >= 3.5 && mhz <= 4.0: case mhz >= 3.5 && mhz <= 4.0:
@@ -810,6 +818,30 @@ func BandFromHz(hz int64) string {
return "33cm" return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0: case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm" return "23cm"
// Microwave. The table used to stop at 23 cm, so 10 GHz — a band people
// actually work, and spot — resolved to an empty band: no band on the QSO, no
// band-slot, nothing in the awards. Ranges and names are the ADIF 3.1.7 ones,
// which is what an export has to carry.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
} }
return "" return ""
} }
+47 -25
View File
@@ -42,32 +42,32 @@ type ServerConfig struct {
// is emitted to the UI, so the table never has empty country cells // is emitted to the UI, so the table never has empty country cells
// flickering in for a few hundred ms. // flickering in for a few hundred ms.
type Spot struct { type Spot struct {
SourceID int64 `json:"source_id"` // ID of the cluster server this came from SourceID int64 `json:"source_id"` // ID of the cluster server this came from
SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers) SourceName string `json:"source_name"` // display name (handy in the UI when multiple servers)
Spotter string `json:"spotter"` // DE field Spotter string `json:"spotter"` // DE field
DXCall string `json:"dx_call"` // the DX station heard DXCall string `json:"dx_call"` // the DX station heard
FreqKHz float64 `json:"freq_khz"` FreqKHz float64 `json:"freq_khz"`
FreqHz int64 `json:"freq_hz"` FreqHz int64 `json:"freq_hz"`
Band string `json:"band,omitempty"` Band string `json:"band,omitempty"`
Comment string `json:"comment,omitempty"` Comment string `json:"comment,omitempty"`
Locator string `json:"locator,omitempty"` // spotter grid (optional) Locator string `json:"locator,omitempty"` // spotter grid (optional)
TimeUTC string `json:"time_utc,omitempty"` TimeUTC string `json:"time_utc,omitempty"`
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat) Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
Continent string `json:"continent,omitempty"` // 2-letter continent Continent string `json:"continent,omitempty"` // 2-letter continent
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid DistanceKm int `json:"distance_km,omitempty"` // great-circle km from operator's grid
ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator ShortPath int `json:"sp_deg,omitempty"` // azimuth (deg) short path from operator
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360 LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
ReceivedAt time.Time `json:"received_at"` ReceivedAt time.Time `json:"received_at"`
Raw string `json:"raw"` Raw string `json:"raw"`
// Historical marks a spot recovered from a SH/DX table rather than heard live. // Historical marks a spot recovered from a SH/DX table rather than heard live.
// It belongs in the grid, but must NOT fire alerts or reach the panadapter: // It belongs in the grid, but must NOT fire alerts or reach the panadapter:
// replaying 100 past spots would spam both, and a station spotted three hours // replaying 100 past spots would spam both, and a station spotted three hours
// ago is not on the air now. // ago is not on the air now.
Historical bool `json:"historical,omitempty"` Historical bool `json:"historical,omitempty"`
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app) POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
POTAName string `json:"pota_name,omitempty"` // park name POTAName string `json:"pota_name,omitempty"` // park name
} }
// State enumerates the per-server lifecycle. // State enumerates the per-server lifecycle.
@@ -122,14 +122,14 @@ type session struct {
onLine func(Line) onLine func(Line)
onStatus func() onStatus func()
mu sync.RWMutex mu sync.RWMutex
status ServerStatus status ServerStatus
conn net.Conn conn net.Conn
stopCh chan struct{} stopCh chan struct{}
doneCh chan struct{} doneCh chan struct{}
stopped bool // guards against double-stop on the same session stopped bool // guards against double-stop on the same session
spotsCnt int spotsCnt int
dbgN int // diagnostic: how many raw lines logged this connection dbgN int // diagnostic: how many raw lines logged this connection
} }
// Manager owns N sessions, one per enabled server. Safe for concurrent // Manager owns N sessions, one per enabled server. Safe for concurrent
@@ -560,8 +560,8 @@ const (
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …" // showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
// broadcast format, and therefore matched nothing at all: // broadcast format, and therefore matched nothing at all:
// //
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC> // 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
// freq dxcall date time comment <spotter> // freq dxcall date time comment <spotter>
// //
// This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast // This is why "SH/DX/100 does nothing": the 100 lines arrive, fail the broadcast
// regex, and get dropped. The decimal point in the frequency is required — it is // regex, and get dropped. The decimal point in the frequency is required — it is
@@ -682,6 +682,28 @@ func bandFromHz(hz int64) string {
return "33cm" return "33cm"
case mhz >= 1240.0 && mhz <= 1300.0: case mhz >= 1240.0 && mhz <= 1300.0:
return "23cm" return "23cm"
// Microwave — a 10 GHz spot used to land with no band at all, so it could
// not be filtered, matched against the log, or shown on a band map.
case mhz >= 2300.0 && mhz <= 2450.0:
return "13cm"
case mhz >= 3300.0 && mhz <= 3500.0:
return "9cm"
case mhz >= 5650.0 && mhz <= 5925.0:
return "6cm"
case mhz >= 10000.0 && mhz <= 10500.0:
return "3cm"
case mhz >= 24000.0 && mhz <= 24250.0:
return "1.25cm"
case mhz >= 47000.0 && mhz <= 47200.0:
return "6mm"
case mhz >= 75500.0 && mhz <= 81000.0:
return "4mm"
case mhz >= 119980.0 && mhz <= 123000.0:
return "2.5mm"
case mhz >= 134000.0 && mhz <= 149000.0:
return "2mm"
case mhz >= 241000.0 && mhz <= 250000.0:
return "1mm"
} }
return "" return ""
} }
+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.21.7" appVersion = "0.21.8"
// 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.