Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9d1fc1cc0 | ||
|
|
f43d41892e | ||
|
|
1f2e86c04d | ||
|
|
305da583e1 | ||
|
|
71bde30e24 | ||
|
|
31b13cfbc0 | ||
|
|
30d88276f3 | ||
|
|
b18db7acfc | ||
|
|
d303dee768 | ||
|
|
ad02583ef7 |
@@ -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.
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,4 +1,18 @@
|
|||||||
[
|
[
|
||||||
|
{
|
||||||
|
"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",
|
||||||
|
|||||||
@@ -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 '';
|
||||||
|
|||||||
@@ -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'];
|
||||||
|
|
||||||
|
|||||||
@@ -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 = [
|
||||||
|
|||||||
@@ -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,
|
||||||
@@ -2358,8 +2358,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 +2482,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 +2654,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 +2942,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 +3113,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 +4676,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 +5152,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 +5356,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 +5434,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,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
@@ -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{}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user