+
-
-
Total Spots
+
Received
+
+
+
+
+
+
+
+
+
{stats.spotsProcessed || 0}
+
+
Processed
+
+
+
+
+
+
+
+
+
+ {stats.spotSuccessRate ? stats.spotSuccessRate.toFixed(1) : '0.0'}%
+
+
+
Success Rate
-
+
- {stats.newDXCC}
+ {stats.newDXCC}
New DXCC
diff --git a/httpserver.go b/httpserver.go
index 5154672..a937f80 100644
--- a/httpserver.go
+++ b/httpserver.go
@@ -21,6 +21,7 @@ import (
//go:embed frontend/dist/*
var frontendFiles embed.FS
+var httpServerInstance *HTTPServer
type HTTPServer struct {
Router *mux.Router
@@ -52,6 +53,10 @@ type Stats struct {
MyCallsign string `json:"myCallsign"`
Mode string `json:"mode"`
Filters Filters `json:"filters"`
+ SpotsReceived int64 `json:"spotsReceived"`
+ SpotsProcessed int64 `json:"spotsProcessed"`
+ SpotsRejected int64 `json:"spotsRejected"`
+ SpotSuccessRate float64 `json:"spotSuccessRate"`
}
type Filters struct {
@@ -131,6 +136,8 @@ func NewHTTPServer(flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContact
lastBandOpening: make(map[string]time.Time),
}
+ httpServerInstance = server
+
server.setupRoutes()
go server.handleBroadcasts()
go server.broadcastUpdates()
@@ -162,6 +169,8 @@ func (s *HTTPServer) setupRoutes() {
api.HandleFunc("/watchlist/remove", s.removeFromWatchlist).Methods("DELETE", "OPTIONS")
api.HandleFunc("/watchlist/update-notes", s.updateWatchlistNotes).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist/update-sound", s.updateWatchlistSound).Methods("POST", "OPTIONS")
+ api.HandleFunc("/stats/spots", s.getSpotProcessingStats).Methods("GET", "OPTIONS")
+ api.HandleFunc("/logs", s.getLogs).Methods("GET", "OPTIONS")
// WebSocket endpoint
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
@@ -268,6 +277,11 @@ func (s *HTTPServer) sendInitialData(conn *websocket.Conn) {
"percentage": float64(dxccCount) / 340.0 * 100.0,
}
conn.WriteJSON(WSMessage{Type: "dxccProgress", Data: dxccData})
+
+ if logBuffer != nil {
+ logs := logBuffer.GetAll()
+ conn.WriteJSON(WSMessage{Type: "appLogs", Data: logs})
+ }
}
func (s *HTTPServer) handleBroadcasts() {
@@ -355,6 +369,30 @@ func (s *HTTPServer) broadcastUpdates() {
}
}
+func (s *HTTPServer) getLogs(w http.ResponseWriter, r *http.Request) {
+ if logBuffer == nil {
+ s.sendJSON(w, APIResponse{Success: true, Data: []LogEntry{}})
+ return
+ }
+
+ logs := logBuffer.GetAll()
+ s.sendJSON(w, APIResponse{Success: true, Data: logs})
+}
+
+func (s *HTTPServer) getSpotProcessingStats(w http.ResponseWriter, r *http.Request) {
+ received, processed, rejected := GetSpotStats()
+ successRate := GetSpotSuccessRate()
+
+ stats := map[string]interface{}{
+ "received": received,
+ "processed": processed,
+ "rejected": rejected,
+ "successRate": successRate,
+ }
+
+ s.sendJSON(w, APIResponse{Success: true, Data: stats})
+}
+
func (s *HTTPServer) checkQSOMilestones(todayCount int) {
s.statsMutex.Lock()
defer s.statsMutex.Unlock()
@@ -463,6 +501,10 @@ func (s *HTTPServer) calculateStats() Stats {
flexStatus = "connected"
}
+ // Récupérer les stats de traitement des spots
+ received, processed, rejected := GetSpotStats()
+ successRate := GetSpotSuccessRate()
+
return Stats{
TotalSpots: len(allSpots),
NewDXCC: newDXCCCount,
@@ -477,6 +519,10 @@ func (s *HTTPServer) calculateStats() Stats {
FT4: Cfg.Cluster.FT4,
Beacon: Cfg.Cluster.Beacon,
},
+ SpotsReceived: received,
+ SpotsProcessed: processed,
+ SpotsRejected: rejected,
+ SpotSuccessRate: successRate,
}
}
@@ -901,6 +947,7 @@ func (s *HTTPServer) sendJSON(w http.ResponseWriter, data interface{}) {
json.NewEncoder(w).Encode(data)
}
+// ✅ Fonction de shutdown propre
func (s *HTTPServer) shutdownApp(w http.ResponseWriter, r *http.Request) {
s.Log.Info("Shutdown request received from dashboard")
@@ -908,7 +955,10 @@ func (s *HTTPServer) shutdownApp(w http.ResponseWriter, r *http.Request) {
go func() {
time.Sleep(500 * time.Millisecond)
- s.Log.Info("Initiating shutdown...")
+
+ // ✅ Utiliser le shutdown centralisé
+ GracefulShutdown(s.TCPClient, s.TCPServer, s.FlexClient, s.FlexRepo, s.ContactRepo)
+
os.Exit(0)
}()
}
diff --git a/log.go b/log.go
index 60e5b86..498c9b6 100644
--- a/log.go
+++ b/log.go
@@ -1,42 +1,102 @@
package main
import (
+ "context"
+ "fmt"
"io"
"os"
+ "path/filepath"
+ "sync"
+ "time"
log "github.com/sirupsen/logrus"
prefixed "github.com/x-cray/logrus-prefixed-formatter"
)
var Log *log.Logger
+var logFile *os.File
+var logWriter *syncWriter
+var logCtx context.Context
+var logCancel context.CancelFunc
+
+// syncWriter écrit de manière synchrone (pas de buffer)
+type syncWriter struct {
+ file *os.File
+ mutex sync.Mutex
+}
+
+func (w *syncWriter) Write(p []byte) (n int, err error) {
+ w.mutex.Lock()
+ defer w.mutex.Unlock()
+
+ n, err = w.file.Write(p)
+ if err == nil {
+ w.file.Sync() // Force l'écriture immédiate sur disque
+ }
+ return n, err
+}
func NewLog() *log.Logger {
+ // ✅ Vérifier que Cfg existe
+ if Cfg == nil {
+ panic("Config not initialized! Call NewConfig() before NewLog()")
+ }
+
+ // ✅ Chemin du log à côté de l'exe
+ exe, _ := os.Executable()
+ exePath := filepath.Dir(exe)
+ logPath := filepath.Join(exePath, "flexradio.log")
+
if Cfg.General.DeleteLogFileAtStart {
- if _, err := os.Stat("flexradio.log"); err == nil {
- os.Remove("flexradio.log")
+ if _, err := os.Stat(logPath); err == nil {
+ os.Remove(logPath)
}
}
+ logCtx, logCancel = context.WithCancel(context.Background())
+
var w io.Writer
+
if Cfg.General.LogToFile {
- f, err := os.OpenFile("flexradio.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
+ f, err := os.OpenFile(logPath, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
- panic(err)
+ panic(fmt.Sprintf("Cannot open log file %s: %v", logPath, err))
+ }
+
+ logFile = f
+ logWriter = &syncWriter{file: f}
+
+ // ✅ IMPORTANT: Vérifier si Stdout est disponible (mode console vs GUI)
+ if isConsoleAvailable() {
+ // Mode console : log vers fichier ET console
+ w = io.MultiWriter(os.Stdout, logWriter)
+ } else {
+ // Mode GUI (windowsgui) : log SEULEMENT vers fichier
+ w = logWriter
}
- w = io.MultiWriter(os.Stdout, f)
} else {
- w = io.Writer(os.Stdout)
+ // Log uniquement vers console (si disponible)
+ if isConsoleAvailable() {
+ w = os.Stdout
+ } else {
+ // Pas de console, pas de log fichier -> log vers null
+ w = io.Discard
+ }
}
Log = &log.Logger{
Out: w,
Formatter: &prefixed.TextFormatter{
- DisableColors: false,
- TimestampFormat: "02-01-2006 15:04:05",
- FullTimestamp: true,
- ForceFormatting: true,
+ DisableColors: !isConsoleAvailable(),
+ TimestampFormat: "02-01-2006 15:04:05",
+ FullTimestamp: true,
+ ForceFormatting: true,
+ DisableSorting: true, // ✅ Ajoute
+ QuoteEmptyFields: true, // ✅ Ajoute
+ SpacePadding: 0, // ✅ Ajoute (pas d'espace)
},
+ Hooks: make(log.LevelHooks),
}
if Cfg.General.LogLevel == "DEBUG" {
@@ -49,5 +109,55 @@ func NewLog() *log.Logger {
Log.Level = log.InfoLevel
}
+ logBuffer = NewLogBuffer(500) // Garde les 500 derniers logs
+ // Log.AddHook(&LogHook{buffer: logBuffer})
+
+ // ✅ Premier vrai log
+ Log.Infof("Logger initialized - Level: %s, ToFile: %v, LogPath: %s",
+ Cfg.General.LogLevel, Cfg.General.LogToFile, logPath)
+
return Log
}
+
+func InitLogHook() {
+ if logBuffer == nil {
+ logBuffer = NewLogBuffer(500)
+ }
+
+ Log.AddHook(&LogHook{buffer: logBuffer})
+ Log.Info("Log hook initialized and broadcasting enabled")
+}
+
+// ✅ Détecter si on a une console (fonctionne sur Windows)
+func isConsoleAvailable() bool {
+ // Si Stdout est nil ou invalide, on n'a pas de console
+ stat, err := os.Stdout.Stat()
+ if err != nil {
+ return false
+ }
+ // Si c'est un char device, on a une console
+ return (stat.Mode() & os.ModeCharDevice) != 0
+}
+
+// ✅ Fonction pour fermer proprement le log
+func CloseLog() {
+ if Log != nil {
+ Log.Info("Closing log file...")
+ }
+
+ if logCancel != nil {
+ logCancel()
+ }
+
+ time.Sleep(200 * time.Millisecond) // Donne le temps d'écrire
+
+ if logWriter != nil {
+ logWriter.mutex.Lock()
+ if logFile != nil {
+ logFile.Sync()
+ logFile.Close()
+ logFile = nil
+ }
+ logWriter.mutex.Unlock()
+ }
+}
diff --git a/loghook.go b/loghook.go
new file mode 100644
index 0000000..2129005
--- /dev/null
+++ b/loghook.go
@@ -0,0 +1,80 @@
+package main
+
+import (
+ "sync"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// LogBuffer garde les derniers logs en mémoire
+type LogBuffer struct {
+ entries []LogEntry
+ maxSize int
+ mutex sync.RWMutex
+}
+
+type LogEntry struct {
+ Timestamp string `json:"timestamp"`
+ Level string `json:"level"`
+ Message string `json:"message"`
+}
+
+var logBuffer *LogBuffer
+
+func NewLogBuffer(maxSize int) *LogBuffer {
+ return &LogBuffer{
+ entries: make([]LogEntry, 0, maxSize),
+ maxSize: maxSize,
+ }
+}
+
+func (lb *LogBuffer) Add(entry LogEntry) {
+ lb.mutex.Lock()
+ defer lb.mutex.Unlock()
+
+ lb.entries = append(lb.entries, entry)
+
+ // Garder seulement les N derniers
+ if len(lb.entries) > lb.maxSize {
+ lb.entries = lb.entries[1:]
+ }
+}
+
+func (lb *LogBuffer) GetAll() []LogEntry {
+ lb.mutex.RLock()
+ defer lb.mutex.RUnlock()
+
+ // Retourner une copie
+ result := make([]LogEntry, len(lb.entries))
+ copy(result, lb.entries)
+ return result
+}
+
+// Hook pour capturer les logs
+type LogHook struct {
+ buffer *LogBuffer
+}
+
+func (h *LogHook) Levels() []log.Level {
+ return log.AllLevels
+}
+
+func (h *LogHook) Fire(entry *log.Entry) error {
+ logEntry := LogEntry{
+ Timestamp: entry.Time.Format("02-01-2006 15:04:05"),
+ Level: entry.Level.String(),
+ Message: entry.Message,
+ }
+
+ h.buffer.Add(logEntry)
+
+ // Broadcaster vers les clients WebSocket connectés
+ if httpServerInstance != nil {
+ httpServerInstance.broadcast <- WSMessage{
+ Type: "appLog",
+ Data: logEntry,
+ }
+ }
+
+ return nil
+}
diff --git a/main.go b/main.go
index fdc16f3..d1ca388 100644
--- a/main.go
+++ b/main.go
@@ -33,6 +33,35 @@ func ParseFlags() (string, error) {
return configPath, nil
}
+func GracefulShutdown(tcpClient *TCPClient, tcpServer *TCPServer, flexClient *FlexClient, flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContactsRepository) {
+ Log.Info("Starting graceful shutdown...")
+
+ // Fermer les clients
+ if tcpClient != nil {
+ tcpClient.Close()
+ }
+ if flexClient != nil {
+ flexClient.Close()
+ }
+
+ // Fermer les serveurs
+ if tcpServer != nil {
+ // tcpServer.Close() si tu as une méthode close
+ }
+
+ // Fermer les bases de données
+ if flexRepo != nil && flexRepo.db != nil {
+ flexRepo.db.Close()
+ }
+ if contactRepo != nil && contactRepo.db != nil {
+ contactRepo.db.Close()
+ }
+
+ // ✅ Fermer le log en dernier
+ Log.Info("Shutdown complete")
+ CloseLog()
+}
+
func main() {
// Generate our config based on the config supplied
@@ -42,17 +71,18 @@ func main() {
log.Fatal(err)
}
- cfg := NewConfig(cfgPath)
+ NewConfig(cfgPath)
log := NewLog()
+ defer CloseLog()
log.Info("Running FlexDXCluster version 2.1")
- log.Infof("Callsign: %s", cfg.General.Callsign)
+ log.Infof("Callsign: %s", Cfg.General.Callsign)
DeleteDatabase("./flex.sqlite", log)
- log.Debugf("Gotify Push Enabled: %v", cfg.Gotify.Enable)
- if cfg.Gotify.Enable {
- log.Debugf("Gotify Push NewDXCC: %v - NewBand: %v - NewMode: %v - NewBandAndMode: %v", cfg.Gotify.NewDXCC, cfg.Gotify.NewBand, cfg.Gotify.NewMode, cfg.Gotify.NewBandAndMode)
+ log.Debugf("Gotify Push Enabled: %v", Cfg.Gotify.Enable)
+ if Cfg.Gotify.Enable {
+ log.Debugf("Gotify Push NewDXCC: %v - NewBand: %v - NewMode: %v - NewBandAndMode: %v", Cfg.Gotify.NewDXCC, Cfg.Gotify.NewBand, Cfg.Gotify.NewMode, Cfg.Gotify.NewBandAndMode)
}
// Load country.xml to get all the DXCC number
@@ -64,7 +94,7 @@ func main() {
defer fRepo.db.Close()
// Database connection to Log4OM
- cRepo := NewLog4OMContactsRepository(cfg.SQLite.SQLitePath)
+ cRepo := NewLog4OMContactsRepository(Cfg.SQLite.SQLitePath)
defer cRepo.db.Close()
contacts := cRepo.CountEntries()
log.Infof("Log4OM Database Contains %v Contacts", contacts)
@@ -73,12 +103,13 @@ func main() {
SpotChanToHTTPServer := make(chan TelnetSpot, 100)
// Initialize servers and clients
- TCPServer := NewTCPServer(cfg.TelnetServer.Host, cfg.TelnetServer.Port)
+ TCPServer := NewTCPServer(Cfg.TelnetServer.Host, Cfg.TelnetServer.Port)
TCPClient := NewTCPClient(TCPServer, Countries, cRepo, SpotChanToHTTPServer)
FlexClient := NewFlexClient(*fRepo, TCPServer, nil, nil)
// Initialize HTTP Server for Dashboard
HTTPServer := NewHTTPServer(fRepo, cRepo, TCPServer, TCPClient, FlexClient, "8080")
+ InitLogHook()
FlexClient.HTTPServer = HTTPServer
@@ -92,8 +123,8 @@ func main() {
go TCPServer.StartServer()
go HTTPServer.Start()
- log.Infof("Telnet Server: %s:%s", cfg.TelnetServer.Host, cfg.TelnetServer.Port)
- log.Infof("Cluster: %s:%s", cfg.Cluster.Server, cfg.Cluster.Port)
+ log.Infof("Telnet Server: %s:%s", Cfg.TelnetServer.Host, Cfg.TelnetServer.Port)
+ log.Infof("Cluster: %s:%s", Cfg.Cluster.Server, Cfg.Cluster.Port)
CheckSignal(TCPClient, TCPServer, FlexClient, fRepo, cRepo)
diff --git a/spot.go b/spot.go
index f65c2a9..6cfe885 100644
--- a/spot.go
+++ b/spot.go
@@ -32,123 +32,136 @@ func ProcessTelnetSpot(re *regexp.Regexp, spotRaw string, SpotChanToFlex chan Te
match := re.FindStringSubmatch(spotRaw)
- if len(match) != 0 {
- spot := TelnetSpot{
- DX: match[3],
- Spotter: match[1],
- Frequency: match[2],
- Mode: match[4],
- Comment: strings.Trim(match[5], " "),
- Time: match[6],
- }
-
- DXCC := GetDXCC(spot.DX, Countries)
- spot.DXCC = DXCC.DXCC
- spot.CountryName = DXCC.CountryName
-
- if spot.DXCC == "" {
- Log.Errorf("Could not identify the DXCC for %s", spot.DX)
- return
- }
-
- spot.GetBand()
- spot.GuessMode()
- spot.CallsignWorked = false
- spot.NewBand = false
- spot.NewMode = false
- spot.NewDXCC = false
- spot.NewSlot = false
-
- contactsChan := make(chan []Contact)
- contactsModeChan := make(chan []Contact)
- contactsModeBandChan := make(chan []Contact)
- contactsBandChan := make(chan []Contact)
- contactsCallChan := make(chan []Contact)
-
- wg := new(sync.WaitGroup)
- wg.Add(5)
-
- go contactRepo.ListByCountry(spot.DXCC, contactsChan, wg)
- contacts := <-contactsChan
-
- go contactRepo.ListByCountryMode(spot.DXCC, spot.Mode, contactsModeChan, wg)
- contactsMode := <-contactsModeChan
-
- go contactRepo.ListByCountryBand(spot.DXCC, spot.Band, contactsBandChan, wg)
- contactsBand := <-contactsBandChan
-
- go contactRepo.ListByCallSign(spot.DX, spot.Band, spot.Mode, contactsCallChan, wg)
- contactsCall := <-contactsCallChan
-
- go contactRepo.ListByCountryModeBand(spot.DXCC, spot.Band, spot.Mode, contactsModeBandChan, wg)
- contactsModeBand := <-contactsModeBandChan
-
- wg.Wait()
-
- if len(contacts) == 0 {
- spot.NewDXCC = true
- }
-
- if len(contactsMode) == 0 {
- spot.NewMode = true
- }
- if len(contactsBand) == 0 {
- spot.NewBand = true
- }
-
- if len(contactsModeBand) == 0 && !spot.NewDXCC && !spot.NewBand && !spot.NewMode {
- spot.NewSlot = true
- }
-
- if len(contactsCall) > 0 {
- spot.CallsignWorked = true
- }
-
- // Envoyer TOUJOURS le spot vers le processeur principal (base de données + HTTP)
- // Ce canal est maintenant géré par une goroutine dans main.go
- select {
- case SpotChanToHTTPServer <- spot:
- // Spot envoyé avec succès
- default:
- Log.Warn("SpotChanToHTTPServer is full, spot may be lost")
- }
-
- // Logging des spots
- if spot.NewDXCC {
- Log.Debugf("(** New DXCC **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
-
- if !spot.NewDXCC && spot.NewBand && spot.NewMode {
- Log.Debugf("(** New Band/Mode **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
-
- if !spot.NewDXCC && spot.NewBand && !spot.NewMode {
- Log.Debugf("(** New Band **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
-
- if !spot.NewDXCC && !spot.NewBand && spot.NewMode && spot.Mode != "" {
- Log.Debugf("(** New Mode **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
-
- if !spot.NewDXCC && !spot.NewBand && !spot.NewMode && spot.NewSlot && spot.Mode != "" {
- Log.Debugf("(** New Slot **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
-
- if !spot.NewDXCC && !spot.NewBand && !spot.NewMode && spot.CallsignWorked {
- Log.Debugf("(** Worked **) DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
-
- if !spot.NewDXCC && !spot.NewBand && !spot.NewMode && !spot.CallsignWorked {
- Log.Debugf("DX: %s - Spotter: %s - Freq: %s - Band: %s - Mode: %s - Comment: %s - Time: %s - DXCC: %s",
- spot.DX, spot.Spotter, spot.Frequency, spot.Band, spot.Mode, spot.Comment, spot.Time, spot.DXCC)
- }
+ if len(match) == 0 {
+ IncrementSpotsRejected()
+ Log.Warnf("❌ Regex no match: %s", spotRaw)
+ return
}
+
+ spot := TelnetSpot{
+ DX: match[3],
+ Spotter: match[1],
+ Frequency: match[2],
+ Mode: match[4],
+ Comment: strings.Trim(match[5], " "),
+ Time: match[6],
+ }
+
+ DXCC := GetDXCC(spot.DX, Countries)
+ spot.DXCC = DXCC.DXCC
+ spot.CountryName = DXCC.CountryName
+
+ if spot.DXCC == "" {
+ IncrementSpotsRejected()
+ Log.Warnf("❌ DXCC not found: %s", spot.DX)
+ return
+ }
+
+ spot.GetBand()
+ spot.GuessMode(spotRaw)
+ spot.CallsignWorked = false
+ spot.NewBand = false
+ spot.NewMode = false
+ spot.NewDXCC = false
+ spot.NewSlot = false
+
+ contactsChan := make(chan []Contact)
+ contactsModeChan := make(chan []Contact)
+ contactsModeBandChan := make(chan []Contact)
+ contactsBandChan := make(chan []Contact)
+ contactsCallChan := make(chan []Contact)
+
+ wg := new(sync.WaitGroup)
+ wg.Add(5)
+
+ go contactRepo.ListByCountry(spot.DXCC, contactsChan, wg)
+ contacts := <-contactsChan
+
+ go contactRepo.ListByCountryMode(spot.DXCC, spot.Mode, contactsModeChan, wg)
+ contactsMode := <-contactsModeChan
+
+ go contactRepo.ListByCountryBand(spot.DXCC, spot.Band, contactsBandChan, wg)
+ contactsBand := <-contactsBandChan
+
+ go contactRepo.ListByCallSign(spot.DX, spot.Band, spot.Mode, contactsCallChan, wg)
+ contactsCall := <-contactsCallChan
+
+ go contactRepo.ListByCountryModeBand(spot.DXCC, spot.Band, spot.Mode, contactsModeBandChan, wg)
+ contactsModeBand := <-contactsModeBandChan
+
+ wg.Wait()
+
+ // ✅ Déterminer le statut
+ if len(contacts) == 0 {
+ spot.NewDXCC = true
+ }
+ if len(contactsMode) == 0 {
+ spot.NewMode = true
+ }
+ if len(contactsBand) == 0 {
+ spot.NewBand = true
+ }
+ if len(contactsModeBand) == 0 && !spot.NewDXCC && !spot.NewBand && !spot.NewMode {
+ spot.NewSlot = true
+ }
+ if len(contactsCall) > 0 {
+ spot.CallsignWorked = true
+ }
+
+ // ✅ Envoyer le spot
+ select {
+ case SpotChanToHTTPServer <- spot:
+ IncrementSpotsProcessed()
+ default:
+ IncrementSpotsRejected()
+ Log.Errorf("❌ Spot dropped (channel full): %s @ %s", spot.DX, spot.Frequency)
+ return
+ }
+
+ // ✅ LOGS CONCIS ET ADAPTES
+ statusIcon := ""
+ statusText := ""
+
+ if spot.NewDXCC {
+ statusIcon = "🆕"
+ statusText = "NEW DXCC"
+ } else if spot.NewBand && spot.NewMode {
+ statusIcon = "📻"
+ statusText = "NEW BAND+MODE"
+ } else if spot.NewBand {
+ statusIcon = "📡"
+ statusText = "NEW BAND"
+ } else if spot.NewMode {
+ statusIcon = "🔧"
+ statusText = "NEW MODE"
+ } else if spot.NewSlot {
+ statusIcon = "✨"
+ statusText = "NEW SLOT"
+ } else if spot.CallsignWorked {
+ statusIcon = "✓"
+ statusText = "WORKED"
+ } else {
+ statusIcon = "·"
+ statusText = "SPOT"
+ }
+
+ // ✅ Log unique et concis
+ Log.Debugf("%s [%s] %s on %.1f kHz (%s %s) - %s @ %s",
+ statusIcon,
+ statusText,
+ spot.DX,
+ mustParseFloat(spot.Frequency),
+ spot.Band,
+ spot.Mode,
+ spot.CountryName,
+ spot.Time,
+ )
+}
+
+// ✅ Helper pour convertir la fréquence
+func mustParseFloat(s string) float64 {
+ f, _ := strconv.ParseFloat(s, 64)
+ return f
}
func (spot *TelnetSpot) GetBand() {
@@ -216,189 +229,231 @@ func (spot *TelnetSpot) GetBand() {
}
}
-func (spot *TelnetSpot) GuessMode() {
+func (spot *TelnetSpot) GuessMode(rawSpot string) {
+ // ✅ D'ABORD : Chercher le mode dans le commentaire
+ if spot.Mode == "" {
+ spot.Mode = extractModeFromComment(spot.Comment)
+ if spot.Mode != "" {
+ Log.Debugf("Mode extracted from comment: %s", spot.Mode)
+ }
+ }
+
+ // ✅ Normaliser SSB avant de deviner
+ if spot.Mode == "SSB" {
+ if spot.Band == "10M" || spot.Band == "12M" || spot.Band == "6M" || spot.Band == "15M" || spot.Band == "17M" || spot.Band == "20M" {
+ spot.Mode = "USB"
+ } else {
+ spot.Mode = "LSB"
+ }
+ Log.Debugf("Converted SSB to %s for band %s", spot.Mode, spot.Band)
+ return
+ }
+
+ // ✅ Si pas de mode, deviner depuis la fréquence
if spot.Mode == "" {
freqInt, err := strconv.ParseFloat(spot.Frequency, 32)
+ Log.Debugf("No mode specified in spot, will guess from frequency %v with spot: %s", freqInt, strings.TrimSpace(rawSpot))
if err != nil {
- Log.Errorf("could not convert frequency string in float64:", err)
+ Log.Errorf("could not convert frequency string to float: %v", err)
+ return
}
switch spot.Band {
- case "160M":
- if freqInt >= 1800 && freqInt <= 1840 {
+ case "160M": // 1.800 - 2.000 MHz
+ if freqInt < 1838 {
spot.Mode = "CW"
- }
- if freqInt >= 1840 && freqInt <= 1844 {
+ } else if freqInt < 1843 {
spot.Mode = "FT8"
- }
- case "80M":
- if freqInt >= 3500 && freqInt < 3568 {
- spot.Mode = "CW"
- }
- if freqInt >= 3568 && freqInt < 3573 {
- spot.Mode = "FT4"
- }
- if freqInt >= 3573 && freqInt < 3580 {
- spot.Mode = "FT8"
- }
- if freqInt >= 3580 && freqInt < 3600 {
- spot.Mode = "CW"
- }
- if freqInt >= 3600 && freqInt <= 3800 {
- spot.Mode = "LSB"
- }
- case "60M":
- if freqInt >= 5351.5 && freqInt < 5354 {
- spot.Mode = "CW"
- }
- if freqInt >= 5354 && freqInt < 5366 {
- spot.Mode = "LSB"
- }
- if freqInt >= 5366 && freqInt <= 5266.5 {
- spot.Mode = "FT8"
- }
- case "40M":
- if freqInt >= 7000 && freqInt < 7045.5 {
- spot.Mode = "CW"
- }
- if freqInt >= 7045.5 && freqInt < 7048.5 {
- spot.Mode = "FT4"
- }
- if freqInt >= 7048.5 && freqInt < 7074 {
- spot.Mode = "CW"
- }
- if freqInt >= 7074 && freqInt < 7078 {
- spot.Mode = "FT8"
- }
- if freqInt >= 7078 && freqInt <= 7300 {
- spot.Mode = "LSB"
- }
- case "30M":
- if freqInt >= 10100 && freqInt < 10130 {
- spot.Mode = "CW"
- }
- if freqInt >= 10130 && freqInt < 10140 {
- spot.Mode = "FT8"
- }
- if freqInt >= 10140 && freqInt <= 10150 {
- spot.Mode = "FT4"
- }
- case "20M":
- if freqInt >= 14000 && freqInt < 14074 {
- spot.Mode = "CW"
- }
- if freqInt >= 14074 && freqInt < 14078 {
- spot.Mode = "FT8"
- }
- if freqInt >= 14078 && freqInt < 14083 {
- spot.Mode = "FT4"
- }
- if freqInt >= 14083 && freqInt < 14119 {
- spot.Mode = "FT8"
- }
- if freqInt >= 14119 && freqInt < 14350 {
- spot.Mode = "USB"
- }
-
- case "17M":
- if freqInt >= 18068 && freqInt < 18090 {
- spot.Mode = "CW"
- }
- if freqInt >= 18090 && freqInt < 18104 {
- spot.Mode = "FT8"
- }
- if freqInt >= 18104 && freqInt < 18108 {
- spot.Mode = "FT4"
- }
- if freqInt >= 18108 && freqInt <= 18168 {
- spot.Mode = "USB"
- }
-
- case "15M":
- if freqInt >= 21000 && freqInt < 21074 {
- spot.Mode = "CW"
- }
- if freqInt >= 21074 && freqInt < 21100 {
- spot.Mode = "FT8"
- }
- if freqInt >= 21100 && freqInt < 21140 {
- spot.Mode = "RTTY"
- }
- if freqInt >= 21140 && freqInt < 21144 {
- spot.Mode = "FT4"
- }
- if freqInt >= 21144 && freqInt <= 21450 {
- spot.Mode = "USB"
- }
-
- case "12M":
- if freqInt >= 24890 && freqInt < 24910 {
- spot.Mode = "CW"
- }
- if freqInt >= 24910 && freqInt < 24919 {
- spot.Mode = "FT8"
- }
- if freqInt >= 24919 && freqInt < 24922 {
- spot.Mode = "FT4"
- }
- if freqInt >= 24922 && freqInt < 24930 {
- spot.Mode = "RTTY"
- }
- if freqInt >= 24930 && freqInt <= 24990 {
- spot.Mode = "USB"
- }
-
- case "10M":
- if freqInt >= 28000 && freqInt < 28070 {
- spot.Mode = "CW"
- }
- if freqInt >= 28070 && freqInt < 28080 {
- spot.Mode = "FT8"
- }
- if freqInt >= 28080 && freqInt < 28100 {
- spot.Mode = "RTTY"
- }
- if freqInt >= 28100 && freqInt < 28180 {
- spot.Mode = "CW"
- }
- if freqInt >= 28180 && freqInt < 28190 {
- spot.Mode = "FT4"
- }
- if freqInt >= 28190 && freqInt < 29000 {
- spot.Mode = "USB"
- }
- if freqInt >= 29000 && freqInt <= 29700 {
- spot.Mode = "FM"
- }
- case "6M":
- if freqInt >= 50000 && freqInt < 50100 {
- spot.Mode = "CW"
- }
- if freqInt >= 50100 && freqInt < 50313 {
- spot.Mode = "USB"
- }
- if freqInt >= 50313 && freqInt < 50320 {
- spot.Mode = "FT8"
- }
- if freqInt >= 50320 && freqInt < 50400 {
- spot.Mode = "USB"
- }
- if freqInt >= 50400 && freqInt < +52000 {
- spot.Mode = "FM"
- }
- }
- } else {
- spot.Mode = strings.ToUpper(spot.Mode)
- if spot.Mode == "SSB" {
- if spot.Band == "10M" || spot.Band == "12M" || spot.Band == "6M" || spot.Band == "15M" || spot.Band == "17M" || spot.Band == "20M" {
- spot.Mode = "USB"
} else {
spot.Mode = "LSB"
}
+
+ case "80M": // 3.500 - 4.000 MHz
+ if freqInt < 3570 {
+ spot.Mode = "CW"
+ } else if freqInt < 3575 {
+ spot.Mode = "FT4"
+ } else if freqInt < 3578 {
+ spot.Mode = "FT8"
+ } else if freqInt < 3590 {
+ spot.Mode = "RTTY"
+ } else {
+ spot.Mode = "LSB"
+ }
+
+ case "60M": // 5.330 - 5.405 MHz
+ if freqInt < 5357 {
+ spot.Mode = "CW"
+ } else if freqInt < 5359 {
+ spot.Mode = "FT8"
+ } else {
+ spot.Mode = "USB"
+ }
+
+ case "40M": // 7.000 - 7.300 MHz
+ if freqInt < 7040 {
+ spot.Mode = "CW"
+ } else if freqInt < 7047 {
+ spot.Mode = "RTTY"
+ } else if freqInt < 7050 {
+ spot.Mode = "FT4"
+ } else if freqInt < 7080 {
+ spot.Mode = "FT8" // ✅ 7.056 = FT8
+ } else if freqInt < 7125 {
+ spot.Mode = "RTTY" // ✅ 7.112 = RTTY
+ } else {
+ spot.Mode = "LSB"
+ }
+
+ case "30M": // 10.100 - 10.150 MHz (CW/Digital seulement)
+ if freqInt < 10130 {
+ spot.Mode = "CW"
+ } else if freqInt < 10142 {
+ spot.Mode = "FT8"
+ } else {
+ spot.Mode = "FT4"
+ }
+
+ case "20M": // 14.000 - 14.350 MHz
+ if freqInt < 14070 {
+ spot.Mode = "CW"
+ } else if freqInt < 14078 {
+ spot.Mode = "FT8"
+ } else if freqInt < 14083 {
+ spot.Mode = "FT4"
+ } else if freqInt < 14112 {
+ spot.Mode = "RTTY"
+ } else {
+ spot.Mode = "USB"
+ }
+
+ case "17M": // 18.068 - 18.168 MHz
+ if freqInt < 18090 {
+ spot.Mode = "CW"
+ } else if freqInt < 18104 {
+ spot.Mode = "FT8"
+ } else if freqInt < 18106 {
+ spot.Mode = "FT4"
+ } else if freqInt < 18110 {
+ spot.Mode = "RTTY"
+ } else {
+ spot.Mode = "USB"
+ }
+
+ case "15M": // 21.000 - 21.450 MHz
+ if freqInt < 21070 {
+ spot.Mode = "CW"
+ } else if freqInt < 21078 {
+ spot.Mode = "FT8"
+ } else if freqInt < 21120 {
+ spot.Mode = "RTTY"
+ } else if freqInt < 21143 {
+ spot.Mode = "FT4"
+ } else {
+ spot.Mode = "USB"
+ }
+
+ case "12M": // 24.890 - 24.990 MHz
+ if freqInt < 24910 {
+ spot.Mode = "CW" // ✅ 24.896 = CW
+ } else if freqInt < 24918 {
+ spot.Mode = "FT8"
+ } else if freqInt < 24922 {
+ spot.Mode = "FT4"
+ } else if freqInt < 24930 {
+ spot.Mode = "RTTY"
+ } else {
+ spot.Mode = "USB"
+ }
+
+ case "10M": // 28.000 - 29.700 MHz
+ if freqInt < 28070 {
+ spot.Mode = "CW"
+ } else if freqInt < 28095 {
+ spot.Mode = "FT8"
+ } else if freqInt < 28179 {
+ spot.Mode = "RTTY"
+ } else if freqInt < 28190 {
+ spot.Mode = "FT4"
+ } else if freqInt < 29000 {
+ spot.Mode = "USB"
+ } else {
+ spot.Mode = "FM"
+ }
+
+ case "6M": // 50.000 - 54.000 MHz
+ if freqInt < 50100 {
+ spot.Mode = "CW"
+ } else if freqInt < 50313 {
+ spot.Mode = "USB" // ✅ DX Window + général
+ } else if freqInt < 50318 {
+ spot.Mode = "FT8" // ✅ 50.313-50.318
+ } else if freqInt < 50323 {
+ spot.Mode = "FT4" // ✅ 50.318-50.323
+ } else if freqInt < 51000 {
+ spot.Mode = "USB" // ✅ Retour à USB
+ } else {
+ spot.Mode = "FM"
+ }
+
+ default:
+ // ✅ Bande inconnue
+ if freqInt < 10.0 {
+ spot.Mode = "LSB"
+ } else {
+ spot.Mode = "USB"
+ }
+ }
+
+ if spot.Mode != "" {
+ Log.Debugf("✅ Guessed mode %s for %s on %s MHz (band %s)", spot.Mode, spot.DX, spot.Frequency, spot.Band)
+ } else {
+ Log.Warnf("❌ Could not guess mode for %s on %s MHz (band %s), raw spot: %s", spot.DX, spot.Frequency, spot.Band, rawSpot)
+ }
+ } else {
+ spot.Mode = strings.ToUpper(spot.Mode)
+ }
+}
+
+// ✅ Extraire le mode depuis le commentaire
+func extractModeFromComment(comment string) string {
+ commentUpper := strings.ToUpper(comment)
+
+ // ✅ 1. Détecter FT8/FT4 avec leurs patterns typiques (dB + Hz)
+ if strings.Contains(commentUpper, "FT8") ||
+ (strings.Contains(commentUpper, "DB") && strings.Contains(commentUpper, "HZ")) {
+ return "FT8"
+ }
+
+ if strings.Contains(commentUpper, "FT4") {
+ return "FT4"
+ }
+
+ // ✅ 2. Détecter CW avec WPM (Words Per Minute)
+ if strings.Contains(commentUpper, "WPM") || strings.Contains(commentUpper, " CW ") ||
+ strings.HasSuffix(commentUpper, "CW") || strings.HasPrefix(commentUpper, "CW ") {
+ return "CW"
+ }
+
+ // ✅ 3. Autres modes digitaux
+ digitalModes := []string{"RTTY", "PSK31", "PSK63", "PSK", "MFSK", "OLIVIA", "CONTESTIA", "JT65", "JT9"}
+ for _, mode := range digitalModes {
+ if strings.Contains(commentUpper, mode) {
+ return mode
}
}
- if spot.Mode == "" {
- Log.Errorf("Could not identify mode for %s on %s", spot.DX, spot.Frequency)
+ // ✅ 4. Modes voice
+ voiceModes := []string{"USB", "LSB", "SSB", "FM", "AM"}
+ for _, mode := range voiceModes {
+ // Chercher le mode comme mot complet (pas dans "SSBC" par exemple)
+ if strings.Contains(commentUpper, " "+mode+" ") ||
+ strings.HasPrefix(commentUpper, mode+" ") ||
+ strings.HasSuffix(commentUpper, " "+mode) ||
+ commentUpper == mode {
+ return mode
+ }
}
+
+ return ""
}
diff --git a/spotprocessor.go b/spotprocessor.go
index 7dc2433..4874943 100644
--- a/spotprocessor.go
+++ b/spotprocessor.go
@@ -78,6 +78,7 @@ func (sp *SpotProcessor) processSpot(spot TelnetSpot) {
DXCC: spot.DXCC,
}
+ flexSpot.OriginalComment = spot.Comment
flexSpot.Comment = flexSpot.Comment + " [" + flexSpot.Mode + "] [" + flexSpot.SpotterCallsign + "] [" + flexSpot.UTCTime + "]"
if sp.HTTPServer != nil && sp.HTTPServer.Watchlist != nil {
diff --git a/stats.go b/stats.go
new file mode 100644
index 0000000..4688c9e
--- /dev/null
+++ b/stats.go
@@ -0,0 +1,45 @@
+package main
+
+import "sync"
+
+var (
+ spotsReceived int64
+ spotsProcessed int64
+ spotsRejected int64
+ spotsMutex sync.RWMutex
+)
+
+func IncrementSpotsReceived() {
+ spotsMutex.Lock()
+ spotsReceived++
+ spotsMutex.Unlock()
+}
+
+func IncrementSpotsProcessed() {
+ spotsMutex.Lock()
+ spotsProcessed++
+ spotsMutex.Unlock()
+}
+
+func IncrementSpotsRejected() {
+ spotsMutex.Lock()
+ spotsRejected++
+ spotsMutex.Unlock()
+}
+
+func GetSpotStats() (int64, int64, int64) {
+ spotsMutex.RLock()
+ defer spotsMutex.RUnlock()
+ return spotsReceived, spotsProcessed, spotsRejected
+}
+
+func GetSpotSuccessRate() float64 {
+ spotsMutex.RLock()
+ defer spotsMutex.RUnlock()
+
+ if spotsReceived == 0 {
+ return 0.0
+ }
+
+ return float64(spotsProcessed) / float64(spotsReceived) * 100.0
+}
diff --git a/utils.go b/utils.go
index 915748d..ef0cc10 100644
--- a/utils.go
+++ b/utils.go
@@ -32,31 +32,14 @@ func FreqHztoMhz(freq string) string {
return strconv.FormatFloat(frequency, 'f', 6, 64)
}
-func CheckSignal(TCPClient *TCPClient, TCPServer *TCPServer, FlexClient *FlexClient, fRepo *FlexDXClusterRepository, cRepo *Log4OMContactsRepository) {
+func CheckSignal(tcpClient *TCPClient, tcpServer *TCPServer, flexClient *FlexClient, flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContactsRepository) {
+ sigchan := make(chan os.Signal, 1)
+ signal.Notify(sigchan, os.Interrupt, syscall.SIGTERM)
- sigCh := make(chan os.Signal, 1)
- signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
+ <-sigchan
- // Gracely closing all connextions if signal is received
- for sig := range sigCh {
- Log.Infof("received signal: %v, shutting down all connections.", sig)
-
- TCPClient.Close()
- TCPServer.Conn.Close()
- FlexClient.Conn.Close()
-
- if err := fRepo.db.Close(); err != nil {
- Log.Error("failed to close the database connection properly")
- os.Exit(1)
- }
-
- if err := cRepo.db.Close(); err != nil {
- Log.Error("failed to close Log4OM database connection properly")
- os.Exit(1)
- }
-
- os.Exit(0)
- }
+ GracefulShutdown(tcpClient, tcpServer, flexClient, flexRepo, contactRepo)
+ os.Exit(0)
}
func SendUDPMessage(data []byte) {
diff --git a/watchlist.json b/watchlist.json
index fb5145a..bc22def 100644
--- a/watchlist.json
+++ b/watchlist.json
@@ -1,10 +1,37 @@
[
{
- "callsign": "H44MS",
+ "callsign": "3B8M",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:16:49.1572859+02:00",
+ "addedAt": "2025-10-18T17:18:32.6851135+02:00",
+ "spotCount": 0,
+ "playSound": true
+ },
+ {
+ "callsign": "E6AD",
+ "notes": "",
+ "lastSeen": "2025-10-19T10:11:57.4553495+02:00",
+ "lastSeenStr": "Just now",
+ "addedAt": "2025-10-18T17:17:40.8765179+02:00",
+ "spotCount": 198,
+ "playSound": true
+ },
+ {
+ "callsign": "TZ4AM",
+ "notes": "",
+ "lastSeen": "2025-10-18T23:16:45.8032011+02:00",
+ "lastSeenStr": "10 hours ago",
+ "addedAt": "2025-10-18T17:19:00.3154177+02:00",
+ "spotCount": 22,
+ "playSound": true
+ },
+ {
+ "callsign": "5J0EA",
+ "notes": "",
+ "lastSeen": "0001-01-01T00:00:00Z",
+ "lastSeenStr": "Never",
+ "addedAt": "2025-10-18T17:17:51.0758741+02:00",
"spotCount": 0,
"playSound": true
},
@@ -18,47 +45,47 @@
"playSound": true
},
{
- "callsign": "C5LT",
+ "callsign": "C21TS",
+ "notes": "",
+ "lastSeen": "2025-10-18T18:50:28.7708075+02:00",
+ "lastSeenStr": "15 hours ago",
+ "addedAt": "2025-10-18T17:18:21.7895474+02:00",
+ "spotCount": 9,
+ "playSound": true
+ },
+ {
+ "callsign": "E51MWA",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:07.2442738+02:00",
+ "addedAt": "2025-10-18T17:17:43.6895454+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "PJ6Y",
- "notes": "",
- "lastSeen": "2025-10-18T18:44:30.0286129+02:00",
- "lastSeenStr": "Just now",
- "addedAt": "2025-10-18T17:17:47.7237081+02:00",
- "spotCount": 20,
- "playSound": true
- },
- {
- "callsign": "PY0FB",
+ "callsign": "ZL7IO",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:24.3843986+02:00",
+ "addedAt": "2025-10-18T17:17:30.7153757+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "5H3MB",
+ "callsign": "XV9",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:42.8402097+02:00",
+ "addedAt": "2025-10-18T17:18:24.9155327+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "YJ0CA",
+ "callsign": "9L8MD",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:33.3921665+02:00",
+ "addedAt": "2025-10-18T17:18:56.7896868+02:00",
"spotCount": 0,
"playSound": true
},
@@ -66,35 +93,17 @@
"callsign": "TJ1GD",
"notes": "",
"lastSeen": "2025-10-18T18:45:59.6232796+02:00",
- "lastSeenStr": "Just now",
+ "lastSeenStr": "15 hours ago",
"addedAt": "2025-10-18T17:18:27.6004027+02:00",
"spotCount": 10,
"playSound": true
},
{
- "callsign": "C8K",
+ "callsign": "V85NPV",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:39.8627992+02:00",
- "spotCount": 0,
- "playSound": true
- },
- {
- "callsign": "VP2M",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:57.308717+02:00",
- "spotCount": 0,
- "playSound": true
- },
- {
- "callsign": "XT2AW",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:27.3839089+02:00",
+ "addedAt": "2025-10-18T17:18:15.8781583+02:00",
"spotCount": 0,
"playSound": true
},
@@ -107,33 +116,105 @@
"spotCount": 0,
"playSound": true
},
+ {
+ "callsign": "YI1MB",
+ "notes": "",
+ "lastSeen": "0001-01-01T00:00:00Z",
+ "lastSeenStr": "Never",
+ "addedAt": "2025-10-18T17:18:18.825584+02:00",
+ "spotCount": 0,
+ "playSound": true
+ },
+ {
+ "callsign": "YJ0CA",
+ "notes": "",
+ "lastSeen": "0001-01-01T00:00:00Z",
+ "lastSeenStr": "Never",
+ "addedAt": "2025-10-18T17:17:33.3921665+02:00",
+ "spotCount": 0,
+ "playSound": true
+ },
{
"callsign": "FW5K",
"notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
+ "lastSeen": "2025-10-19T10:11:59.0591627+02:00",
+ "lastSeenStr": "Just now",
"addedAt": "2025-10-18T17:17:37.9061157+02:00",
+ "spotCount": 45,
+ "playSound": true
+ },
+ {
+ "callsign": "VP2M",
+ "notes": "",
+ "lastSeen": "0001-01-01T00:00:00Z",
+ "lastSeenStr": "Never",
+ "addedAt": "2025-10-18T17:17:57.308717+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "E51MWA",
+ "callsign": "H44MS",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:43.6895454+02:00",
+ "addedAt": "2025-10-18T17:16:49.1572859+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "3B8M",
+ "callsign": "VP8LP",
+ "notes": "",
+ "lastSeen": "2025-10-19T02:44:48.7228962+02:00",
+ "lastSeenStr": "7 hours ago",
+ "addedAt": "2025-10-18T17:18:49.0576187+02:00",
+ "spotCount": 4,
+ "playSound": true
+ },
+ {
+ "callsign": "C5LT",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:32.6851135+02:00",
+ "addedAt": "2025-10-18T17:18:07.2442738+02:00",
"spotCount": 0,
"playSound": true
},
+ {
+ "callsign": "XT2AW",
+ "notes": "",
+ "lastSeen": "2025-10-19T10:00:12.7622002+02:00",
+ "lastSeenStr": "10 minutes ago",
+ "addedAt": "2025-10-18T17:17:27.3839089+02:00",
+ "spotCount": 3,
+ "playSound": true
+ },
+ {
+ "callsign": "C8K",
+ "notes": "",
+ "lastSeen": "0001-01-01T00:00:00Z",
+ "lastSeenStr": "Never",
+ "addedAt": "2025-10-18T17:18:39.8627992+02:00",
+ "spotCount": 0,
+ "playSound": true
+ },
+ {
+ "callsign": "EL2BG",
+ "notes": "",
+ "lastSeen": "2025-10-19T10:03:29.0397937+02:00",
+ "lastSeenStr": "7 minutes ago",
+ "addedAt": "2025-10-18T17:18:10.2000017+02:00",
+ "spotCount": 8,
+ "playSound": true
+ },
+ {
+ "callsign": "PJ6Y",
+ "notes": "",
+ "lastSeen": "2025-10-19T09:46:21.9937557+02:00",
+ "lastSeenStr": "24 minutes ago",
+ "addedAt": "2025-10-18T17:17:47.7237081+02:00",
+ "spotCount": 219,
+ "playSound": true
+ },
{
"callsign": "Z66IPA",
"notes": "",
@@ -143,85 +224,13 @@
"spotCount": 0,
"playSound": true
},
- {
- "callsign": "C21TS",
- "notes": "",
- "lastSeen": "2025-10-18T18:50:28.7708075+02:00",
- "lastSeenStr": "Just now",
- "addedAt": "2025-10-18T17:18:21.7895474+02:00",
- "spotCount": 9,
- "playSound": true
- },
- {
- "callsign": "ZL7IO",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:30.7153757+02:00",
- "spotCount": 0,
- "playSound": true
- },
- {
- "callsign": "V85NPV",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:15.8781583+02:00",
- "spotCount": 0,
- "playSound": true
- },
- {
- "callsign": "YI1MB",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:18.825584+02:00",
- "spotCount": 0,
- "playSound": true
- },
- {
- "callsign": "C5R",
- "notes": "",
- "lastSeen": "2025-10-18T18:05:16.1881069+02:00",
- "lastSeenStr": "Just now",
- "addedAt": "2025-10-18T17:18:04.5006892+02:00",
- "spotCount": 5,
- "playSound": true
- },
- {
- "callsign": "9L8MD",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:56.7896868+02:00",
- "spotCount": 0,
- "playSound": true
- },
{
"callsign": "5K0UA",
"notes": "",
- "lastSeen": "2025-10-18T18:48:03.346559+02:00",
- "lastSeenStr": "Just now",
+ "lastSeen": "2025-10-19T10:05:21.8975474+02:00",
+ "lastSeenStr": "5 minutes ago",
"addedAt": "2025-10-18T17:17:53.7390559+02:00",
- "spotCount": 10,
- "playSound": true
- },
- {
- "callsign": "TZ4AM",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:19:00.3154177+02:00",
- "spotCount": 0,
- "playSound": true
- },
- {
- "callsign": "9L9L",
- "notes": "",
- "lastSeen": "0001-01-01T00:00:00Z",
- "lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:53.3401773+02:00",
- "spotCount": 0,
+ "spotCount": 45,
"playSound": true
},
{
@@ -234,21 +243,21 @@
"playSound": true
},
{
- "callsign": "VP8LP",
+ "callsign": "PY0FB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:49.0576187+02:00",
+ "addedAt": "2025-10-18T17:17:24.3843986+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "EL2BG",
+ "callsign": "C5R",
"notes": "",
- "lastSeen": "2025-10-18T18:32:28.3424341+02:00",
+ "lastSeen": "2025-10-19T10:11:33.5959073+02:00",
"lastSeenStr": "Just now",
- "addedAt": "2025-10-18T17:18:10.2000017+02:00",
- "spotCount": 2,
+ "addedAt": "2025-10-18T17:18:04.5006892+02:00",
+ "spotCount": 57,
"playSound": true
},
{
@@ -261,29 +270,20 @@
"playSound": true
},
{
- "callsign": "E6AD",
- "notes": "",
- "lastSeen": "2025-10-18T18:43:14.6994075+02:00",
- "lastSeenStr": "Just now",
- "addedAt": "2025-10-18T17:17:40.8765179+02:00",
- "spotCount": 25,
- "playSound": true
- },
- {
- "callsign": "5J0EA",
+ "callsign": "5H3MB",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:17:51.0758741+02:00",
+ "addedAt": "2025-10-18T17:18:42.8402097+02:00",
"spotCount": 0,
"playSound": true
},
{
- "callsign": "XV9",
+ "callsign": "9L9L",
"notes": "",
"lastSeen": "0001-01-01T00:00:00Z",
"lastSeenStr": "Never",
- "addedAt": "2025-10-18T17:18:24.9155327+02:00",
+ "addedAt": "2025-10-18T17:18:53.3401773+02:00",
"spotCount": 0,
"playSound": true
}