update
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
package adif
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"hamlog/internal/qso"
|
||||
)
|
||||
|
||||
// ExportResult summarises an ADIF export for the UI.
|
||||
type ExportResult struct {
|
||||
Path string `json:"path"`
|
||||
Count int `json:"count"`
|
||||
SizeKB int64 `json:"size_kb"`
|
||||
}
|
||||
|
||||
// Exporter streams every QSO in a repo to an ADIF (.adi) file.
|
||||
type Exporter struct {
|
||||
Repo *qso.Repo
|
||||
|
||||
// AppName / AppVersion populate the ADIF header comments. Optional.
|
||||
AppName string
|
||||
AppVersion string
|
||||
}
|
||||
|
||||
// ExportFile creates path (overwriting if it exists) and writes every QSO.
|
||||
func (e *Exporter) ExportFile(ctx context.Context, path string) (ExportResult, error) {
|
||||
f, err := os.Create(path)
|
||||
if err != nil {
|
||||
return ExportResult{}, fmt.Errorf("create %s: %w", path, err)
|
||||
}
|
||||
defer f.Close()
|
||||
|
||||
count, err := e.Export(ctx, f)
|
||||
if err != nil {
|
||||
return ExportResult{Path: path, Count: count}, err
|
||||
}
|
||||
info, _ := f.Stat()
|
||||
return ExportResult{
|
||||
Path: path,
|
||||
Count: count,
|
||||
SizeKB: info.Size() / 1024,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Export writes a complete ADIF document (header + records + EOF) to w.
|
||||
// Returns the number of QSOs successfully written.
|
||||
func (e *Exporter) Export(ctx context.Context, w io.Writer) (int, error) {
|
||||
bw := bufio.NewWriterSize(w, 64*1024)
|
||||
defer bw.Flush()
|
||||
|
||||
app := strings.TrimSpace(e.AppName)
|
||||
if app == "" {
|
||||
app = "HamLog"
|
||||
}
|
||||
ver := strings.TrimSpace(e.AppVersion)
|
||||
now := time.Now().UTC().Format("20060102 150405")
|
||||
fmt.Fprintf(bw, "# ADIF export by %s %s — %s UTC\n", app, ver, now)
|
||||
fmt.Fprintf(bw, "<ADIF_VER:5>3.1.0 <PROGRAMID:%d>%s", len(app), app)
|
||||
if ver != "" {
|
||||
fmt.Fprintf(bw, " <PROGRAMVERSION:%d>%s", len(ver), ver)
|
||||
}
|
||||
fmt.Fprintf(bw, " <CREATED_TIMESTAMP:15>%s <EOH>\n\n", now)
|
||||
|
||||
count := 0
|
||||
err := e.Repo.IterateAll(ctx, func(q qso.QSO) error {
|
||||
writeRecord(bw, q)
|
||||
count++
|
||||
return nil
|
||||
})
|
||||
return count, err
|
||||
}
|
||||
|
||||
// writeRecord serialises one QSO as ADIF tags terminated by <EOR>.
|
||||
// Empty fields are omitted. MODE/SUBMODE are massaged so a "promoted"
|
||||
// mode (e.g. FT4 stored without a parent) is exported as the canonical
|
||||
// pair MODE=MFSK SUBMODE=FT4 — round-trips cleanly with strict loggers.
|
||||
func writeRecord(bw *bufio.Writer, q qso.QSO) {
|
||||
// --- Core ---
|
||||
writeField(bw, "CALL", q.Callsign)
|
||||
|
||||
if !q.QSODate.IsZero() {
|
||||
writeField(bw, "QSO_DATE", q.QSODate.UTC().Format("20060102"))
|
||||
writeField(bw, "TIME_ON", q.QSODate.UTC().Format("150405"))
|
||||
}
|
||||
if !q.QSODateOff.IsZero() {
|
||||
writeField(bw, "QSO_DATE_OFF", q.QSODateOff.UTC().Format("20060102"))
|
||||
writeField(bw, "TIME_OFF", q.QSODateOff.UTC().Format("150405"))
|
||||
}
|
||||
writeField(bw, "BAND", q.Band)
|
||||
writeField(bw, "BAND_RX", q.BandRX)
|
||||
|
||||
mode, submode := modeForExport(q.Mode, q.Submode)
|
||||
writeField(bw, "MODE", mode)
|
||||
writeField(bw, "SUBMODE", submode)
|
||||
|
||||
if q.FreqHz != nil && *q.FreqHz > 0 {
|
||||
writeField(bw, "FREQ", strconv.FormatFloat(float64(*q.FreqHz)/1_000_000, 'f', 6, 64))
|
||||
}
|
||||
if q.FreqRXHz != nil && *q.FreqRXHz > 0 {
|
||||
writeField(bw, "FREQ_RX", strconv.FormatFloat(float64(*q.FreqRXHz)/1_000_000, 'f', 6, 64))
|
||||
}
|
||||
|
||||
writeField(bw, "RST_SENT", q.RSTSent)
|
||||
writeField(bw, "RST_RCVD", q.RSTRcvd)
|
||||
|
||||
// --- Contacted ---
|
||||
writeField(bw, "NAME", q.Name)
|
||||
writeField(bw, "QTH", q.QTH)
|
||||
writeField(bw, "ADDRESS", q.Address)
|
||||
writeField(bw, "EMAIL", q.Email)
|
||||
writeField(bw, "WEB", q.Web)
|
||||
writeField(bw, "GRIDSQUARE", q.Grid)
|
||||
writeField(bw, "GRIDSQUARE_EXT", q.GridExt)
|
||||
writeField(bw, "VUCC_GRIDS", q.VUCCGrids)
|
||||
writeField(bw, "COUNTRY", q.Country)
|
||||
writeField(bw, "STATE", q.State)
|
||||
writeField(bw, "CNTY", q.County)
|
||||
writeIntPtr(bw, "DXCC", q.DXCC)
|
||||
writeField(bw, "CONT", q.Continent)
|
||||
writeIntPtr(bw, "CQZ", q.CQZ)
|
||||
writeIntPtr(bw, "ITUZ", q.ITUZ)
|
||||
writeField(bw, "IOTA", q.IOTA)
|
||||
writeField(bw, "SOTA_REF", q.SOTARef)
|
||||
writeField(bw, "POTA_REF", q.POTARef)
|
||||
writeIntPtr(bw, "AGE", q.Age)
|
||||
writeFloatPtr(bw, "LAT", q.Lat, 6)
|
||||
writeFloatPtr(bw, "LON", q.Lon, 6)
|
||||
writeField(bw, "RIG", q.Rig)
|
||||
writeField(bw, "ANT", q.Ant)
|
||||
|
||||
// --- QSL / LoTW / eQSL / Clublog / HRDLog ---
|
||||
writeField(bw, "QSL_SENT", q.QSLSent)
|
||||
writeField(bw, "QSL_RCVD", q.QSLRcvd)
|
||||
writeField(bw, "QSLSDATE", q.QSLSentDate)
|
||||
writeField(bw, "QSLRDATE", q.QSLRcvdDate)
|
||||
writeField(bw, "QSL_VIA", q.QSLVia)
|
||||
writeField(bw, "QSLMSG", q.QSLMsg)
|
||||
writeField(bw, "QSLMSG_RCVD", q.QSLMsgRcvd)
|
||||
writeField(bw, "LOTW_QSL_SENT", q.LOTWSent)
|
||||
writeField(bw, "LOTW_QSL_RCVD", q.LOTWRcvd)
|
||||
writeField(bw, "LOTW_QSLSDATE", q.LOTWSentDate)
|
||||
writeField(bw, "LOTW_QSLRDATE", q.LOTWRcvdDate)
|
||||
writeField(bw, "EQSL_QSL_SENT", q.EQSLSent)
|
||||
writeField(bw, "EQSL_QSL_RCVD", q.EQSLRcvd)
|
||||
writeField(bw, "EQSL_QSLSDATE", q.EQSLSentDate)
|
||||
writeField(bw, "EQSL_QSLRDATE", q.EQSLRcvdDate)
|
||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_DATE", q.ClublogUploadDate)
|
||||
writeField(bw, "CLUBLOG_QSO_UPLOAD_STATUS", q.ClublogUploadStatus)
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_DATE", q.HRDLogUploadDate)
|
||||
writeField(bw, "HRDLOG_QSO_UPLOAD_STATUS", q.HRDLogUploadStatus)
|
||||
|
||||
// --- Contest ---
|
||||
writeField(bw, "CONTEST_ID", q.ContestID)
|
||||
writeIntPtr(bw, "SRX", q.SRX)
|
||||
writeIntPtr(bw, "STX", q.STX)
|
||||
writeField(bw, "SRX_STRING", q.SRXString)
|
||||
writeField(bw, "STX_STRING", q.STXString)
|
||||
writeField(bw, "CHECK", q.Check)
|
||||
writeField(bw, "PRECEDENCE", q.Precedence)
|
||||
writeField(bw, "ARRL_SECT", q.ARRLSect)
|
||||
|
||||
// --- Satellite / propagation ---
|
||||
writeField(bw, "PROP_MODE", q.PropMode)
|
||||
writeField(bw, "SAT_NAME", q.SatName)
|
||||
writeField(bw, "SAT_MODE", q.SatMode)
|
||||
writeFloatPtr(bw, "ANT_AZ", q.AntAz, 1)
|
||||
writeFloatPtr(bw, "ANT_EL", q.AntEl, 1)
|
||||
writeField(bw, "ANT_PATH", q.AntPath)
|
||||
|
||||
// --- My station / operator ---
|
||||
writeField(bw, "STATION_CALLSIGN", q.StationCallsign)
|
||||
writeField(bw, "OPERATOR", q.Operator)
|
||||
writeField(bw, "MY_GRIDSQUARE", q.MyGrid)
|
||||
writeField(bw, "MY_GRIDSQUARE_EXT", q.MyGridExt)
|
||||
writeField(bw, "MY_COUNTRY", q.MyCountry)
|
||||
writeField(bw, "MY_STATE", q.MyState)
|
||||
writeField(bw, "MY_CNTY", q.MyCounty)
|
||||
writeField(bw, "MY_IOTA", q.MyIOTA)
|
||||
writeField(bw, "MY_SOTA_REF", q.MySOTARef)
|
||||
writeField(bw, "MY_POTA_REF", q.MyPOTARef)
|
||||
writeIntPtr(bw, "MY_DXCC", q.MyDXCC)
|
||||
writeIntPtr(bw, "MY_CQ_ZONE", q.MyCQZone)
|
||||
writeIntPtr(bw, "MY_ITU_ZONE", q.MyITUZone)
|
||||
writeFloatPtr(bw, "MY_LAT", q.MyLat, 6)
|
||||
writeFloatPtr(bw, "MY_LON", q.MyLon, 6)
|
||||
writeField(bw, "MY_STREET", q.MyStreet)
|
||||
writeField(bw, "MY_CITY", q.MyCity)
|
||||
writeField(bw, "MY_POSTAL_CODE", q.MyPostalCode)
|
||||
writeField(bw, "MY_RIG", q.MyRig)
|
||||
writeField(bw, "MY_ANTENNA", q.MyAntenna)
|
||||
|
||||
// --- Misc ---
|
||||
writeFloatPtr(bw, "TX_PWR", q.TXPower, 1)
|
||||
writeField(bw, "COMMENT", q.Comment)
|
||||
writeField(bw, "NOTES", q.Notes)
|
||||
|
||||
// --- Extras (unpromoted ADIF fields preserved verbatim) ---
|
||||
for k, v := range q.Extras {
|
||||
writeField(bw, strings.ToUpper(k), v)
|
||||
}
|
||||
|
||||
bw.WriteString("<EOR>\n")
|
||||
}
|
||||
|
||||
// writeField writes one `<TAG:length>value` pair, no-op when value is empty.
|
||||
// length is the byte count (ADIF spec), which matches len(v) in Go since v is
|
||||
// already a UTF-8 byte string.
|
||||
func writeField(bw *bufio.Writer, tag, v string) {
|
||||
if v == "" {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(bw, "<%s:%d>%s ", tag, len(v), v)
|
||||
}
|
||||
|
||||
func writeIntPtr(bw *bufio.Writer, tag string, p *int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
s := strconv.Itoa(*p)
|
||||
writeField(bw, tag, s)
|
||||
}
|
||||
|
||||
func writeFloatPtr(bw *bufio.Writer, tag string, p *float64, decimals int) {
|
||||
if p == nil {
|
||||
return
|
||||
}
|
||||
s := strconv.FormatFloat(*p, 'f', decimals, 64)
|
||||
writeField(bw, tag, s)
|
||||
}
|
||||
|
||||
// parentMode maps a "specific" mode (the ones we promote on import) back
|
||||
// to its ADIF parent. Symmetric with promotableSubmodes in import.go.
|
||||
var parentMode = map[string]string{
|
||||
"FT2": "MFSK", "FT4": "MFSK", "JS8": "MFSK", "MSK144": "MFSK",
|
||||
"ISCAT": "MFSK", "Q65": "MFSK", "FST4": "MFSK", "FST4W": "MFSK",
|
||||
"MFSK16": "MFSK", "MFSK32": "MFSK", "MFSK64": "MFSK", "MFSK128": "MFSK",
|
||||
"OLIVIA": "MFSK",
|
||||
"PSK31": "PSK", "PSK63": "PSK", "PSK125": "PSK", "PSK250": "PSK", "PSK500": "PSK",
|
||||
"QPSK31": "PSK", "QPSK63": "PSK", "QPSK125": "PSK", "QPSK250": "PSK", "QPSK500": "PSK",
|
||||
"FREEDV": "DIGITALVOICE",
|
||||
"VARA": "DYNAMIC", "VARA HF": "DYNAMIC", "VARA FM": "DYNAMIC", "VARAC": "DYNAMIC",
|
||||
"THOR4": "THOR", "THOR8": "THOR", "THOR16": "THOR", "THOR32": "THOR",
|
||||
"DOMINOF": "DOMINO", "DOMINOEX": "DOMINO",
|
||||
"HELL80": "HELL", "FMHELL": "HELL",
|
||||
}
|
||||
|
||||
// modeForExport returns the (MODE, SUBMODE) pair to write. If we promoted
|
||||
// on import (Mode=FT4 Submode=""), we re-derive the parent so the file
|
||||
// is import-compatible with strict ADIF tools.
|
||||
func modeForExport(mode, submode string) (string, string) {
|
||||
if submode != "" {
|
||||
// Already a (parent, child) pair — pass through unchanged.
|
||||
return mode, submode
|
||||
}
|
||||
if parent, ok := parentMode[mode]; ok {
|
||||
return parent, mode
|
||||
}
|
||||
return mode, ""
|
||||
}
|
||||
@@ -0,0 +1,505 @@
|
||||
// Package cluster provides a multi-server DX cluster client (telnet) —
|
||||
// connects concurrently to several AR-Cluster / CC-Cluster / DXSpider
|
||||
// nodes, logs in with the operator's callsign, optionally sends an init
|
||||
// command list, and streams DX spots back to the UI via a callback.
|
||||
//
|
||||
// Spot parsing is tolerant of the dozens of slight format variations
|
||||
// between cluster flavours (the prompt, the trailing locator, the time
|
||||
// format). Anything that doesn't match the spot regex is treated as
|
||||
// banner/chat noise and ignored, not surfaced as an error.
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// ServerConfig is the persisted shape of one cluster node. Mirrors the
|
||||
// columns of the cluster_servers table; the frontend SettingsPanel
|
||||
// pushes one of these per row.
|
||||
type ServerConfig struct {
|
||||
ID int64 `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
LoginOverride string `json:"login_override"`
|
||||
Password string `json:"password,omitempty"`
|
||||
InitCommands string `json:"init_commands"` // newline-separated, sent post-login
|
||||
Enabled bool `json:"enabled"`
|
||||
SortOrder int `json:"sort_order"`
|
||||
}
|
||||
|
||||
// Spot is a single DX spot as parsed from the cluster stream.
|
||||
type Spot struct {
|
||||
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)
|
||||
Spotter string `json:"spotter"` // DE field
|
||||
DXCall string `json:"dx_call"` // the DX station heard
|
||||
FreqKHz float64 `json:"freq_khz"`
|
||||
FreqHz int64 `json:"freq_hz"`
|
||||
Band string `json:"band,omitempty"`
|
||||
Comment string `json:"comment,omitempty"`
|
||||
Locator string `json:"locator,omitempty"` // spotter grid (optional)
|
||||
TimeUTC string `json:"time_utc,omitempty"`
|
||||
ReceivedAt time.Time `json:"received_at"`
|
||||
Raw string `json:"raw"`
|
||||
}
|
||||
|
||||
// State enumerates the per-server lifecycle.
|
||||
type State string
|
||||
|
||||
const (
|
||||
StateDisconnected State = "disconnected"
|
||||
StateConnecting State = "connecting"
|
||||
StateConnected State = "connected"
|
||||
StateReconnecting State = "reconnecting"
|
||||
StateError State = "error"
|
||||
)
|
||||
|
||||
// ServerStatus is one row of the runtime status table — one entry per
|
||||
// active session.
|
||||
type ServerStatus struct {
|
||||
ServerID int64 `json:"server_id"`
|
||||
Name string `json:"name"`
|
||||
Host string `json:"host"`
|
||||
Port int `json:"port"`
|
||||
State State `json:"state"`
|
||||
Login string `json:"login,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
ConnectedAt time.Time `json:"connected_at,omitempty"`
|
||||
SpotsCount int `json:"spots_count,omitempty"`
|
||||
Retries int `json:"retries,omitempty"`
|
||||
}
|
||||
|
||||
// session is one telnet connection bound to a single server config.
|
||||
// Internal — callers use Manager. The onStatus callback is fire-and-
|
||||
// forget: it tells the manager something changed; the frontend fetches
|
||||
// the new aggregate via Status() rather than receiving per-server diffs.
|
||||
type session struct {
|
||||
cfg ServerConfig
|
||||
login string
|
||||
onSpot func(Spot)
|
||||
onStatus func()
|
||||
|
||||
mu sync.RWMutex
|
||||
status ServerStatus
|
||||
conn net.Conn
|
||||
stopCh chan struct{}
|
||||
doneCh chan struct{}
|
||||
spotsCnt int
|
||||
}
|
||||
|
||||
// Manager owns N sessions, one per enabled server. Safe for concurrent
|
||||
// use from any goroutine; I/O is on per-session background goroutines.
|
||||
type Manager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[int64]*session
|
||||
onSpot func(Spot)
|
||||
onStatus func()
|
||||
}
|
||||
|
||||
// NewManager builds an empty manager. emitSpot is called for each parsed
|
||||
// spot (with the source server filled in). emitStatusChanged is called
|
||||
// whenever ANY server's status changes — the frontend then re-fetches
|
||||
// the aggregate Status() via a Wails binding.
|
||||
func NewManager(emitSpot func(Spot), emitStatusChanged func()) *Manager {
|
||||
return &Manager{
|
||||
sessions: make(map[int64]*session),
|
||||
onSpot: emitSpot,
|
||||
onStatus: emitStatusChanged,
|
||||
}
|
||||
}
|
||||
|
||||
// Status returns a snapshot of every running session's status.
|
||||
func (m *Manager) Status() []ServerStatus {
|
||||
m.mu.RLock()
|
||||
defer m.mu.RUnlock()
|
||||
out := make([]ServerStatus, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
out = append(out, s.snapshot())
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// StartServer launches a session for cfg. login is the resolved callsign
|
||||
// to send (empty = anonymous). If a session for the same ID is already
|
||||
// running it is restarted with the new config.
|
||||
func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
||||
m.StopServer(cfg.ID)
|
||||
if !cfg.Enabled || cfg.Host == "" {
|
||||
return
|
||||
}
|
||||
s := &session{
|
||||
cfg: cfg,
|
||||
login: login,
|
||||
onSpot: m.onSpot,
|
||||
onStatus: m.emitStatus,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
status: ServerStatus{
|
||||
ServerID: cfg.ID, Name: cfg.Name, Host: cfg.Host, Port: cfg.Port,
|
||||
Login: login, State: StateConnecting,
|
||||
},
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.sessions[cfg.ID] = s
|
||||
m.mu.Unlock()
|
||||
s.emitStatus()
|
||||
go s.run()
|
||||
}
|
||||
|
||||
// StopServer terminates the session for the given ID (if any) and waits
|
||||
// for its goroutine to exit.
|
||||
func (m *Manager) StopServer(id int64) {
|
||||
m.mu.Lock()
|
||||
s, ok := m.sessions[id]
|
||||
if ok {
|
||||
delete(m.sessions, id)
|
||||
}
|
||||
m.mu.Unlock()
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
s.stop()
|
||||
m.emitStatus()
|
||||
}
|
||||
|
||||
// StopAll closes every running session.
|
||||
func (m *Manager) StopAll() {
|
||||
m.mu.Lock()
|
||||
all := make([]*session, 0, len(m.sessions))
|
||||
for _, s := range m.sessions {
|
||||
all = append(all, s)
|
||||
}
|
||||
m.sessions = make(map[int64]*session)
|
||||
m.mu.Unlock()
|
||||
for _, s := range all {
|
||||
s.stop()
|
||||
}
|
||||
m.emitStatus()
|
||||
}
|
||||
|
||||
// SendCommand writes raw text (a CRLF is appended) to the session for
|
||||
// the given server ID. Used for "show last 30", "set/skimmer", etc.
|
||||
func (m *Manager) SendCommand(serverID int64, cmd string) error {
|
||||
m.mu.RLock()
|
||||
s, ok := m.sessions[serverID]
|
||||
m.mu.RUnlock()
|
||||
if !ok {
|
||||
return fmt.Errorf("no active session for server %d", serverID)
|
||||
}
|
||||
return s.send(cmd)
|
||||
}
|
||||
|
||||
func (m *Manager) emitStatus() {
|
||||
if m.onStatus != nil {
|
||||
m.onStatus()
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- session ----------
|
||||
|
||||
func (s *session) snapshot() ServerStatus {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.status
|
||||
}
|
||||
|
||||
func (s *session) emitStatus() {
|
||||
if s.onStatus != nil {
|
||||
s.onStatus()
|
||||
}
|
||||
}
|
||||
|
||||
func (s *session) send(cmd string) error {
|
||||
s.mu.RLock()
|
||||
conn := s.conn
|
||||
s.mu.RUnlock()
|
||||
if conn == nil {
|
||||
return fmt.Errorf("session %q not connected", s.cfg.Name)
|
||||
}
|
||||
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
|
||||
_, err := conn.Write([]byte(strings.TrimRight(cmd, "\r\n") + "\r\n"))
|
||||
return err
|
||||
}
|
||||
|
||||
func (s *session) stop() {
|
||||
s.mu.Lock()
|
||||
stop, done := s.stopCh, s.doneCh
|
||||
conn := s.conn
|
||||
s.stopCh, s.doneCh, s.conn = nil, nil, nil
|
||||
s.mu.Unlock()
|
||||
if conn != nil {
|
||||
_ = conn.Close()
|
||||
}
|
||||
if stop != nil {
|
||||
close(stop)
|
||||
}
|
||||
if done != nil {
|
||||
<-done
|
||||
}
|
||||
}
|
||||
|
||||
// run is the per-session supervisor: keeps trying to connect until
|
||||
// Stop is called. Backoff caps at 60s, resets after a 30s healthy link.
|
||||
func (s *session) run() {
|
||||
defer close(s.doneCh)
|
||||
backoff := []time.Duration{2, 5, 10, 30, 60}
|
||||
attempt := 0
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
connectedAt, err := s.runOnce()
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
if !connectedAt.IsZero() && time.Since(connectedAt) > 30*time.Second {
|
||||
attempt = 0
|
||||
}
|
||||
idx := attempt
|
||||
if idx >= len(backoff) {
|
||||
idx = len(backoff) - 1
|
||||
}
|
||||
delay := backoff[idx] * time.Second
|
||||
attempt++
|
||||
|
||||
s.mu.Lock()
|
||||
s.status.State = StateReconnecting
|
||||
if err != nil {
|
||||
s.status.Error = err.Error()
|
||||
}
|
||||
s.status.Retries = attempt
|
||||
s.mu.Unlock()
|
||||
s.emitStatus()
|
||||
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-time.After(delay):
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// runOnce dials, optionally logs in, sends init commands, parses spots.
|
||||
// Returns the moment we marked the link "connected" (zero if dial failed)
|
||||
// and the error that ended the session (nil if stopCh).
|
||||
func (s *session) runOnce() (time.Time, error) {
|
||||
addr := fmt.Sprintf("%s:%d", s.cfg.Host, s.cfg.Port)
|
||||
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
|
||||
if err != nil {
|
||||
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.conn = conn
|
||||
s.mu.Unlock()
|
||||
defer func() {
|
||||
s.mu.Lock()
|
||||
if s.conn == conn {
|
||||
s.conn = nil
|
||||
}
|
||||
s.mu.Unlock()
|
||||
_ = conn.Close()
|
||||
}()
|
||||
|
||||
// Login: send on first prompt OR blindly after 1.5s. Many DXSpider
|
||||
// nodes accept the callsign without re-prompting.
|
||||
loginSent := false
|
||||
if s.login != "" {
|
||||
go func() {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-time.After(1500 * time.Millisecond):
|
||||
if !loginSent {
|
||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Init commands: fire 1s after login goes through. Each command on
|
||||
// its own line; blank lines and "//" comments are skipped.
|
||||
initFired := false
|
||||
fireInitCommands := func() {
|
||||
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
||||
return
|
||||
}
|
||||
initFired = true
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
default:
|
||||
}
|
||||
_, _ = conn.Write([]byte(line + "\r\n"))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
var connectedAt time.Time
|
||||
rd := bufio.NewReader(conn)
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return connectedAt, nil
|
||||
default:
|
||||
}
|
||||
|
||||
_ = conn.SetReadDeadline(time.Now().Add(120 * time.Second))
|
||||
line, err := rd.ReadString('\n')
|
||||
if err != nil {
|
||||
return connectedAt, fmt.Errorf("read: %w", err)
|
||||
}
|
||||
line = strings.TrimRight(line, "\r\n")
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
// Login on explicit prompt.
|
||||
if !loginSent && s.login != "" && isLoginPrompt(line) {
|
||||
_, _ = conn.Write([]byte(s.login + "\r\n"))
|
||||
loginSent = true
|
||||
continue
|
||||
}
|
||||
// Password on prompt (rare).
|
||||
if loginSent && s.cfg.Password != "" && isPasswordPrompt(line) {
|
||||
_, _ = conn.Write([]byte(s.cfg.Password + "\r\n"))
|
||||
continue
|
||||
}
|
||||
|
||||
// Mark connected once we've sent login OR seen a welcome banner.
|
||||
if s.snapshot().State != StateConnected && (loginSent || isWelcome(line)) {
|
||||
connectedAt = time.Now()
|
||||
s.mu.Lock()
|
||||
s.status.State = StateConnected
|
||||
s.status.ConnectedAt = connectedAt
|
||||
s.status.Error = ""
|
||||
s.mu.Unlock()
|
||||
s.emitStatus()
|
||||
fireInitCommands()
|
||||
}
|
||||
|
||||
if spot, ok := parseSpot(line); ok {
|
||||
spot.SourceID = s.cfg.ID
|
||||
spot.SourceName = s.cfg.Name
|
||||
s.mu.Lock()
|
||||
s.spotsCnt++
|
||||
s.status.SpotsCount = s.spotsCnt
|
||||
s.mu.Unlock()
|
||||
if s.onSpot != nil {
|
||||
s.onSpot(spot)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------- parsing ----------
|
||||
|
||||
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
||||
var spotRE = regexp.MustCompile(
|
||||
`^\s*DX\s+de\s+([A-Z0-9/#\-]+):?\s+(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
||||
)
|
||||
|
||||
func parseSpot(line string) (Spot, bool) {
|
||||
m := spotRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
return Spot{}, false
|
||||
}
|
||||
freqKHz, err := strconv.ParseFloat(m[2], 64)
|
||||
if err != nil {
|
||||
return Spot{}, false
|
||||
}
|
||||
freqHz := int64(freqKHz*1000 + 0.5)
|
||||
return Spot{
|
||||
Spotter: strings.ToUpper(m[1]),
|
||||
FreqKHz: freqKHz,
|
||||
FreqHz: freqHz,
|
||||
Band: bandFromHz(freqHz),
|
||||
DXCall: strings.ToUpper(m[3]),
|
||||
Comment: strings.TrimSpace(m[4]),
|
||||
TimeUTC: m[5],
|
||||
Locator: strings.ToUpper(m[6]),
|
||||
ReceivedAt: time.Now(),
|
||||
Raw: line,
|
||||
}, true
|
||||
}
|
||||
|
||||
func isLoginPrompt(s string) bool {
|
||||
low := strings.ToLower(s)
|
||||
return strings.Contains(low, "login:") ||
|
||||
strings.Contains(low, "please enter your call") ||
|
||||
strings.Contains(low, "your call:") ||
|
||||
strings.HasSuffix(strings.TrimSpace(low), "callsign:")
|
||||
}
|
||||
|
||||
func isPasswordPrompt(s string) bool {
|
||||
low := strings.ToLower(s)
|
||||
return strings.Contains(low, "password:") || strings.Contains(low, "pwd:")
|
||||
}
|
||||
|
||||
func isWelcome(s string) bool {
|
||||
low := strings.ToLower(s)
|
||||
return strings.Contains(low, "welcome") ||
|
||||
strings.Contains(low, "logged in") ||
|
||||
strings.Contains(low, "started")
|
||||
}
|
||||
|
||||
func bandFromHz(hz int64) string {
|
||||
mhz := float64(hz) / 1_000_000
|
||||
switch {
|
||||
case mhz >= 1.8 && mhz <= 2.0:
|
||||
return "160m"
|
||||
case mhz >= 3.5 && mhz <= 4.0:
|
||||
return "80m"
|
||||
case mhz >= 5.3 && mhz <= 5.5:
|
||||
return "60m"
|
||||
case mhz >= 7.0 && mhz <= 7.3:
|
||||
return "40m"
|
||||
case mhz >= 10.1 && mhz <= 10.15:
|
||||
return "30m"
|
||||
case mhz >= 14.0 && mhz <= 14.35:
|
||||
return "20m"
|
||||
case mhz >= 18.068 && mhz <= 18.168:
|
||||
return "17m"
|
||||
case mhz >= 21.0 && mhz <= 21.45:
|
||||
return "15m"
|
||||
case mhz >= 24.89 && mhz <= 24.99:
|
||||
return "12m"
|
||||
case mhz >= 28.0 && mhz <= 29.7:
|
||||
return "10m"
|
||||
case mhz >= 50.0 && mhz <= 54.0:
|
||||
return "6m"
|
||||
case mhz >= 70.0 && mhz <= 70.5:
|
||||
return "4m"
|
||||
case mhz >= 144.0 && mhz <= 148.0:
|
||||
return "2m"
|
||||
case mhz >= 222.0 && mhz <= 225.0:
|
||||
return "1.25m"
|
||||
case mhz >= 420.0 && mhz <= 450.0:
|
||||
return "70cm"
|
||||
case mhz >= 902.0 && mhz <= 928.0:
|
||||
return "33cm"
|
||||
case mhz >= 1240.0 && mhz <= 1300.0:
|
||||
return "23cm"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
package cluster
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseSpot(t *testing.T) {
|
||||
cases := []struct {
|
||||
line string
|
||||
wantCall string
|
||||
wantKHz float64
|
||||
wantBand string
|
||||
wantLoc string
|
||||
}{
|
||||
{
|
||||
`DX de DK0SWL: 14195.5 W1AW CQ Field Day 1745Z`,
|
||||
"W1AW", 14195.5, "20m", "",
|
||||
},
|
||||
{
|
||||
`DX de F4XYZ-#: 7074.0 3DA0RU FT8 -10 0823Z JN18`,
|
||||
"3DA0RU", 7074.0, "40m", "JN18",
|
||||
},
|
||||
{
|
||||
`DX de N1MM: 14010.0 K1JT 599 NJ 1234Z FN20`,
|
||||
"K1JT", 14010.0, "20m", "FN20",
|
||||
},
|
||||
{
|
||||
`DX de YO3JW 3573.0 EA1ABC CQ 2010Z IN73`,
|
||||
"EA1ABC", 3573.0, "80m", "IN73",
|
||||
},
|
||||
}
|
||||
for _, c := range cases {
|
||||
s, ok := parseSpot(c.line)
|
||||
if !ok {
|
||||
t.Errorf("%q: parse failed", c.line)
|
||||
continue
|
||||
}
|
||||
if s.DXCall != c.wantCall {
|
||||
t.Errorf("%q: call=%q want %q", c.line, s.DXCall, c.wantCall)
|
||||
}
|
||||
if s.FreqKHz != c.wantKHz {
|
||||
t.Errorf("%q: kHz=%v want %v", c.line, s.FreqKHz, c.wantKHz)
|
||||
}
|
||||
if s.Band != c.wantBand {
|
||||
t.Errorf("%q: band=%q want %q", c.line, s.Band, c.wantBand)
|
||||
}
|
||||
if s.Locator != c.wantLoc {
|
||||
t.Errorf("%q: loc=%q want %q", c.line, s.Locator, c.wantLoc)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSpotRejectsNoise(t *testing.T) {
|
||||
noise := []string{
|
||||
"Welcome to DXCluster",
|
||||
"login:",
|
||||
"WX bulletin from G4ABC: heavy rain",
|
||||
"To ALL de F1XYZ: anyone using XYZ contest log?",
|
||||
"",
|
||||
"sh/dx 10",
|
||||
}
|
||||
for _, line := range noise {
|
||||
if _, ok := parseSpot(line); ok {
|
||||
t.Errorf("noise line parsed as spot: %q", line)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Multi-cluster support: the user can keep several DX cluster nodes saved
|
||||
-- and connect to them concurrently. The first enabled row (by sort_order)
|
||||
-- is the "master" — commands typed in the UI are sent through it.
|
||||
CREATE TABLE cluster_servers (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL, -- "VE7CC", "F4BPO home", …
|
||||
host TEXT NOT NULL,
|
||||
port INTEGER NOT NULL DEFAULT 7300,
|
||||
login_override TEXT NOT NULL DEFAULT '', -- empty = use active profile callsign
|
||||
password TEXT NOT NULL DEFAULT '', -- some nodes require one
|
||||
init_commands TEXT NOT NULL DEFAULT '', -- newline-separated, sent after login
|
||||
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_cluster_servers_enabled ON cluster_servers(enabled, sort_order);
|
||||
|
||||
-- UI options for the Cluster tab. Stored in settings (key/value) as
|
||||
-- usual, no migration needed.
|
||||
+21
-24
@@ -140,8 +140,11 @@ func (m *Manager) Lookup(ctx context.Context, callsign string) (Result, error) {
|
||||
return Result{}, lastErr
|
||||
}
|
||||
|
||||
// fillFromDXCC fills in country/continent/zones/lat/lon from the cty.dat
|
||||
// resolver when the provider returned them empty. Provider data wins.
|
||||
// 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).
|
||||
// Returns true if any field was filled.
|
||||
func fillFromDXCC(r *Result, dxcc DXCCResolver) bool {
|
||||
if dxcc == nil {
|
||||
@@ -151,29 +154,23 @@ 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 r.Country == "" && country != "" {
|
||||
r.Country = country
|
||||
filled = true
|
||||
}
|
||||
if r.Continent == "" && cont != "" {
|
||||
r.Continent = cont
|
||||
filled = true
|
||||
}
|
||||
if r.CQZ == 0 && cqz != 0 {
|
||||
r.CQZ = cqz
|
||||
filled = true
|
||||
}
|
||||
if r.ITUZ == 0 && ituz != 0 {
|
||||
r.ITUZ = ituz
|
||||
filled = true
|
||||
}
|
||||
if r.Lat == 0 && lat != 0 {
|
||||
r.Lat = lat
|
||||
filled = true
|
||||
}
|
||||
if r.Lon == 0 && lon != 0 {
|
||||
r.Lon = lon
|
||||
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 {
|
||||
r.DXCC = 0
|
||||
filled = true
|
||||
}
|
||||
return filled
|
||||
|
||||
@@ -800,6 +800,78 @@ func sortStrings(s []string) {
|
||||
}
|
||||
}
|
||||
|
||||
// IterateAll streams every QSO in the database through fn, ordered by
|
||||
// qso_date ascending so an ADIF export is chronological. Constant memory
|
||||
// regardless of table size — the alternative (loading all 25k+ rows into
|
||||
// a slice) wastes ~20MB for no good reason.
|
||||
func (r *Repo) IterateAll(ctx context.Context, fn func(QSO) error) error {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT `+selectCols+` FROM qso ORDER BY qso_date ASC, id ASC`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("query qso: %w", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
for rows.Next() {
|
||||
q, err := scanQSO(rows)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := fn(q); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return rows.Err()
|
||||
}
|
||||
|
||||
// EntitySlot bundles every (band, mode) tuple ever worked for a given
|
||||
// DXCC entity name. Used by the cluster spot colouring code to decide
|
||||
// NEW / NEW SLOT / WORKED in constant time after one batched query.
|
||||
type EntitySlot struct {
|
||||
Country string
|
||||
Bands map[string]struct{} // bands worked, any mode
|
||||
Slots map[string]map[string]struct{} // band → modes worked
|
||||
}
|
||||
|
||||
// EntitySlotMap returns slot data for every QSO grouped by lowercase
|
||||
// country name (cty.dat-style key). Cheap on a 25k-row table: one
|
||||
// scan, no joins. Callers can compare a spot's entity to this map to
|
||||
// decide if it's NEW / NEW SLOT / WORKED.
|
||||
func (r *Repo) EntitySlotMap(ctx context.Context) (map[string]*EntitySlot, error) {
|
||||
rows, err := r.db.QueryContext(ctx,
|
||||
`SELECT lower(country), lower(band), upper(mode) FROM qso
|
||||
WHERE country IS NOT NULL AND country != ''
|
||||
AND band IS NOT NULL AND band != ''
|
||||
AND mode IS NOT NULL AND mode != ''`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
out := make(map[string]*EntitySlot, 256)
|
||||
for rows.Next() {
|
||||
var country, band, mode string
|
||||
if err := rows.Scan(&country, &band, &mode); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
e, ok := out[country]
|
||||
if !ok {
|
||||
e = &EntitySlot{
|
||||
Country: country,
|
||||
Bands: make(map[string]struct{}),
|
||||
Slots: make(map[string]map[string]struct{}),
|
||||
}
|
||||
out[country] = e
|
||||
}
|
||||
e.Bands[band] = struct{}{}
|
||||
bandSlots, ok := e.Slots[band]
|
||||
if !ok {
|
||||
bandSlots = make(map[string]struct{})
|
||||
e.Slots[band] = bandSlots
|
||||
}
|
||||
bandSlots[mode] = struct{}{}
|
||||
}
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
// Count returns the total number of QSOs in the database.
|
||||
func (r *Repo) Count(ctx context.Context) (int64, error) {
|
||||
var n int64
|
||||
|
||||
Reference in New Issue
Block a user