An operator sent two screenshots: 76 LIVE in the counter, and the panel beside it reading "Waiting for spots… Spots will appear as the cluster sends them." Both his band and mode were locked to the rig — 20 m, SSB — so the filters were doing exactly their job and the empty state was describing a different problem entirely. He went looking for a connection fault. It now says how many arrived, names every filter currently narrowing the list, and offers one button to clear them all. The band and mode locks are named first: they follow the rig rather than a click, so they are the two nobody remembers switching on. Also times the connection. He reports the first launch taking a while to produce spots and a restart connecting instantly, which is the signature of a slow name resolution rather than a slow node — the OS caches the answer, so the second run skips it. The log now carries the dial duration and the delay to the first spot, which separates that from a node that simply had nothing to say. Hypothesis, not conclusion: the next log settles it.
786 lines
25 KiB
Go
786 lines
25 KiB
Go
// 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"
|
|
"errors"
|
|
"fmt"
|
|
"net"
|
|
"regexp"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"hamlog/internal/applog"
|
|
)
|
|
|
|
// 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.
|
|
// Country/Continent are filled by the caller (app.go) before the spot
|
|
// is emitted to the UI, so the table never has empty country cells
|
|
// flickering in for a few hundred ms.
|
|
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"`
|
|
Country string `json:"country,omitempty"` // DXCC entity name (cty.dat)
|
|
Continent string `json:"continent,omitempty"` // 2-letter continent
|
|
CQZone int `json:"cqz,omitempty"` // DXCC entity CQ zone
|
|
ITUZone int `json:"ituz,omitempty"` // DXCC entity ITU zone
|
|
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
|
|
LongPath int `json:"lp_deg,omitempty"` // azimuth (deg) long path = SP + 180 mod 360
|
|
ReceivedAt time.Time `json:"received_at"`
|
|
Raw string `json:"raw"`
|
|
// 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:
|
|
// replaying 100 past spots would spam both, and a station spotted three hours
|
|
// ago is not on the air now.
|
|
Historical bool `json:"historical,omitempty"`
|
|
POTARef string `json:"pota_ref,omitempty"` // park id if this station is activating (api.pota.app)
|
|
POTAName string `json:"pota_name,omitempty"` // park name
|
|
}
|
|
|
|
// 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.
|
|
// Line is one raw line of cluster traffic: everything the server says, plus the
|
|
// commands we send (echoed, so the console reads as a conversation).
|
|
//
|
|
// Spots are PARSED OUT of this stream — but everything else used to be silently
|
|
// dropped. You could type SH/DX/100 or WHO and never see the answer, which made
|
|
// the command box look broken. The raw stream is the fix: the console shows what
|
|
// the server actually said, spot or not.
|
|
type Line struct {
|
|
ServerID int64 `json:"server_id"`
|
|
ServerName string `json:"server_name"`
|
|
Text string `json:"text"`
|
|
Sent bool `json:"sent"` // true = a command WE sent
|
|
At string `json:"at"` // HH:MM:SS, local
|
|
}
|
|
|
|
// 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)
|
|
onLine func(Line)
|
|
onStatus func()
|
|
|
|
mu sync.RWMutex
|
|
status ServerStatus
|
|
conn net.Conn
|
|
stopCh chan struct{}
|
|
doneCh chan struct{}
|
|
stopped bool // guards against double-stop on the same session
|
|
spotsCnt int
|
|
dbgN int // diagnostic: how many raw lines logged this connection
|
|
}
|
|
|
|
// 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)
|
|
onLine func(Line)
|
|
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(), emitLine func(Line)) *Manager {
|
|
return &Manager{
|
|
sessions: make(map[int64]*session),
|
|
onSpot: emitSpot,
|
|
onLine: emitLine,
|
|
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,
|
|
onLine: m.onLine,
|
|
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"))
|
|
if err == nil {
|
|
// Echo it into the console, so the operator sees WHAT was sent and can pair
|
|
// it with the reply that follows. A console that only shows one side of the
|
|
// conversation is barely better than none.
|
|
s.emitLine(cmd, true)
|
|
}
|
|
return err
|
|
}
|
|
|
|
func (s *session) stop() {
|
|
// Critical: do NOT nil out s.stopCh — the supervisor goroutine reads
|
|
// `<-s.stopCh` in its select. Setting the field to nil would make
|
|
// `<-nil` block forever, leaving the supervisor stuck on its backoff
|
|
// timer and then re-dialing → a "deleted" cluster keeps spotting.
|
|
// We just close() the channel and let the goroutine see the broadcast.
|
|
s.mu.Lock()
|
|
if s.stopped {
|
|
s.mu.Unlock()
|
|
return
|
|
}
|
|
s.stopped = true
|
|
stop, done := s.stopCh, s.doneCh
|
|
conn := s.conn
|
|
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):
|
|
}
|
|
}
|
|
}
|
|
|
|
// idleTick is how often the read is interrupted so the loop can notice a stop
|
|
// request. It is NOT a liveness test — see the read loop.
|
|
//
|
|
// A var, not a const, so the test that proves a silent node survives can shorten
|
|
// it: at 30 s that test spends a minute doing nothing, and a slow test is a test
|
|
// that gets skipped.
|
|
var idleTick = 30 * time.Second
|
|
|
|
// quietNotice is the silence after which the log says so, once.
|
|
const quietNotice = 10 * time.Minute
|
|
|
|
// 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 := net.JoinHostPort(s.cfg.Host, fmt.Sprintf("%d", s.cfg.Port)) // IPv6-safe
|
|
// KeepAlive is set explicitly rather than left to the package default: it is
|
|
// what actually detects a dead peer here, so it should be visible in the
|
|
// code that depends on it. The OS probes an idle connection and a genuine
|
|
// failure surfaces as an error on Read — which is the only thing that ends
|
|
// a session below.
|
|
d := net.Dialer{Timeout: 10 * time.Second, KeepAlive: 30 * time.Second}
|
|
// TIMED, and reported. An operator sees "connected" and no spots for a
|
|
// minute on the first launch, then an instant connection when the program is
|
|
// restarted — which is the signature of a slow name resolution rather than a
|
|
// slow cluster (the OS caches the answer, so the second run skips it). The
|
|
// only way to tell that from a node that simply had nothing to say is to
|
|
// know how long the dial itself took.
|
|
dialStart := time.Now()
|
|
conn, err := d.Dial("tcp", addr)
|
|
if err != nil {
|
|
applog.Printf("cluster[%s] dial %s failed after %s: %v", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond), err)
|
|
return time.Time{}, fmt.Errorf("dial %s: %w", addr, err)
|
|
}
|
|
applog.Printf("cluster[%s] connected to %s in %s", s.cfg.Name, addr, time.Since(dialStart).Round(time.Millisecond))
|
|
linkUpAt := time.Now()
|
|
firstSpotLogged := false
|
|
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, once per connection (so they replay after a reconnect).
|
|
//
|
|
// Timing: the cluster needs a moment after login before it will take commands,
|
|
// hence the lead-in wait; then they are PACED, because a DXSpider/AR-Cluster
|
|
// happily swallows a burst of back-to-back lines and silently ignores half of
|
|
// them. A fast-but-dropped command is worse than a slow one.
|
|
//
|
|
// They go through s.send() — NOT a raw conn.Write, which is what they used to
|
|
// do. That matters for two reasons: the raw write skipped the write deadline
|
|
// (a wedged server could hang this goroutine forever), and it skipped the
|
|
// console echo, so there was no way to SEE whether your init commands had
|
|
// actually been applied. Now you watch them go out and the reply come back.
|
|
initFired := false
|
|
fireInitCommands := func() {
|
|
if initFired || strings.TrimSpace(s.cfg.InitCommands) == "" {
|
|
return
|
|
}
|
|
initFired = true
|
|
go func() {
|
|
time.Sleep(initCommandLeadIn)
|
|
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
|
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
|
if line == "" || strings.HasPrefix(line, "//") {
|
|
continue
|
|
}
|
|
select {
|
|
case <-s.stopCh:
|
|
return
|
|
default:
|
|
}
|
|
if err := s.send(line); err != nil {
|
|
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
|
|
return
|
|
}
|
|
time.Sleep(initCommandGap)
|
|
}
|
|
}()
|
|
}
|
|
|
|
var connectedAt time.Time
|
|
var quiet time.Duration // how long the node has said nothing
|
|
var pending string // a line cut in half by a read deadline
|
|
rd := bufio.NewReader(conn)
|
|
for {
|
|
select {
|
|
case <-s.stopCh:
|
|
return connectedAt, nil
|
|
default:
|
|
}
|
|
|
|
// The deadline exists to keep this loop responsive to stopCh, NOT to
|
|
// judge the link. A quiet node is a quiet node: some clusters send
|
|
// nothing for minutes on a dead band, and tearing the socket down over
|
|
// it produced a reconnect every two minutes — which loses the login,
|
|
// the filters, and any spot that arrived during the gap.
|
|
//
|
|
// Only a REAL error ends the session. A dead peer still gets caught:
|
|
// TCP keepalive probes an idle connection and its failure arrives here
|
|
// as an error, not as a timeout.
|
|
_ = conn.SetReadDeadline(time.Now().Add(idleTick))
|
|
chunk, err := rd.ReadString('\n')
|
|
if err != nil {
|
|
var ne net.Error
|
|
if errors.As(err, &ne) && ne.Timeout() {
|
|
// ReadString hands back what it HAD read along with the timeout.
|
|
// Keep it: a spot line straddling the deadline would otherwise lose
|
|
// its first half and arrive as nonsense, or vanish entirely.
|
|
pending += chunk
|
|
quiet += idleTick
|
|
// Said once at the first long silence, so a genuinely mute node is
|
|
// visible without a line every tick.
|
|
if quiet == quietNotice {
|
|
applog.Printf("cluster[%s] no traffic for %s — still connected", s.cfg.Name, quiet)
|
|
}
|
|
continue
|
|
}
|
|
return connectedAt, fmt.Errorf("read: %w", err)
|
|
}
|
|
quiet = 0
|
|
line := pending + chunk
|
|
pending = ""
|
|
line = strings.TrimRight(line, "\r\n")
|
|
// Strip terminal BELLs (\a) and other control bytes that some nodes
|
|
// (e.g. F5LEN) append to spot lines — "DX de …0935Z KN04\a\a" — which
|
|
// would otherwise break the parser's end-of-line anchor. Tabs → spaces.
|
|
line = strings.Map(func(r rune) rune {
|
|
if r == '\t' {
|
|
return ' '
|
|
}
|
|
if r < 0x20 || r == 0x7f {
|
|
return -1
|
|
}
|
|
return r
|
|
}, line)
|
|
if line == "" {
|
|
continue
|
|
}
|
|
|
|
// Safety net: warn (rate-limited) about DX lines the parser still can't
|
|
// read, so a future format drift is visible in the log.
|
|
if strings.Contains(line, "DX de") && s.dbgN < 10 {
|
|
if _, ok := parseSpot(line); !ok {
|
|
s.dbgN++
|
|
applog.Printf("cluster[%s] UNPARSED spot: %q", s.cfg.Name, line)
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|
|
|
|
// EVERY line goes to the console — spot or not. This is the whole point:
|
|
// SH/DX, WHO, the MOTD and error replies are not spots, and dropping them
|
|
// (which is what happened before) made the command box look inert.
|
|
s.emitLine(line, false)
|
|
|
|
// Live broadcast first; then the SH/DX table form. Without the second, a
|
|
// SH/DX reply arrived, matched nothing, and vanished — which is exactly why
|
|
// "SH/DX/100" looked like it did nothing at all.
|
|
spot, ok := parseSpot(line)
|
|
if !ok {
|
|
spot, ok = parseShowDX(line)
|
|
}
|
|
if ok {
|
|
spot.SourceID = s.cfg.ID
|
|
spot.SourceName = s.cfg.Name
|
|
s.mu.Lock()
|
|
// Historical spots are a replay of the past, not new traffic — counting
|
|
// them would inflate the server's "spots received" figure.
|
|
if !spot.Historical {
|
|
s.spotsCnt++
|
|
s.status.SpotsCount = s.spotsCnt
|
|
}
|
|
s.mu.Unlock()
|
|
if s.onSpot != nil {
|
|
if !firstSpotLogged {
|
|
firstSpotLogged = true
|
|
// The gap between the socket opening and the first spot is the
|
|
// other half of the answer: a long dial is the network, a quick
|
|
// dial and a long silence is the node (or the login) instead.
|
|
applog.Printf("cluster[%s] first spot %s after connecting", s.cfg.Name, time.Since(linkUpAt).Round(time.Millisecond))
|
|
}
|
|
s.onSpot(spot)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// emitLine hands one line of traffic to the console. Blank lines are dropped —
|
|
// clusters send plenty and they'd only pad the scrollback.
|
|
func (s *session) emitLine(text string, sent bool) {
|
|
if s.onLine == nil || strings.TrimSpace(text) == "" {
|
|
return
|
|
}
|
|
s.onLine(Line{
|
|
ServerID: s.cfg.ID,
|
|
ServerName: s.cfg.Name,
|
|
Text: strings.TrimRight(text, "\r\n"),
|
|
Sent: sent,
|
|
At: time.Now().Format("15:04:05"),
|
|
})
|
|
}
|
|
|
|
// ---------- parsing ----------
|
|
|
|
// spotRE matches "DX de SPOTTER: FREQ DXCALL COMMENT TIME [LOC]".
|
|
//
|
|
// The spotter→freq separator is (?::\s*|\s+): a colon followed by ANY number of
|
|
// spaces (including ZERO), or one-or-more spaces with no colon. Some RBN skimmer
|
|
// nodes glue the frequency straight onto the colon — "DX de DL1HWS-3-#:14024.0 …"
|
|
// — which the old ":?\s+" (colon then a REQUIRED space) rejected, dropping every
|
|
// spot from those nodes.
|
|
var spotRE = regexp.MustCompile(
|
|
`^\s*DX\s+de\s+([A-Z0-9/#\-]+)(?::\s*|\s+)(\d+\.?\d*)\s+([A-Z0-9/]+)\s+(.*?)\s+(\d{4}Z?)(?:\s+([A-R]{2}\d{2}(?:[A-X]{2})?))?\s*$`,
|
|
)
|
|
|
|
// Pacing for the per-server init commands.
|
|
//
|
|
// A cluster is not ready to take commands the instant the login line goes out —
|
|
// it is still printing its banner — so we wait before the first one. And they are
|
|
// spaced, because DXSpider / AR-Cluster will happily accept a burst of
|
|
// back-to-back lines and silently apply only some of them: the connection stays
|
|
// up, no error is returned, your filters just aren't set. A command that is
|
|
// dropped is far worse than one that is slow.
|
|
const (
|
|
initCommandLeadIn = 1500 * time.Millisecond
|
|
initCommandGap = 700 * time.Millisecond
|
|
)
|
|
|
|
// showDXRE matches the reply to SH/DX — which is a TABLE, not the "DX de …"
|
|
// broadcast format, and therefore matched nothing at all:
|
|
//
|
|
// 14195.0 EA8DHH 3-Jul-2026 1234Z CQ DX <F5ABC>
|
|
// freq dxcall date time comment <spotter>
|
|
//
|
|
// 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
|
|
// what keeps ordinary prose out of the spot grid.
|
|
var showDXRE = regexp.MustCompile(
|
|
`^\s*(\d{3,7}\.\d+)\s+([A-Z0-9]{1,3}[0-9][A-Z0-9/]*)\s+(\d{1,2}-[A-Za-z]{3}-\d{4})\s+(\d{4})Z?\s*(.*?)\s*(?:<\s*([A-Z0-9/#\-]+)\s*>)?\s*$`,
|
|
)
|
|
|
|
// parseShowDX turns one line of a SH/DX table into a Spot. The result is flagged
|
|
// Historical: these are PAST spots, so they belong in the grid but must not fire
|
|
// alerts or land on the panadapter — replaying 100 of them would spam both, and a
|
|
// station spotted three hours ago is not on the air now.
|
|
func parseShowDX(line string) (Spot, bool) {
|
|
if strings.Contains(strings.ToUpper(line), "DX DE") {
|
|
return Spot{}, false // that's the broadcast form; spotRE owns it
|
|
}
|
|
m := showDXRE.FindStringSubmatch(line)
|
|
if m == nil {
|
|
return Spot{}, false
|
|
}
|
|
khz, err := strconv.ParseFloat(m[1], 64)
|
|
if err != nil || khz <= 0 {
|
|
return Spot{}, false
|
|
}
|
|
hz := int64(khz * 1000)
|
|
return Spot{
|
|
Spotter: strings.ToUpper(m[6]),
|
|
DXCall: strings.ToUpper(m[2]),
|
|
FreqKHz: khz,
|
|
FreqHz: hz,
|
|
Band: bandFromHz(hz),
|
|
Comment: strings.TrimSpace(m[5]),
|
|
TimeUTC: m[4] + "Z",
|
|
ReceivedAt: time.Now(),
|
|
Raw: line,
|
|
Historical: true,
|
|
}, true
|
|
}
|
|
|
|
func parseSpot(line string) (Spot, bool) {
|
|
// A cluster prints its prompt WITHOUT a trailing newline, so the first spot
|
|
// to arrive gets glued onto it and the line reads
|
|
// "login: DX de YO2CK-#: 21074.0 PY4PTO FT8 …". The anchor then fails
|
|
// and the spot is dropped — silently, apart from an UNPARSED log line.
|
|
//
|
|
// Cutting everything ahead of "DX de" rather than un-anchoring the pattern:
|
|
// an un-anchored match would also accept a talk/announce line that merely
|
|
// QUOTES a spot, and start injecting other people's conversations as real
|
|
// spots.
|
|
if i := strings.Index(line, "DX de"); i > 0 {
|
|
line = line[i:]
|
|
}
|
|
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"
|
|
// 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 ""
|
|
}
|