feat: added command sent in cluster panel
This commit is contained in:
+141
-11
@@ -61,6 +61,11 @@ type Spot struct {
|
||||
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
|
||||
}
|
||||
@@ -92,6 +97,21 @@ type ServerStatus struct {
|
||||
}
|
||||
|
||||
// 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.
|
||||
@@ -99,6 +119,7 @@ type session struct {
|
||||
cfg ServerConfig
|
||||
login string
|
||||
onSpot func(Spot)
|
||||
onLine func(Line)
|
||||
onStatus func()
|
||||
|
||||
mu sync.RWMutex
|
||||
@@ -117,6 +138,7 @@ type Manager struct {
|
||||
mu sync.RWMutex
|
||||
sessions map[int64]*session
|
||||
onSpot func(Spot)
|
||||
onLine func(Line)
|
||||
onStatus func()
|
||||
}
|
||||
|
||||
@@ -124,10 +146,11 @@ type Manager struct {
|
||||
// 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 {
|
||||
func NewManager(emitSpot func(Spot), emitStatusChanged func(), emitLine func(Line)) *Manager {
|
||||
return &Manager{
|
||||
sessions: make(map[int64]*session),
|
||||
onSpot: emitSpot,
|
||||
onLine: emitLine,
|
||||
onStatus: emitStatusChanged,
|
||||
}
|
||||
}
|
||||
@@ -155,6 +178,7 @@ func (m *Manager) StartServer(cfg ServerConfig, login string) {
|
||||
cfg: cfg,
|
||||
login: login,
|
||||
onSpot: m.onSpot,
|
||||
onLine: m.onLine,
|
||||
onStatus: m.emitStatus,
|
||||
stopCh: make(chan struct{}),
|
||||
doneCh: make(chan struct{}),
|
||||
@@ -242,6 +266,12 @@ func (s *session) send(cmd string) error {
|
||||
}
|
||||
_ = 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
|
||||
}
|
||||
|
||||
@@ -353,8 +383,18 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}()
|
||||
}
|
||||
|
||||
// Init commands: fire 1s after login goes through. Each command on
|
||||
// its own line; blank lines and "//" comments are skipped.
|
||||
// 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) == "" {
|
||||
@@ -362,10 +402,9 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}
|
||||
initFired = true
|
||||
go func() {
|
||||
time.Sleep(1 * time.Second)
|
||||
time.Sleep(initCommandLeadIn)
|
||||
for _, line := range strings.Split(s.cfg.InitCommands, "\n") {
|
||||
line = strings.TrimRight(line, "\r")
|
||||
line = strings.TrimSpace(line)
|
||||
line = strings.TrimSpace(strings.TrimRight(line, "\r"))
|
||||
if line == "" || strings.HasPrefix(line, "//") {
|
||||
continue
|
||||
}
|
||||
@@ -374,8 +413,11 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
return
|
||||
default:
|
||||
}
|
||||
_, _ = conn.Write([]byte(line + "\r\n"))
|
||||
time.Sleep(500 * time.Millisecond)
|
||||
if err := s.send(line); err != nil {
|
||||
s.emitLine("init command failed: "+line+" — "+err.Error(), false)
|
||||
return
|
||||
}
|
||||
time.Sleep(initCommandGap)
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -444,12 +486,28 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
fireInitCommands()
|
||||
}
|
||||
|
||||
if spot, ok := parseSpot(line); ok {
|
||||
// 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()
|
||||
s.spotsCnt++
|
||||
s.status.SpotsCount = s.spotsCnt
|
||||
// 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 {
|
||||
s.onSpot(spot)
|
||||
@@ -458,6 +516,21 @@ func (s *session) runOnce() (time.Time, error) {
|
||||
}
|
||||
}
|
||||
|
||||
// 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]".
|
||||
@@ -465,6 +538,63 @@ 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*$`,
|
||||
)
|
||||
|
||||
// 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) {
|
||||
m := spotRE.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
|
||||
Reference in New Issue
Block a user