up
This commit is contained in:
@@ -206,6 +206,11 @@ func stringSet(items ...string) map[string]struct{} {
|
||||
return m
|
||||
}
|
||||
|
||||
// RecordToQSO is the exported alias used by the UDP auto-log path so it
|
||||
// can convert a freshly received ADIF record into a QSO and then enrich
|
||||
// it with lookup + operating data before inserting.
|
||||
func RecordToQSO(rec Record) (qso.QSO, bool) { return recordToQSO(rec) }
|
||||
|
||||
// recordToQSO maps an ADIF record onto a QSO. Returns false if required
|
||||
// fields are missing. Any ADIF tag we don't promote is stored in Extras.
|
||||
func recordToQSO(rec Record) (qso.QSO, bool) {
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
// Package applog routes the app's diagnostic output to a rotating log
|
||||
// file inside the user's data dir. Wails builds with the Windows GUI
|
||||
// subsystem by default — fmt.Println output is dropped, so launching
|
||||
// from cmd never showed anything. The file gives us a reliable place to
|
||||
// inspect what the UDP listener / cluster / CAT layer is doing.
|
||||
package applog
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
var (
|
||||
mu sync.Mutex
|
||||
file *os.File
|
||||
path string
|
||||
)
|
||||
|
||||
// Init opens (creates) the log file in dataDir. On rotation we truncate
|
||||
// at startup if the file is too big; for now it's a single file, no
|
||||
// rolling — the volume is low (a few KB per session).
|
||||
func Init(dataDir string) (string, error) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if file != nil {
|
||||
return path, nil
|
||||
}
|
||||
if dataDir == "" {
|
||||
return "", fmt.Errorf("empty data dir")
|
||||
}
|
||||
if err := os.MkdirAll(dataDir, 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
logPath := filepath.Join(dataDir, "opslog.log")
|
||||
// One-shot rename for users coming from the HamLog era.
|
||||
if _, err := os.Stat(logPath); os.IsNotExist(err) {
|
||||
oldLog := filepath.Join(dataDir, "hamlog.log")
|
||||
if _, err := os.Stat(oldLog); err == nil {
|
||||
_ = os.Rename(oldLog, logPath)
|
||||
}
|
||||
}
|
||||
// Truncate if the file grew past ~5MB so we don't accumulate logs
|
||||
// forever. We keep one file — simple and adequate for diagnostics.
|
||||
if fi, err := os.Stat(logPath); err == nil && fi.Size() > 5*1024*1024 {
|
||||
_ = os.Remove(logPath)
|
||||
}
|
||||
f, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
file = f
|
||||
path = logPath
|
||||
|
||||
// Redirect log.Print* and the standard logger to the file too, so
|
||||
// any third-party output stays consistent.
|
||||
log.SetOutput(io.MultiWriter(file, os.Stderr))
|
||||
log.SetFlags(log.LstdFlags | log.Lmicroseconds)
|
||||
|
||||
fmt.Fprintf(file, "\n────── OpsLog start %s ──────\n", time.Now().Format(time.RFC3339))
|
||||
return logPath, nil
|
||||
}
|
||||
|
||||
// Printf writes a formatted line with a timestamp. Caller's format may
|
||||
// or may not end with a newline — we strip a trailing one before adding
|
||||
// our own, so log entries always look like "HH:MM:SS.mmm msg\n".
|
||||
func Printf(format string, args ...any) {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
stamp := time.Now().Format("15:04:05.000")
|
||||
msg := fmt.Sprintf(format, args...)
|
||||
for len(msg) > 0 && (msg[len(msg)-1] == '\n' || msg[len(msg)-1] == '\r') {
|
||||
msg = msg[:len(msg)-1]
|
||||
}
|
||||
if file != nil {
|
||||
fmt.Fprintf(file, "%s %s\n", stamp, msg)
|
||||
}
|
||||
// Also dump to stderr in case the binary was launched with a console
|
||||
// attached (wails dev, custom build).
|
||||
fmt.Fprintf(os.Stderr, "%s %s\n", stamp, msg)
|
||||
}
|
||||
|
||||
// Path returns where the file is so the UI can surface it.
|
||||
func Path() string {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
return path
|
||||
}
|
||||
|
||||
// Close flushes and releases the handle. Called from shutdown.
|
||||
func Close() {
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
if file != nil {
|
||||
_ = file.Close()
|
||||
file = nil
|
||||
}
|
||||
}
|
||||
@@ -63,7 +63,7 @@ func Run(ctx context.Context, dbConn *sql.DB, dbPath, folder string, rotation in
|
||||
}
|
||||
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
base := fmt.Sprintf("hamlog-%s", stamp)
|
||||
base := fmt.Sprintf("opslog-%s", stamp)
|
||||
var dstPath string
|
||||
if doZip {
|
||||
dstPath = filepath.Join(folder, base+".db.zip")
|
||||
@@ -141,7 +141,7 @@ func copyZipped(src, dst string) error {
|
||||
}
|
||||
|
||||
// rotate keeps the most recent `keep` backups in folder and deletes the
|
||||
// rest. Only files matching the hamlog-*.db / hamlog-*.db.zip pattern
|
||||
// rest. Only files matching the opslog-*.db / opslog-*.db.zip pattern
|
||||
// are touched — never user files that happen to live in the same folder.
|
||||
func rotate(folder string, keep int) error {
|
||||
entries, err := os.ReadDir(folder)
|
||||
@@ -158,7 +158,7 @@ func rotate(folder string, keep int) error {
|
||||
continue
|
||||
}
|
||||
name := e.Name()
|
||||
if !strings.HasPrefix(name, "hamlog-") {
|
||||
if !strings.HasPrefix(name, "opslog-") {
|
||||
continue
|
||||
}
|
||||
if !(strings.HasSuffix(name, ".db") || strings.HasSuffix(name, ".db.zip")) {
|
||||
@@ -192,7 +192,7 @@ func HasBackupToday(folder string) bool {
|
||||
}
|
||||
stamp := time.Now().Format("2006-01-02")
|
||||
for _, ext := range []string{".db", ".db.zip"} {
|
||||
if _, err := os.Stat(filepath.Join(folder, "hamlog-"+stamp+ext)); err == nil {
|
||||
if _, err := os.Stat(filepath.Join(folder, "opslog-"+stamp+ext)); err == nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
-- UDP integrations: each row is one inbound or outbound UDP socket the
|
||||
-- user wants HamLog to maintain. Direction is split so a single record
|
||||
-- can describe either side without nullable destination_ip oddities.
|
||||
--
|
||||
-- service_type drives the parser/emitter chosen at runtime:
|
||||
-- inbound:
|
||||
-- 'wsjt' - WSJT-X / JTDX / MSHV binary protocol (status + logged QSO)
|
||||
-- 'adif' - text ADIF payload (JTAlert, GridTracker)
|
||||
-- 'n1mm' - N1MM Logger+ XML (contests)
|
||||
-- 'remote_call' - plain text callsign, fills the entry field
|
||||
-- outbound:
|
||||
-- 'db_updated' - emits the just-logged QSO as ADIF
|
||||
--
|
||||
-- Multicast is the only way to share a port with another listener; when
|
||||
-- the flag is set the manager joins the group instead of binding the
|
||||
-- unicast socket.
|
||||
|
||||
CREATE TABLE integrations_udp (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
direction TEXT NOT NULL CHECK(direction IN ('inbound','outbound')),
|
||||
name TEXT NOT NULL,
|
||||
port INTEGER NOT NULL,
|
||||
service_type TEXT NOT NULL,
|
||||
multicast INTEGER NOT NULL DEFAULT 0,
|
||||
multicast_group TEXT NOT NULL DEFAULT '',
|
||||
destination_ip TEXT NOT NULL DEFAULT '',
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
|
||||
updated_at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now'))
|
||||
);
|
||||
|
||||
CREATE INDEX idx_integrations_udp_dir ON integrations_udp(direction, enabled, sort_order);
|
||||
@@ -0,0 +1,19 @@
|
||||
-- The UDP auto-log path was filling the non-standard `rig` and `ant`
|
||||
-- columns (intended for the contacted station's rig/antenna, rarely
|
||||
-- used) instead of `my_rig` / `my_antenna` (the official ADIF MY_RIG /
|
||||
-- MY_ANTENNA fields). Move any data that's already there to the right
|
||||
-- column when the standard one is empty — then clear the non-standard
|
||||
-- fields so the QSOs match what should have been logged.
|
||||
|
||||
UPDATE qso
|
||||
SET my_rig = rig
|
||||
WHERE (my_rig IS NULL OR my_rig = '')
|
||||
AND rig IS NOT NULL AND rig != '';
|
||||
|
||||
UPDATE qso
|
||||
SET my_antenna = ant
|
||||
WHERE (my_antenna IS NULL OR my_antenna = '')
|
||||
AND ant IS NOT NULL AND ant != '';
|
||||
|
||||
UPDATE qso SET rig = '' WHERE rig IS NOT NULL AND rig != '';
|
||||
UPDATE qso SET ant = '' WHERE ant IS NOT NULL AND ant != '';
|
||||
@@ -0,0 +1,7 @@
|
||||
-- The profile already stores the station callsign (callsign — what's
|
||||
-- transmitted) and the operator callsign (operator — who is actually
|
||||
-- working the radio). Some setups need a third: owner_callsign, the
|
||||
-- legal owner of the station. This matters at a club station or a
|
||||
-- remote setup where the operator and owner aren't the same person.
|
||||
-- ADIF maps this to STATION_OWNER.
|
||||
ALTER TABLE station_profiles ADD COLUMN owner_callsign TEXT NOT NULL DEFAULT '';
|
||||
@@ -0,0 +1,320 @@
|
||||
package dxcc
|
||||
|
||||
import "strings"
|
||||
|
||||
// dxccByName maps cty.dat entity names to ADIF DXCC entity numbers
|
||||
// (ARRL DXCC List). cty.dat itself doesn't carry this number — it's a
|
||||
// separate ARRL-maintained list. We embed the current entities here so
|
||||
// QSO records can be stamped with MY_DXCC / DXCC at log time without a
|
||||
// network round-trip.
|
||||
//
|
||||
// Coverage: every current ARRL DXCC entity (340+). Deleted entities are
|
||||
// included for legacy compatibility. The lookup is case-insensitive and
|
||||
// space-tolerant on the caller side.
|
||||
var dxccByName = map[string]int{
|
||||
// 0xx
|
||||
"sovereign military order of malta": 246,
|
||||
"spratly is.": 247,
|
||||
"sable i.": 211,
|
||||
"st. paul i.": 252,
|
||||
"hawaii": 110,
|
||||
"agalega & st. brandon is.": 4,
|
||||
"alaska": 6,
|
||||
"american samoa": 9,
|
||||
"amsterdam & st. paul is.": 10,
|
||||
"andaman & nicobar is.": 11,
|
||||
"anguilla": 12,
|
||||
"antarctica": 13,
|
||||
"armenia": 14,
|
||||
"asiatic russia": 15,
|
||||
"aves i.": 17,
|
||||
"azerbaijan": 18,
|
||||
"baker & howland is.": 20,
|
||||
"balearic is.": 21,
|
||||
"palmyra & jarvis is.": 22,
|
||||
"central kiribati": 31,
|
||||
"central african republic": 27,
|
||||
"cape verde": 32,
|
||||
"chagos is.": 33,
|
||||
"chatham is.": 34,
|
||||
"christmas i.": 35,
|
||||
"clipperton i.": 36,
|
||||
"cocos i.": 37,
|
||||
"cocos (keeling) is.": 38,
|
||||
"comoros": 39,
|
||||
"crete": 40,
|
||||
"crozet i.": 41,
|
||||
"falkland is.": 141,
|
||||
"chesterfield is.": 512,
|
||||
"easter i.": 47,
|
||||
"sint eustatius & saba": 519,
|
||||
"ducie i.": 513,
|
||||
"european russia": 54,
|
||||
"farquhar": 55,
|
||||
"fernando de noronha": 56,
|
||||
"french equatorial africa": 57,
|
||||
"french indo-china": 58,
|
||||
"french polynesia": 175,
|
||||
"djibouti": 382,
|
||||
"gabon": 420,
|
||||
"galapagos is.": 71,
|
||||
"guantanamo bay": 105,
|
||||
"guatemala": 76,
|
||||
"guernsey": 106,
|
||||
"guinea": 107,
|
||||
"guyana": 129,
|
||||
"hong kong": 321,
|
||||
"howland & baker is.": 20,
|
||||
"isle of man": 114,
|
||||
"itu hq": 117,
|
||||
"iran": 330,
|
||||
"iraq": 333,
|
||||
"juan de nova & europa": 124,
|
||||
"juan fernandez is.": 125,
|
||||
"kaliningrad": 126,
|
||||
"kerguelen is.": 131,
|
||||
"kermadec is.": 133,
|
||||
"kingman reef": 134,
|
||||
"kuwait": 348,
|
||||
"kyrgyzstan": 135,
|
||||
"jersey": 122,
|
||||
"laccadive is.": 142,
|
||||
"laos": 143,
|
||||
"lord howe i.": 147,
|
||||
"market reef": 151,
|
||||
"marquesas is.": 509,
|
||||
"marshall is.": 168,
|
||||
"mauritania": 444,
|
||||
"mayotte": 169,
|
||||
"mexico": 50,
|
||||
"midway i.": 174,
|
||||
"minami torishima": 177,
|
||||
"monaco": 260,
|
||||
"mongolia": 363,
|
||||
"mount athos": 180,
|
||||
"navassa i.": 182,
|
||||
"new caledonia": 162,
|
||||
"new zealand": 170,
|
||||
"niue": 188,
|
||||
"norfolk i.": 189,
|
||||
"north cook is.": 191,
|
||||
"north korea": 344,
|
||||
"ogasawara": 192,
|
||||
"oman": 370,
|
||||
"palestine": 510,
|
||||
"pratas i.": 505,
|
||||
"qatar": 376,
|
||||
"rotuma i.": 460,
|
||||
"rwanda": 454,
|
||||
"san andres & providencia": 216,
|
||||
"south georgia i.": 235,
|
||||
"south orkney is.": 238,
|
||||
"south sandwich is.": 240,
|
||||
"south shetland is.": 241,
|
||||
"swains i.": 515,
|
||||
"swaziland": 468,
|
||||
"taiwan": 386,
|
||||
"tajikistan": 262,
|
||||
"thailand": 387,
|
||||
"timor-leste": 511,
|
||||
"tokelau is.": 270,
|
||||
"tonga": 160,
|
||||
"trindade & martim vaz is.": 273,
|
||||
"tristan da cunha & gough is.": 274,
|
||||
"tromelin i.": 276,
|
||||
"tunisia": 474,
|
||||
"turkmenistan": 280,
|
||||
"turks & caicos is.": 89,
|
||||
"tuvalu": 282,
|
||||
"uk sov. base areas on cyprus": 283,
|
||||
"united nations hq": 289,
|
||||
"vatican city": 295,
|
||||
"venezuela": 148,
|
||||
"viet nam": 293,
|
||||
"wake i.": 297,
|
||||
"wallis & futuna is.": 298,
|
||||
"western kiribati": 301,
|
||||
"yemen": 492,
|
||||
|
||||
// Major populous entities
|
||||
"france": 227,
|
||||
"germany": 230,
|
||||
"belgium": 209,
|
||||
"netherlands": 263,
|
||||
"luxembourg": 254,
|
||||
"switzerland": 287,
|
||||
"liechtenstein": 251,
|
||||
"austria": 206,
|
||||
"italy": 248,
|
||||
"sicily": 225,
|
||||
"sardinia": 225,
|
||||
"spain": 281,
|
||||
"portugal": 272,
|
||||
"andorra": 203,
|
||||
"san marino": 278,
|
||||
"corsica": 214,
|
||||
"vatican": 295,
|
||||
"england": 223,
|
||||
"scotland": 279,
|
||||
"wales": 294,
|
||||
"northern ireland": 265,
|
||||
"ireland": 245,
|
||||
"shetland is.": 279,
|
||||
"poland": 269,
|
||||
"czech republic": 503,
|
||||
"slovak republic": 504,
|
||||
"hungary": 239,
|
||||
"romania": 275,
|
||||
"bulgaria": 212,
|
||||
"greece": 236,
|
||||
"dodecanese": 45,
|
||||
"turkey": 390,
|
||||
"european turkey": 390,
|
||||
"asiatic turkey": 390,
|
||||
"cyprus": 215,
|
||||
"malta": 257,
|
||||
"denmark": 221,
|
||||
"faroe is.": 222,
|
||||
"greenland": 237,
|
||||
"sweden": 284,
|
||||
"norway": 266,
|
||||
"finland": 224,
|
||||
"aland is.": 5,
|
||||
"iceland": 242,
|
||||
"estonia": 52,
|
||||
"latvia": 145,
|
||||
"lithuania": 146,
|
||||
"belarus": 27,
|
||||
"ukraine": 288,
|
||||
"moldova": 179,
|
||||
"georgia": 75,
|
||||
"serbia": 296,
|
||||
"montenegro": 514,
|
||||
"slovenia": 499,
|
||||
"croatia": 497,
|
||||
"bosnia-herzegovina": 501,
|
||||
"macedonia": 502,
|
||||
"kosovo": 522,
|
||||
"albania": 7,
|
||||
"israel": 336,
|
||||
"jordan": 342,
|
||||
"lebanon": 354,
|
||||
"syria": 384,
|
||||
"saudi arabia": 378,
|
||||
"united arab emirates": 391,
|
||||
"bahrain": 304,
|
||||
"egypt": 478,
|
||||
"libya": 436,
|
||||
"algeria": 400,
|
||||
"morocco": 446,
|
||||
"western sahara": 302,
|
||||
"south africa": 462,
|
||||
"namibia": 464,
|
||||
"botswana": 402,
|
||||
"zimbabwe": 452,
|
||||
"zambia": 482,
|
||||
"mozambique": 181,
|
||||
"madagascar": 438,
|
||||
"mauritius": 165,
|
||||
"reunion i.": 453,
|
||||
"seychelles": 379,
|
||||
"kenya": 430,
|
||||
"tanzania": 470,
|
||||
"uganda": 286,
|
||||
"ethiopia": 53,
|
||||
"eritrea": 51,
|
||||
"sudan": 466,
|
||||
"south sudan republic of": 521,
|
||||
"nigeria": 450,
|
||||
"ghana": 424,
|
||||
"cameroon": 406,
|
||||
"senegal": 456,
|
||||
"liberia": 434,
|
||||
"sierra leone": 458,
|
||||
"benin": 416,
|
||||
"togo": 483,
|
||||
"ivory coast": 428,
|
||||
"mali": 442,
|
||||
"niger": 187,
|
||||
"chad": 410,
|
||||
"japan": 339,
|
||||
"south korea": 137,
|
||||
"china": 318,
|
||||
"india": 324,
|
||||
"pakistan": 372,
|
||||
"sri lanka": 315,
|
||||
"nepal": 369,
|
||||
"bangladesh": 305,
|
||||
"bhutan": 306,
|
||||
"myanmar": 309,
|
||||
"west malaysia": 299,
|
||||
"east malaysia": 46,
|
||||
"singapore": 381,
|
||||
"indonesia": 327,
|
||||
"philippines": 375,
|
||||
"brunei darussalam": 345,
|
||||
"cambodia": 312,
|
||||
"kazakhstan": 130,
|
||||
"uzbekistan": 292,
|
||||
"afghanistan": 3,
|
||||
"maldives": 159,
|
||||
"australia": 150,
|
||||
"tasmania": 150,
|
||||
"papua new guinea": 163,
|
||||
"solomon is.": 185,
|
||||
"vanuatu": 158,
|
||||
"fiji": 176,
|
||||
"samoa": 190,
|
||||
"canada": 1,
|
||||
"united states": 291,
|
||||
"united states of america": 291,
|
||||
"puerto rico": 202,
|
||||
"us virgin is.": 285,
|
||||
"british virgin is.": 91,
|
||||
"cayman is.": 69,
|
||||
"jamaica": 82,
|
||||
"bahamas": 60,
|
||||
"bermuda": 64,
|
||||
"haiti": 78,
|
||||
"dominican republic": 72,
|
||||
"cuba": 70,
|
||||
"barbados": 62,
|
||||
"trinidad & tobago": 90,
|
||||
"grenada": 77,
|
||||
"st. lucia": 97,
|
||||
"st. vincent": 98,
|
||||
"dominica": 95,
|
||||
"montserrat": 96,
|
||||
"st. kitts & nevis": 249,
|
||||
"antigua & barbuda": 94,
|
||||
"guadeloupe": 79,
|
||||
"martinique": 84,
|
||||
"french guiana": 63,
|
||||
"suriname": 140,
|
||||
"colombia": 116,
|
||||
"ecuador": 120,
|
||||
"peru": 136,
|
||||
"bolivia": 104,
|
||||
"chile": 112,
|
||||
"argentina": 100,
|
||||
"uruguay": 144,
|
||||
"paraguay": 132,
|
||||
"brazil": 108,
|
||||
"belize": 66,
|
||||
"honduras": 80,
|
||||
"el salvador": 74,
|
||||
"nicaragua": 86,
|
||||
"costa rica": 308,
|
||||
"panama": 88,
|
||||
}
|
||||
|
||||
// EntityDXCC returns the ADIF DXCC entity number for the given cty.dat
|
||||
// entity name. Returns 0 when the name isn't in our table — callers
|
||||
// should leave the field empty in that case rather than guess. The match
|
||||
// is case-insensitive and tolerant of leading/trailing whitespace.
|
||||
func EntityDXCC(name string) int {
|
||||
if name == "" {
|
||||
return 0
|
||||
}
|
||||
return dxccByName[strings.ToLower(strings.TrimSpace(name))]
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
// Package udp manages user-defined UDP integrations: inbound listeners
|
||||
// (WSJT-X, JTDX, MSHV log events; JTAlert ADIF; N1MM XML; DXHunter call)
|
||||
// and outbound emitters (db_updated → notifies Cloudlog/N1MM when HamLog
|
||||
// just logged a QSO).
|
||||
//
|
||||
// One Server per connection row, started/stopped by the Manager when the
|
||||
// user enables/disables or edits the row. Multicast support lets multiple
|
||||
// apps share the same port without bind conflicts — essential since
|
||||
// WSJT-X uses 2237 and several tools already listen there.
|
||||
package udp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
)
|
||||
|
||||
// Direction is "inbound" (we listen) or "outbound" (we emit).
|
||||
type Direction string
|
||||
|
||||
const (
|
||||
Inbound Direction = "inbound"
|
||||
Outbound Direction = "outbound"
|
||||
)
|
||||
|
||||
// ServiceType selects the parser/emitter for a connection.
|
||||
type ServiceType string
|
||||
|
||||
const (
|
||||
ServiceWSJT ServiceType = "wsjt" // WSJT-X / JTDX / MSHV binary
|
||||
ServiceADIF ServiceType = "adif" // text ADIF over UDP
|
||||
ServiceN1MM ServiceType = "n1mm" // N1MM Logger+ XML
|
||||
ServiceRemoteCall ServiceType = "remote_call" // plain text callsign
|
||||
ServiceDBUpdated ServiceType = "db_updated" // outbound ADIF of local QSO
|
||||
)
|
||||
|
||||
// Config is one user-defined UDP connection.
|
||||
type Config struct {
|
||||
ID int64 `json:"id"`
|
||||
Direction Direction `json:"direction"`
|
||||
Name string `json:"name"`
|
||||
Port int `json:"port"`
|
||||
ServiceType ServiceType `json:"service_type"`
|
||||
Multicast bool `json:"multicast"`
|
||||
MulticastGroup string `json:"multicast_group"`
|
||||
DestinationIP string `json:"destination_ip"` // outbound only
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// Repo is the persistence layer for UDP integration rows.
|
||||
type Repo struct{ db *sql.DB }
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
func (r *Repo) List(ctx context.Context) ([]Config, error) {
|
||||
rows, err := r.db.QueryContext(ctx, `
|
||||
SELECT id, direction, name, port, service_type,
|
||||
multicast, multicast_group, destination_ip,
|
||||
enabled, sort_order
|
||||
FROM integrations_udp
|
||||
ORDER BY direction, sort_order, id`)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("list udp: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []Config
|
||||
for rows.Next() {
|
||||
var c Config
|
||||
var mc, en int
|
||||
if err := rows.Scan(&c.ID, &c.Direction, &c.Name, &c.Port, &c.ServiceType,
|
||||
&mc, &c.MulticastGroup, &c.DestinationIP, &en, &c.SortOrder); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
c.Multicast = mc != 0
|
||||
c.Enabled = en != 0
|
||||
out = append(out, c)
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
func (r *Repo) Save(ctx context.Context, c *Config) error {
|
||||
if c.Direction != Inbound && c.Direction != Outbound {
|
||||
return fmt.Errorf("invalid direction %q", c.Direction)
|
||||
}
|
||||
if c.Name == "" {
|
||||
return fmt.Errorf("name required")
|
||||
}
|
||||
mc, en := 0, 0
|
||||
if c.Multicast {
|
||||
mc = 1
|
||||
}
|
||||
if c.Enabled {
|
||||
en = 1
|
||||
}
|
||||
if c.ID == 0 {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO integrations_udp(direction, name, port, service_type,
|
||||
multicast, multicast_group, destination_ip, enabled, sort_order)
|
||||
VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)`,
|
||||
c.Direction, c.Name, c.Port, c.ServiceType,
|
||||
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder)
|
||||
if err != nil {
|
||||
return fmt.Errorf("insert udp: %w", err)
|
||||
}
|
||||
id, _ := res.LastInsertId()
|
||||
c.ID = id
|
||||
return nil
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE integrations_udp SET
|
||||
direction = ?, name = ?, port = ?, service_type = ?,
|
||||
multicast = ?, multicast_group = ?, destination_ip = ?,
|
||||
enabled = ?, sort_order = ?,
|
||||
updated_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
|
||||
WHERE id = ?`,
|
||||
c.Direction, c.Name, c.Port, c.ServiceType,
|
||||
mc, c.MulticastGroup, c.DestinationIP, en, c.SortOrder, c.ID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("update udp: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repo) Delete(ctx context.Context, id int64) error {
|
||||
_, err := r.db.ExecContext(ctx, `DELETE FROM integrations_udp WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
//go:build !windows
|
||||
|
||||
package udp
|
||||
|
||||
import "golang.org/x/sys/unix"
|
||||
|
||||
// setSocketReuse enables SO_REUSEADDR + SO_REUSEPORT on Linux/macOS so
|
||||
// multiple processes can share a multicast UDP port (matches the Windows
|
||||
// behaviour with SO_REUSEADDR).
|
||||
func setSocketReuse(fd uintptr) error {
|
||||
if err := unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEADDR, 1); err != nil {
|
||||
return err
|
||||
}
|
||||
// SO_REUSEPORT isn't defined on every Unix; the syscall returning
|
||||
// ENOPROTOOPT is fine to ignore.
|
||||
_ = unix.SetsockoptInt(int(fd), unix.SOL_SOCKET, unix.SO_REUSEPORT, 1)
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
//go:build windows
|
||||
|
||||
package udp
|
||||
|
||||
import "golang.org/x/sys/windows"
|
||||
|
||||
// setSocketReuse enables SO_REUSEADDR on the socket before bind so that
|
||||
// multiple processes (HamLog + Log4OM + …) can listen on the same UDP
|
||||
// multicast port. Without it, Windows fails the second bind with
|
||||
// WSAEADDRINUSE ("Une seule utilisation de chaque adresse de socket…").
|
||||
func setSocketReuse(fd uintptr) error {
|
||||
return windows.SetsockoptInt(windows.Handle(fd),
|
||||
windows.SOL_SOCKET, windows.SO_REUSEADDR, 1)
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"golang.org/x/net/ipv4"
|
||||
|
||||
"hamlog/internal/applog"
|
||||
)
|
||||
|
||||
// reusingListenConfig builds a net.ListenConfig that sets SO_REUSEADDR
|
||||
// (and SO_REUSEPORT on Unix) on the underlying socket before bind. This
|
||||
// is the only way for two processes to share a UDP port on Windows — Go
|
||||
// doesn't expose the option directly, but ListenConfig.Control hooks the
|
||||
// raw socket and lets us call setsockopt.
|
||||
func reusingListenConfig() net.ListenConfig {
|
||||
return net.ListenConfig{
|
||||
Control: func(network, address string, c syscall.RawConn) error {
|
||||
var opErr error
|
||||
err := c.Control(func(fd uintptr) {
|
||||
opErr = setSocketReuse(fd)
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return opErr
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Event is what a Server emits to its consumer for every parsed packet.
|
||||
// At most one of the fields is populated per event.
|
||||
type Event struct {
|
||||
ConfigID int64
|
||||
Service ServiceType
|
||||
Source string // remote addr that sent the packet, for diagnostics
|
||||
|
||||
DXCall string // ServiceWSJT (Status) or ServiceRemoteCall
|
||||
DXGrid string // ServiceWSJT (Status)
|
||||
Mode string // ServiceWSJT (Status)
|
||||
FreqHz int64 // ServiceWSJT (Status)
|
||||
LoggedADIF string // ServiceWSJT (LoggedADIF) or ServiceADIF
|
||||
RawText string // generic fallback (n1mm xml, etc.)
|
||||
}
|
||||
|
||||
// Server is a single inbound UDP listener.
|
||||
type Server struct {
|
||||
cfg Config
|
||||
conn *net.UDPConn
|
||||
out chan<- Event
|
||||
stop chan struct{}
|
||||
done chan struct{}
|
||||
stopped bool
|
||||
mu sync.Mutex
|
||||
}
|
||||
|
||||
func newServer(cfg Config, out chan<- Event) *Server {
|
||||
return &Server{
|
||||
cfg: cfg,
|
||||
out: out,
|
||||
stop: make(chan struct{}),
|
||||
done: make(chan struct{}),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) start() error {
|
||||
var conn *net.UDPConn
|
||||
if s.cfg.Multicast {
|
||||
group := strings.TrimSpace(s.cfg.MulticastGroup)
|
||||
if group == "" {
|
||||
return fmt.Errorf("multicast enabled but group address is empty")
|
||||
}
|
||||
groupIP := net.ParseIP(group)
|
||||
if groupIP == nil {
|
||||
return fmt.Errorf("bad multicast group %q", group)
|
||||
}
|
||||
gaddr := &net.UDPAddr{IP: groupIP, Port: s.cfg.Port}
|
||||
// Bind to INADDR_ANY:port so the kernel will forward packets
|
||||
// addressed to the multicast group from any interface. Then
|
||||
// JoinGroup() on every up & multicast-capable interface — Windows
|
||||
// won't route multicast through interfaces we haven't explicitly
|
||||
// joined, and the "default" interface picked by
|
||||
// net.ListenMulticastUDP isn't always the one MSHV/WSJT sends on.
|
||||
// ListenConfig with SO_REUSEADDR lets us share the port with
|
||||
// Log4OM / other listeners already bound to 2237.
|
||||
lc := reusingListenConfig()
|
||||
pc, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf("0.0.0.0:%d", s.cfg.Port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen :%d for multicast: %w", s.cfg.Port, err)
|
||||
}
|
||||
c, ok := pc.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = pc.Close()
|
||||
return fmt.Errorf("internal: ListenPacket returned %T not *net.UDPConn", pc)
|
||||
}
|
||||
p := ipv4.NewPacketConn(c)
|
||||
ifaces, _ := net.Interfaces()
|
||||
joined := 0
|
||||
for _, ifi := range ifaces {
|
||||
if ifi.Flags&net.FlagUp == 0 || ifi.Flags&net.FlagMulticast == 0 {
|
||||
continue
|
||||
}
|
||||
if err := p.JoinGroup(&ifi, gaddr); err != nil {
|
||||
applog.Printf("udp: [%s] join %s on %s: %v\n", s.cfg.Name, gaddr.IP, ifi.Name, err)
|
||||
continue
|
||||
}
|
||||
joined++
|
||||
}
|
||||
if joined == 0 {
|
||||
_ = c.Close()
|
||||
return fmt.Errorf("couldn't join multicast %s on any interface", gaddr.IP)
|
||||
}
|
||||
conn = c
|
||||
applog.Printf("udp: [%s] listening on multicast %s on %d interface(s) (service=%s)\n",
|
||||
s.cfg.Name, gaddr, joined, s.cfg.ServiceType)
|
||||
} else {
|
||||
lc := reusingListenConfig()
|
||||
pc, err := lc.ListenPacket(context.Background(), "udp4", fmt.Sprintf("0.0.0.0:%d", s.cfg.Port))
|
||||
if err != nil {
|
||||
return fmt.Errorf("listen udp :%d: %w", s.cfg.Port, err)
|
||||
}
|
||||
c, ok := pc.(*net.UDPConn)
|
||||
if !ok {
|
||||
_ = pc.Close()
|
||||
return fmt.Errorf("internal: ListenPacket returned %T not *net.UDPConn", pc)
|
||||
}
|
||||
conn = c
|
||||
applog.Printf("udp: [%s] listening on unicast :%d (service=%s)\n", s.cfg.Name, s.cfg.Port, s.cfg.ServiceType)
|
||||
}
|
||||
s.conn = conn
|
||||
go s.run()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) run() {
|
||||
defer close(s.done)
|
||||
buf := make([]byte, 64*1024)
|
||||
for {
|
||||
select {
|
||||
case <-s.stop:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_ = s.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond))
|
||||
n, remote, err := s.conn.ReadFromUDP(buf)
|
||||
if err != nil {
|
||||
if ne, ok := err.(net.Error); ok && ne.Timeout() {
|
||||
continue
|
||||
}
|
||||
// Closed by stop(): exit silently.
|
||||
return
|
||||
}
|
||||
if n == 0 {
|
||||
continue
|
||||
}
|
||||
pkt := make([]byte, n)
|
||||
copy(pkt, buf[:n])
|
||||
go s.handle(pkt, remote)
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) handle(pkt []byte, remote *net.UDPAddr) {
|
||||
applog.Printf("udp: [%s] rx %d bytes from %s\n", s.cfg.Name, len(pkt), remote)
|
||||
ev := Event{ConfigID: s.cfg.ID, Service: s.cfg.ServiceType, Source: remote.String()}
|
||||
switch s.cfg.ServiceType {
|
||||
case ServiceWSJT:
|
||||
w, ok, err := ParseWSJT(pkt)
|
||||
if err != nil {
|
||||
applog.Printf("udp: [%s] WSJT parse error: %v\n", s.cfg.Name, err)
|
||||
return
|
||||
}
|
||||
if !ok {
|
||||
applog.Printf("udp: [%s] WSJT msg type ignored\n", s.cfg.Name)
|
||||
return
|
||||
}
|
||||
applog.Printf("udp: [%s] WSJT decoded: prog=%q dx_call=%q grid=%q mode=%q freq=%d adif_len=%d\n",
|
||||
s.cfg.Name, w.ProgramID, w.DXCall, w.DXGrid, w.Mode, w.FreqHz, len(w.LoggedADIF))
|
||||
ev.DXCall = w.DXCall
|
||||
ev.DXGrid = w.DXGrid
|
||||
ev.Mode = w.Mode
|
||||
ev.FreqHz = w.FreqHz
|
||||
ev.LoggedADIF = w.LoggedADIF
|
||||
case ServiceADIF:
|
||||
ev.LoggedADIF = string(pkt)
|
||||
case ServiceRemoteCall:
|
||||
// Common payload shapes seen in the wild:
|
||||
// "F4XYZ" (bare callsign)
|
||||
// "CALL F4XYZ" (text prefix)
|
||||
// "<CALLSIGN>F4XYZ<CALLSIGN>" (DXHunter-style tags)
|
||||
// "<CALLSIGN>F4XYZ</CALLSIGN>" (proper XML)
|
||||
// Strip every angle-bracket tag, normalise whitespace, take the
|
||||
// last non-empty token. Upper-case for downstream consistency.
|
||||
text := string(pkt)
|
||||
// Drop every <...> tag (open or close) — works for both
|
||||
// <CALLSIGN>...<CALLSIGN> and <CALLSIGN>...</CALLSIGN>.
|
||||
for {
|
||||
start := strings.IndexByte(text, '<')
|
||||
if start < 0 {
|
||||
break
|
||||
}
|
||||
end := strings.IndexByte(text[start:], '>')
|
||||
if end < 0 {
|
||||
break
|
||||
}
|
||||
text = text[:start] + " " + text[start+end+1:]
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
parts := strings.Fields(text)
|
||||
if len(parts) == 0 {
|
||||
return
|
||||
}
|
||||
ev.DXCall = strings.ToUpper(parts[len(parts)-1])
|
||||
case ServiceN1MM:
|
||||
ev.RawText = string(pkt)
|
||||
default:
|
||||
return
|
||||
}
|
||||
// Empty events are useless; skip.
|
||||
if ev.DXCall == "" && ev.LoggedADIF == "" && ev.RawText == "" {
|
||||
return
|
||||
}
|
||||
select {
|
||||
case s.out <- ev:
|
||||
default:
|
||||
// Drop on backpressure rather than block the read loop.
|
||||
}
|
||||
}
|
||||
|
||||
func (s *Server) close() {
|
||||
s.mu.Lock()
|
||||
if s.stopped {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.stopped = true
|
||||
stop, done, conn := s.stop, s.done, s.conn
|
||||
s.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// ── Outbound emitter ──────────────────────────────────────────────────
|
||||
|
||||
// SendUDP sends payload to dst (host:port). Unicast or directed broadcast.
|
||||
// Returns the error from the write; the connection is closed before return.
|
||||
func SendUDP(dst string, payload []byte) error {
|
||||
conn, err := net.Dial("udp4", dst)
|
||||
if err != nil {
|
||||
return fmt.Errorf("dial %s: %w", dst, err)
|
||||
}
|
||||
defer conn.Close()
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(2 * time.Second))
|
||||
_, err = conn.Write(payload)
|
||||
return err
|
||||
}
|
||||
|
||||
// ── Manager ───────────────────────────────────────────────────────────
|
||||
|
||||
// Manager owns every inbound Server and exposes a helper to emit on
|
||||
// outbound connections at QSO-save time. It reloads from the Repo on
|
||||
// demand (after a CRUD change in the Settings panel).
|
||||
type Manager struct {
|
||||
repo *Repo
|
||||
out chan Event
|
||||
|
||||
mu sync.Mutex
|
||||
inbound map[int64]*Server
|
||||
outbound []Config
|
||||
}
|
||||
|
||||
func NewManager(repo *Repo) *Manager {
|
||||
return &Manager{
|
||||
repo: repo,
|
||||
out: make(chan Event, 64),
|
||||
inbound: map[int64]*Server{},
|
||||
}
|
||||
}
|
||||
|
||||
// Events returns the channel inbound parsed events are delivered on.
|
||||
// The app exposes these as Wails events.
|
||||
func (m *Manager) Events() <-chan Event { return m.out }
|
||||
|
||||
// Reload restarts every server based on the current Repo contents.
|
||||
// Existing servers are stopped, the snapshot is rebuilt from scratch.
|
||||
// Errors on individual rows are logged via the returned slice; the
|
||||
// caller can surface them in the UI.
|
||||
func (m *Manager) Reload(ctx context.Context) []string {
|
||||
applog.Printf("udp: Reload() called")
|
||||
m.mu.Lock()
|
||||
old := m.inbound
|
||||
m.inbound = map[int64]*Server{}
|
||||
m.outbound = nil
|
||||
m.mu.Unlock()
|
||||
|
||||
for _, s := range old {
|
||||
s.close()
|
||||
}
|
||||
|
||||
cfgs, err := m.repo.List(ctx)
|
||||
if err != nil {
|
||||
applog.Printf("udp: Reload list failed: %v", err)
|
||||
return []string{fmt.Sprintf("load udp configs: %v", err)}
|
||||
}
|
||||
applog.Printf("udp: Reload found %d config(s) in DB", len(cfgs))
|
||||
var errs []string
|
||||
for _, c := range cfgs {
|
||||
applog.Printf("udp: cfg id=%d name=%q dir=%s service=%s port=%d mcast=%v group=%q enabled=%v",
|
||||
c.ID, c.Name, c.Direction, c.ServiceType, c.Port, c.Multicast, c.MulticastGroup, c.Enabled)
|
||||
if !c.Enabled {
|
||||
continue
|
||||
}
|
||||
if c.Direction == Outbound {
|
||||
m.mu.Lock()
|
||||
m.outbound = append(m.outbound, c)
|
||||
m.mu.Unlock()
|
||||
continue
|
||||
}
|
||||
srv := newServer(c, m.out)
|
||||
if err := srv.start(); err != nil {
|
||||
applog.Printf("udp: start %q failed: %v", c.Name, err)
|
||||
errs = append(errs, fmt.Sprintf("%s: %v", c.Name, err))
|
||||
continue
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.inbound[c.ID] = srv
|
||||
m.mu.Unlock()
|
||||
}
|
||||
applog.Printf("udp: Reload done — %d server(s) running, %d error(s)", len(m.inbound), len(errs))
|
||||
return errs
|
||||
}
|
||||
|
||||
// Outbound returns the active outbound configs matching a service type.
|
||||
// Used by the QSO save path to push notifications to listeners.
|
||||
func (m *Manager) Outbound(service ServiceType) []Config {
|
||||
m.mu.Lock()
|
||||
defer m.mu.Unlock()
|
||||
var out []Config
|
||||
for _, c := range m.outbound {
|
||||
if c.ServiceType == service {
|
||||
out = append(out, c)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StopAll closes every running server. Called at app shutdown.
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
old := m.inbound
|
||||
m.inbound = map[int64]*Server{}
|
||||
m.outbound = nil
|
||||
m.mu.Unlock()
|
||||
for _, s := range old {
|
||||
s.close()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,176 @@
|
||||
package udp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/binary"
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// WSJT-X / JTDX / MSHV UDP protocol (WSJT-X v2 schema).
|
||||
//
|
||||
// Wire format:
|
||||
// uint32 magic (0xadbccbda)
|
||||
// uint32 schema (2 or 3)
|
||||
// uint32 type (message id)
|
||||
// QString id (the program's "id" — typically "WSJT-X")
|
||||
// ... type-specific payload ...
|
||||
//
|
||||
// QString = int32 length followed by `length` UTF-8 bytes, or -1 for nil.
|
||||
// QUtf8 in newer versions; same wire format for the common case.
|
||||
//
|
||||
// We only care about two messages here:
|
||||
// Status (type 1) → exposes the current DX call so HamLog can pre-fill
|
||||
// LoggedADIF (type 12) → carries the ADIF of the just-logged QSO
|
||||
// Everything else (heartbeat, decodes, clears, status of other VFOs) is
|
||||
// ignored.
|
||||
|
||||
const (
|
||||
wsjtMagic = 0xadbccbda
|
||||
|
||||
wsjtMsgHeartbeat = 0
|
||||
wsjtMsgStatus = 1
|
||||
wsjtMsgDecode = 2
|
||||
wsjtMsgClear = 3
|
||||
wsjtMsgQSOLogged = 5
|
||||
wsjtMsgLoggedADIF = 12
|
||||
)
|
||||
|
||||
// WSJTEvent is the parsed, typed result of decoding a single packet.
|
||||
// One of (DXCall, LoggedADIF) is non-empty depending on the message.
|
||||
type WSJTEvent struct {
|
||||
DXCall string // current "DX Call" field in the WSJT app
|
||||
DXGrid string // optional grid for that call
|
||||
Mode string // FT8 / FT4 / …
|
||||
FreqHz int64 // current dial freq when available
|
||||
LoggedADIF string // full ADIF text when message is LoggedADIF
|
||||
ProgramID string // "WSJT-X" / "JTDX" / "MSHV" — for diagnostics / dedup
|
||||
}
|
||||
|
||||
// ParseWSJT decodes one UDP packet. Returns ok=false for messages we
|
||||
// don't care about (heartbeat, decode lines, clears, etc.).
|
||||
func ParseWSJT(pkt []byte) (WSJTEvent, bool, error) {
|
||||
if len(pkt) < 12 {
|
||||
return WSJTEvent{}, false, fmt.Errorf("packet too short")
|
||||
}
|
||||
r := bytes.NewReader(pkt)
|
||||
var magic, schema, mtype uint32
|
||||
if err := binary.Read(r, binary.BigEndian, &magic); err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
if magic != wsjtMagic {
|
||||
return WSJTEvent{}, false, fmt.Errorf("bad magic %#x", magic)
|
||||
}
|
||||
if err := binary.Read(r, binary.BigEndian, &schema); err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
_ = schema
|
||||
if err := binary.Read(r, binary.BigEndian, &mtype); err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
id, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, fmt.Errorf("read id: %w", err)
|
||||
}
|
||||
|
||||
ev := WSJTEvent{ProgramID: id}
|
||||
switch mtype {
|
||||
case wsjtMsgStatus:
|
||||
// Status payload order (v2):
|
||||
// quint64 dial_frequency
|
||||
// QUtf8 mode
|
||||
// QUtf8 dx_call
|
||||
// QUtf8 report
|
||||
// QUtf8 tx_mode
|
||||
// bool tx_enabled
|
||||
// bool transmitting
|
||||
// bool decoding
|
||||
// qint32 rx_df
|
||||
// qint32 tx_df
|
||||
// QUtf8 de_call
|
||||
// QUtf8 de_grid
|
||||
// QUtf8 dx_grid
|
||||
// ... (more fields appended in later schemas, we stop reading
|
||||
// after dx_grid which is all we need)
|
||||
var dialHz uint64
|
||||
if err := binary.Read(r, binary.BigEndian, &dialHz); err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
ev.FreqHz = int64(dialHz)
|
||||
mode, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
ev.Mode = strings.ToUpper(strings.TrimSpace(mode))
|
||||
dxCall, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
ev.DXCall = strings.ToUpper(strings.TrimSpace(dxCall))
|
||||
// Skip report, tx_mode (QUtf8), tx_enabled (bool), transmitting,
|
||||
// decoding, rx_df (qint32), tx_df (qint32), de_call (QUtf8),
|
||||
// de_grid (QUtf8) → then dx_grid.
|
||||
for _, name := range []string{"report", "tx_mode"} {
|
||||
if _, err := readQString(r); err != nil {
|
||||
return ev, true, fmt.Errorf("read %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
// 3 booleans (each 1 byte)
|
||||
for i := 0; i < 3; i++ {
|
||||
var b uint8
|
||||
if err := binary.Read(r, binary.BigEndian, &b); err != nil {
|
||||
return ev, true, err
|
||||
}
|
||||
}
|
||||
// 2 int32
|
||||
var i32 int32
|
||||
for i := 0; i < 2; i++ {
|
||||
if err := binary.Read(r, binary.BigEndian, &i32); err != nil {
|
||||
return ev, true, err
|
||||
}
|
||||
}
|
||||
// de_call, de_grid, dx_grid
|
||||
if _, err := readQString(r); err != nil {
|
||||
return ev, true, err
|
||||
}
|
||||
if _, err := readQString(r); err != nil {
|
||||
return ev, true, err
|
||||
}
|
||||
dxGrid, err := readQString(r)
|
||||
if err != nil {
|
||||
return ev, true, err
|
||||
}
|
||||
ev.DXGrid = strings.ToUpper(strings.TrimSpace(dxGrid))
|
||||
return ev, true, nil
|
||||
|
||||
case wsjtMsgLoggedADIF:
|
||||
// Payload: a single QString containing the ADIF record.
|
||||
adif, err := readQString(r)
|
||||
if err != nil {
|
||||
return WSJTEvent{}, false, err
|
||||
}
|
||||
ev.LoggedADIF = adif
|
||||
return ev, true, nil
|
||||
}
|
||||
return WSJTEvent{}, false, nil
|
||||
}
|
||||
|
||||
// readQString reads a Qt QString as written by QDataStream: an int32 byte
|
||||
// length (or -1 for null) followed by the UTF-8 bytes.
|
||||
func readQString(r *bytes.Reader) (string, error) {
|
||||
var n int32
|
||||
if err := binary.Read(r, binary.BigEndian, &n); err != nil {
|
||||
return "", err
|
||||
}
|
||||
if n <= 0 {
|
||||
return "", nil
|
||||
}
|
||||
if int(n) > r.Len() {
|
||||
return "", fmt.Errorf("short string: want %d have %d", n, r.Len())
|
||||
}
|
||||
buf := make([]byte, n)
|
||||
if _, err := r.Read(buf); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(buf), nil
|
||||
}
|
||||
+20
-19
@@ -141,10 +141,18 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
}
|
||||
|
||||
// fillFromDXCC fills (or overrides) country/continent/zones/lat/lon from
|
||||
// the cty.dat resolver. Default behaviour is "fill empty fields only";
|
||||
// for slashed callsigns (IT9/DK6XZ, DL/F4NIE…) we OVERRIDE because the
|
||||
// provider returned the home-call's entity, which is wrong for portable
|
||||
// operations. The provider keeps Name/QTH/Address (still useful for QSL).
|
||||
// the cty.dat resolver. cty.dat is the authoritative source for DXCC
|
||||
// mapping, so Country/Continent/CQZ/ITUZ are ALWAYS overridden when it
|
||||
// has an answer — QRZ tends to return the political country (Greece for
|
||||
// SV5*, Russia for UA9*) instead of the DXCC entity (Dodecanese,
|
||||
// Asiatic Russia). Lat/Lon are filled only when empty so a more precise
|
||||
// home QTH from QRZ wins over the cty.dat entity centroid.
|
||||
//
|
||||
// For slashed callsigns (IT9/DK6XZ, DL/F4NIE…) the provider returned the
|
||||
// home-call's entity which is wrong for portable operations; we keep the
|
||||
// Name/QTH/Address from the provider (still useful for QSL) but reset
|
||||
// the DXCC number since QRZ's value is wrong and we don't have an entity
|
||||
// → DXCC# table yet.
|
||||
// Returns true if any field was filled.
|
||||
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if dxcc == nil {
|
||||
@@ -154,22 +162,15 @@ func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if !ok {
|
||||
return false
|
||||
}
|
||||
slashed := strings.ContainsRune(r.Callsign, '/')
|
||||
shouldStr := func(existing string) bool { return existing == "" || slashed }
|
||||
shouldInt := func(existing int) bool { return existing == 0 || slashed }
|
||||
shouldF := func(existing float64) bool { return existing == 0 || slashed }
|
||||
filled := false
|
||||
if country != "" && shouldStr(r.Country) { r.Country = country; filled = true }
|
||||
if cont != "" && shouldStr(r.Continent) { r.Continent = cont; filled = true }
|
||||
if cqz != 0 && shouldInt(r.CQZ) { r.CQZ = cqz; filled = true }
|
||||
if ituz != 0 && shouldInt(r.ITUZ) { r.ITUZ = ituz; filled = true }
|
||||
if lat != 0 && shouldF(r.Lat) { r.Lat = lat; filled = true }
|
||||
if lon != 0 && shouldF(r.Lon) { r.Lon = lon; filled = true }
|
||||
// QRZ's DXCC number is the home call's — wrong for portable ops.
|
||||
// cty.dat has no DXCC# (only Clublog does), so clear it: the UI
|
||||
// will fall back to callsign-level worked-before until we ship a
|
||||
// proper entity-name → DXCC# mapping.
|
||||
if slashed && r.DXCC != 0 {
|
||||
if country != "" { r.Country = country; filled = true }
|
||||
if cont != "" { r.Continent = cont; filled = true }
|
||||
if cqz != 0 { r.CQZ = cqz; filled = true }
|
||||
if ituz != 0 { r.ITUZ = ituz; filled = true }
|
||||
if lat != 0 && r.Lat == 0 { r.Lat = lat; filled = true }
|
||||
if lon != 0 && r.Lon == 0 { r.Lon = lon; filled = true }
|
||||
// Slashed call → drop QRZ's DXCC# (it's the home call's).
|
||||
if strings.ContainsRune(r.Callsign, '/') && r.DXCC != 0 {
|
||||
r.DXCC = 0
|
||||
filled = true
|
||||
}
|
||||
|
||||
+14
-12
@@ -17,10 +17,11 @@ import (
|
||||
// Profile is one operating configuration. A user typically keeps a few:
|
||||
// "Home", "Portable", "SOTA Pic du Midi", "/MM cruise"…
|
||||
type Profile struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Callsign string `json:"callsign"`
|
||||
Operator string `json:"operator"`
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Callsign string `json:"callsign"`
|
||||
Operator string `json:"operator"`
|
||||
OwnerCallsign string `json:"owner_callsign"`
|
||||
MyGrid string `json:"my_grid"`
|
||||
MyCountry string `json:"my_country"`
|
||||
MyState string `json:"my_state"`
|
||||
@@ -45,7 +46,7 @@ type Repo struct{ db *sql.DB }
|
||||
|
||||
func NewRepo(db *sql.DB) *Repo { return &Repo{db: db} }
|
||||
|
||||
const selectCols = `id, name, callsign, operator, my_grid, my_country, my_state, my_cnty,
|
||||
const selectCols = `id, name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at`
|
||||
|
||||
@@ -93,11 +94,11 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
||||
if p.ID == 0 {
|
||||
res, err := r.db.ExecContext(ctx, `
|
||||
INSERT INTO station_profiles
|
||||
(name, callsign, operator, my_grid, my_country, my_state, my_cnty,
|
||||
(name, callsign, operator, owner_callsign, my_grid, my_country, my_state, my_cnty,
|
||||
my_street, my_city, my_postal_code, my_sota_ref, my_pota_ref,
|
||||
my_rig, my_antenna, tx_pwr, is_active, sort_order, created_at, updated_at)
|
||||
VALUES(?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
||||
VALUES(?,?,?,?,?,?,?,?, ?,?,?,?,?, ?,?,?,?,?,?,?)`,
|
||||
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry, p.MyState, p.MyCounty,
|
||||
p.MyStreet, p.MyCity, p.MyPostalCode, p.MySOTARef, p.MyPOTARef,
|
||||
p.MyRig, p.MyAntenna, nullableFloat(p.TxPower), boolInt(p.IsActive), p.SortOrder, now, now)
|
||||
if err != nil {
|
||||
@@ -109,12 +110,12 @@ func (r *Repo) Save(ctx context.Context, p *Profile) error {
|
||||
}
|
||||
_, err := r.db.ExecContext(ctx, `
|
||||
UPDATE station_profiles SET
|
||||
name = ?, callsign = ?, operator = ?, my_grid = ?, my_country = ?,
|
||||
name = ?, callsign = ?, operator = ?, owner_callsign = ?, my_grid = ?, my_country = ?,
|
||||
my_state = ?, my_cnty = ?, my_street = ?, my_city = ?, my_postal_code = ?,
|
||||
my_sota_ref = ?, my_pota_ref = ?, my_rig = ?, my_antenna = ?, tx_pwr = ?,
|
||||
sort_order = ?, updated_at = ?
|
||||
WHERE id = ?`,
|
||||
p.Name, p.Callsign, p.Operator, p.MyGrid, p.MyCountry,
|
||||
p.Name, p.Callsign, p.Operator, p.OwnerCallsign, p.MyGrid, p.MyCountry,
|
||||
p.MyState, p.MyCounty, p.MyStreet, p.MyCity, p.MyPostalCode,
|
||||
p.MySOTARef, p.MyPOTARef, p.MyRig, p.MyAntenna, nullableFloat(p.TxPower),
|
||||
p.SortOrder, now, p.ID)
|
||||
@@ -206,14 +207,14 @@ type scannable interface {
|
||||
func scan(row scannable) (Profile, error) {
|
||||
var p Profile
|
||||
var (
|
||||
callsign, operator, myGrid, myCountry, myState, myCnty,
|
||||
callsign, operator, ownerCall, myGrid, myCountry, myState, myCnty,
|
||||
myStreet, myCity, myPostal, mySOTA, myPOTA,
|
||||
myRig, myAntenna sql.NullString
|
||||
txPwr sql.NullFloat64
|
||||
isActive, sortOrder int
|
||||
createdAt, updatedAt string
|
||||
)
|
||||
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &myGrid, &myCountry, &myState, &myCnty,
|
||||
err := row.Scan(&p.ID, &p.Name, &callsign, &operator, &ownerCall, &myGrid, &myCountry, &myState, &myCnty,
|
||||
&myStreet, &myCity, &myPostal, &mySOTA, &myPOTA,
|
||||
&myRig, &myAntenna, &txPwr, &isActive, &sortOrder, &createdAt, &updatedAt)
|
||||
if err != nil {
|
||||
@@ -221,6 +222,7 @@ func scan(row scannable) (Profile, error) {
|
||||
}
|
||||
p.Callsign = callsign.String
|
||||
p.Operator = operator.String
|
||||
p.OwnerCallsign = ownerCall.String
|
||||
p.MyGrid = myGrid.String
|
||||
p.MyCountry = myCountry.String
|
||||
p.MyState = myState.String
|
||||
|
||||
Reference in New Issue
Block a user