first commit

This commit is contained in:
2025-10-11 16:28:39 +05:30
commit 059dcda680
23 changed files with 21984 additions and 0 deletions

317
TCPClient.go Normal file
View File

@@ -0,0 +1,317 @@
package main
import (
"bufio"
"context"
"fmt"
"math"
"net"
"os"
"regexp"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
var spotRe *regexp.Regexp = regexp.MustCompile(`DX\sde\s([\w\d]+).*:\s+(\d+.\d)\s+([\w\d\/]+)\s+(CW|SSB|FT8|FT4|RTTY|USB|LSB)?\s+(.*)\s\s\s+([\d]+\w{1})`)
var defaultLoginRe *regexp.Regexp = regexp.MustCompile("[\\w\\d-_]+ login:")
var defaultPasswordRe *regexp.Regexp = regexp.MustCompile("Password:")
type TCPClient struct {
Login string
Password string
Address string
Port string
LoggedIn bool
Timeout time.Duration
LogWriter *bufio.Writer
Reader *bufio.Reader
Writer *bufio.Writer
Scanner *bufio.Scanner
Mutex sync.Mutex
Conn net.Conn
TCPServer TCPServer
MsgChan chan string
CmdChan chan string
SpotChanToFlex chan TelnetSpot
SpotChanToHTTPServer chan TelnetSpot
Log *log.Logger
Config *Config
Countries Countries
LoginRe *regexp.Regexp
PasswordRe *regexp.Regexp
ContactRepo *Log4OMContactsRepository
ctx context.Context
cancel context.CancelFunc
reconnectAttempts int
maxReconnectAttempts int
baseReconnectDelay time.Duration
maxReconnectDelay time.Duration
}
func NewTCPClient(TCPServer *TCPServer, Countries Countries, contactRepo *Log4OMContactsRepository) *TCPClient {
ctx, cancel := context.WithCancel(context.Background())
return &TCPClient{
Address: Cfg.Cluster.Server,
Port: Cfg.Cluster.Port,
Login: Cfg.Cluster.Login,
Password: Cfg.Cluster.Password,
MsgChan: TCPServer.MsgChan,
CmdChan: TCPServer.CmdChan,
SpotChanToFlex: make(chan TelnetSpot, 100),
TCPServer: *TCPServer,
SpotChanToHTTPServer: make(chan TelnetSpot, 100),
Countries: Countries,
ContactRepo: contactRepo,
ctx: ctx,
cancel: cancel,
maxReconnectAttempts: 10, // Max 10 tentatives avant abandon
baseReconnectDelay: 1 * time.Second, // Délai initial
maxReconnectDelay: 60 * time.Second, // Max 1 minute entre tentatives
}
}
func (c *TCPClient) setDefaultParams() {
if c.Timeout == 0 {
c.Timeout = 600 * time.Second
}
if c.LogWriter == nil {
c.LogWriter = bufio.NewWriter(os.Stdout)
}
c.LoggedIn = false
if c.LoginRe == nil {
c.LoginRe = defaultLoginRe
}
if c.PasswordRe == nil {
c.PasswordRe = defaultPasswordRe
}
}
func (c *TCPClient) calculateBackoff() time.Duration {
// Formule: min(baseDelay * 2^attempts, maxDelay)
delay := time.Duration(float64(c.baseReconnectDelay) * math.Pow(2, float64(c.reconnectAttempts)))
if delay > c.maxReconnectDelay {
delay = c.maxReconnectDelay
}
return delay
}
func (c *TCPClient) connect() error {
addr := c.Address + ":" + c.Port
Log.Debugf("Attempting to connect to %s (attempt %d/%d)", addr, c.reconnectAttempts+1, c.maxReconnectAttempts)
conn, err := net.DialTimeout("tcp", addr, 10*time.Second)
if err != nil {
return fmt.Errorf("failed to connect to %s: %w", addr, err)
}
c.Conn = conn
c.Reader = bufio.NewReader(c.Conn)
c.Writer = bufio.NewWriter(c.Conn)
c.LoggedIn = false
c.reconnectAttempts = 0 // Reset sur connexion réussie
Log.Infof("Successfully connected to %s", addr)
return nil
}
func (c *TCPClient) StartClient() {
c.setDefaultParams()
// Goroutine pour gérer les commandes
go func() {
for {
select {
case <-c.ctx.Done():
return
case message := <-c.TCPServer.CmdChan:
Log.Infof("Received Command: %s", message)
c.Write([]byte(message + "\r\n"))
}
}
}()
for {
select {
case <-c.ctx.Done():
Log.Info("TCP Client shutting down...")
return
default:
}
// Tentative de connexion
err := c.connect()
if err != nil {
c.reconnectAttempts++
if c.reconnectAttempts >= c.maxReconnectAttempts {
Log.Errorf("Max reconnection attempts (%d) reached. Giving up.", c.maxReconnectAttempts)
return
}
backoff := c.calculateBackoff()
Log.Warnf("Connection failed: %v. Retrying in %v...", err, backoff)
// Attente avec possibilité d'annulation
select {
case <-c.ctx.Done():
return
case <-time.After(backoff):
continue
}
}
// Connexion réussie, démarrer la lecture
c.ReadLine()
// Si ReadLine se termine (déconnexion), on va tenter de se reconnecter
Log.Warn("Connection lost. Attempting to reconnect...")
// Petit délai avant reconnexion
time.Sleep(2 * time.Second)
}
}
func (c *TCPClient) Close() {
c.cancel() // Annule le contexte pour arrêter toutes les goroutines
if c.Conn != nil {
c.Writer.Write([]byte("bye\r\n"))
c.Writer.Flush()
c.Conn.Close()
}
}
func (c *TCPClient) SetFilters() {
if Cfg.Cluster.FT8 {
c.Write([]byte("set/ft8\r\n"))
Log.Info("FT8: On")
}
if Cfg.Cluster.Skimmer {
c.Write([]byte("set/skimmer\r\n"))
Log.Info("Skimmer: On")
}
if Cfg.Cluster.FT4 {
c.Write([]byte("set/ft4\r\n"))
Log.Info("FT4: On")
}
if !Cfg.Cluster.FT8 {
c.Write([]byte("set/noft8\r\n"))
Log.Info("FT8: Off")
}
if !Cfg.Cluster.FT4 {
c.Write([]byte("set/noft4\r\n"))
Log.Info("FT4: Off")
}
if !Cfg.Cluster.Skimmer {
c.Write([]byte("set/noskimmer\r\n"))
Log.Info("Skimmer: Off")
}
}
func (c *TCPClient) ReadLine() {
defer func() {
if c.Conn != nil {
c.Conn.Close()
}
}()
for {
select {
case <-c.ctx.Done():
return
default:
}
if !c.LoggedIn {
// Lecture avec timeout
c.Conn.SetReadDeadline(time.Now().Add(30 * time.Second))
message, err := c.Reader.ReadBytes(':')
if err != nil {
Log.Errorf("Error reading login prompt: %s", err)
return
}
c.Conn.SetReadDeadline(time.Time{}) // Reset deadline
if strings.Contains(string(message), Cfg.Cluster.LoginPrompt) || strings.Contains(string(message), "login:") {
time.Sleep(time.Second * 1)
Log.Debug("Found login prompt...sending callsign")
c.Write([]byte(c.Login + "\n\r"))
c.LoggedIn = true
Log.Infof("Connected to DX cluster %s:%s", Cfg.Cluster.Server, Cfg.Cluster.Port)
continue
}
}
if c.LoggedIn {
// Lecture avec timeout pour détecter les connexions mortes
c.Conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
message, err := c.Reader.ReadBytes('\n')
if err != nil {
Log.Errorf("Error reading message: %s", err)
return // ✅ Retour au lieu de récursion - la boucle principale va reconnecter
}
c.Conn.SetReadDeadline(time.Time{}) // Reset deadline
messageString := string(message)
if messageString != "" {
if strings.Contains(messageString, "password") {
Log.Debug("Found password prompt...sending password...")
c.Write([]byte(c.Password + "\r\n"))
}
if strings.Contains(messageString, "Hello") || strings.Contains(messageString, "Welcome") {
go c.SetFilters()
if Cfg.Cluster.Command != "" {
c.Write([]byte(Cfg.Cluster.Command + "\n\r"))
Log.Debugf("Sending Command: %s", Cfg.Cluster.Command)
}
}
if strings.Contains(messageString, "DX") {
ProcessTelnetSpot(spotRe, messageString, c.SpotChanToFlex, c.SpotChanToHTTPServer, c.Countries, c.ContactRepo)
}
// Send the spot message to TCP server
select {
case c.MsgChan <- messageString:
case <-c.ctx.Done():
return
}
}
}
}
}
// Write sends raw data to remove telnet server
func (c *TCPClient) Write(data []byte) (n int, err error) {
c.Mutex.Lock()
defer c.Mutex.Unlock()
if c.Conn == nil || c.Writer == nil {
return 0, fmt.Errorf("not connected")
}
n, err = c.Writer.Write(data)
if err != nil {
Log.Errorf("Error while sending command to telnet client: %s", err)
return n, err
}
err = c.Writer.Flush()
return n, err
}

139
TCPServer.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"bufio"
"fmt"
"net"
"os"
"strings"
"sync"
"time"
log "github.com/sirupsen/logrus"
)
var (
err error
)
type TCPServer struct {
Address string
Port string
Clients map[net.Conn]bool
Mutex *sync.Mutex
LogWriter *bufio.Writer
Reader *bufio.Reader
Writer *bufio.Writer
Conn net.Conn
Listener net.Listener
MsgChan chan string
CmdChan chan string
Log *log.Logger
Config *Config
MessageSent int
}
func NewTCPServer(address string, port string) *TCPServer {
return &TCPServer{
Address: address,
Port: port,
Clients: make(map[net.Conn]bool),
MsgChan: make(chan string, 100),
CmdChan: make(chan string),
Mutex: new(sync.Mutex),
MessageSent: 0,
}
}
func (s *TCPServer) StartServer() {
s.LogWriter = bufio.NewWriter(os.Stdout)
s.Listener, err = net.Listen("tcp", Cfg.TelnetServer.Host+":"+Cfg.TelnetServer.Port)
if err != nil {
Log.Info("Could not create telnet server")
}
defer s.Listener.Close()
Log.Infof("Telnet server listening on %s:%s", Cfg.TelnetServer.Host, Cfg.TelnetServer.Port)
go func() {
for message := range s.MsgChan {
s.broadcastMessage(message)
}
}()
for {
s.Conn, err = s.Listener.Accept()
Log.Info("Client connected: ", s.Conn.RemoteAddr().String())
if err != nil {
Log.Error("Could not accept connections to telnet server")
continue
}
s.Mutex.Lock()
s.Clients[s.Conn] = true
s.Mutex.Unlock()
go s.handleConnection()
}
}
func (s *TCPServer) handleConnection() {
s.Conn.Write([]byte("Welcome to the FlexDXCluster telnet server! Type 'bye' to exit.\n"))
s.Reader = bufio.NewReader(s.Conn)
s.Writer = bufio.NewWriter(s.Conn)
for {
message, err := s.Reader.ReadString('\n')
if err != nil {
s.Mutex.Lock()
delete(s.Clients, s.Conn)
s.Mutex.Unlock()
return
}
message = strings.TrimSpace(message)
// if message is by then disconnect
if message == "bye" {
s.Mutex.Lock()
delete(s.Clients, s.Conn)
s.Mutex.Unlock()
s.Conn.Close()
Log.Infof("client %s disconnected", s.Conn.RemoteAddr().String())
}
if strings.Contains(message, "DX") || strings.Contains(message, "SH/DX") || strings.Contains(message, "set") || strings.Contains(message, "SET") {
// send DX spot to the client
s.CmdChan <- message
}
}
}
func (s *TCPServer) Write(message string) (n int, err error) {
_, err = s.Writer.Write([]byte(message))
if err == nil {
err = s.Writer.Flush()
}
return
}
func (s *TCPServer) broadcastMessage(message string) {
s.Mutex.Lock()
defer s.Mutex.Unlock()
if len(s.Clients) > 0 {
if s.MessageSent == 0 {
time.Sleep(3 * time.Second)
s.MessageSent += 1
}
for client := range s.Clients {
_, err := client.Write([]byte(message + "\r\n"))
s.MessageSent += 1
if err != nil {
fmt.Println("Error while sending message to clients:", client.RemoteAddr())
}
}
}
}

111
config.go Normal file
View File

@@ -0,0 +1,111 @@
package main
import (
"fmt"
"log"
"os"
"gopkg.in/yaml.v2"
)
var Cfg *Config
type Config struct {
General struct {
DeleteLogFileAtStart bool `yaml:"delete_log_file_at_start"`
LogToFile bool `yaml:"log_to_file"`
Callsign string `yaml:"callsign"`
LogLevel string `yaml:"log_level"`
TelnetServer bool `yaml:"telnetserver"`
FlexRadioSpot bool `yaml:"flexradiospot"`
SpotColorNewEntity string `yaml:"spot_color_new_entity"`
BackgroundColorNewEntity string `yaml:"background_color_new_entity"`
SpotColorNewBand string `yaml:"spot_color_new_band"`
BackgroundColorNewBand string `yaml:"background_color_new_band"`
SpotColorNewMode string `yaml:"spot_color_new_mode"`
BackgroundColorNewMode string `yaml:"background_color_new_mode"`
SpotColorNewBandMode string `yaml:"spot_color_new_band_mode"`
BackgroundColorNewBandMode string `yaml:"background_color_new_band_mode"`
SpotColorNewSlot string `yaml:"spot_color_new_slot"`
BackgroundColorNewSlot string `yaml:"background_color_new_slot"`
SpotColorMyCallsign string `yaml:"spot_color_my_callsign"`
BackgroundColorMyCallsign string `yaml:"background_color_my_callsign"`
SpotColorWorked string `yaml:"spot_color_worked"`
BackgroundColorWorked string `yaml:"background_color_worked"`
} `yaml:"general"`
Database struct {
MySQL bool `yaml:"mysql"`
SQLite bool `yaml:"sqlite"`
MySQLUser string `yaml:"mysql_db_user"`
MySQLPassword string `yaml:"mysql_db_password"`
MySQLDbName string `yaml:"mysql_db_name"`
MySQLHost string `yaml:"mysql_host"`
MySQLPort string `yaml:"mysql_port"`
} `yaml:"database"`
SQLite struct {
SQLitePath string `yaml:"sqlite_path"`
} `yaml:"sqlite"`
Cluster struct {
Server string `yaml:"server"`
Port string `yaml:"port"`
Login string `yaml:"login"`
Password string `yaml:"password"`
Skimmer bool `yaml:"skimmer"`
FT8 bool `yaml:"ft8"`
FT4 bool `yaml:"ft4"`
Command string `yanl:"command"`
LoginPrompt string `yaml:"login_prompt"`
} `yaml:"cluster"`
Flex struct {
Discover bool `yaml:"discovery"`
IP string `yaml:"ip"`
SpotLife string `yaml:"spot_life"`
} `yaml:"flex"`
TelnetServer struct {
Host string `yaml:"host"`
Port string `yaml:"port"`
} `yaml:"telnetserver"`
Gotify struct {
Enable bool `yaml:"enable"`
URL string `yaml:"url"`
Token string `yaml:"token"`
NewDXCC bool `yaml:"NewDXCC"`
NewBand bool `yaml:"NewBand"`
NewMode bool `yaml:"NewMode"`
NewBandAndMode bool `yaml:"NewBandAndMode"`
} `yaml:"gotify"`
}
func NewConfig(configPath string) *Config {
Cfg = &Config{}
file, err := os.Open(configPath)
if err != nil {
log.Println("could not open config file")
}
defer file.Close()
d := yaml.NewDecoder(file)
if err := d.Decode(&Cfg); err != nil {
log.Println("could not decod config file")
}
return Cfg
}
func ValidateConfigPath(path string) error {
s, err := os.Stat(path)
if err != nil {
return err
}
if s.IsDir() {
return fmt.Errorf("'%s' is a directory, not a normal file", path)
}
return nil
}

57
config.yml Normal file
View File

@@ -0,0 +1,57 @@
general:
delete_log_file_at_start: true
callsign: F4BPO # Log4OM Callsign used to check if you get spotted by someone
log_to_file: true
log_level: DEBUG # INFO or DEBUG or WARN
telnetserver: true # not in use for now
flexradiospot: true # not in use for now
# Spot colors, if empty then default, colors in HEX AARRGGBB format
spot_color_new_entity:
background_color_new_entity:
spot_color_new_band:
background_color_new_band:
spot_color_new_mode:
background_color_new_mode:
spot_color_new_band_mode:
background_color_new_band_mode:
spot_color_new_slot:
background_color_new_slot:
spot_color_my_callsign:
background_color_my_callsign:
spot_color_worked:
background_color_worked:
database:
mysql: false #only one of the two can be true
sqlite: true
mysql_db_user: rouggy
mysql_db_password: 89DGgg290379
mysql_db_name: log_f4bpo
mysql_host: 10.10.10.15
mysql_port: 3306
sqlite:
sqlite_path: 'C:\Perso\Seafile\Radio\Logs\Log4OM\F4BPO.SQLite' # SQLite Db oath of Log4OM
cluster:
server: cluster.f4bpo.com # dxc.k0xm.net dxc.sm7iun.se
port: 7300
login: f4bpo
password: 89DGgg
skimmer: true
ft8: false
ft4: false
command: "SET/FILTER DOC/PASS 1A,3A,4O,9A,9H,C3,CT,CU,DL,E7,EA,EA6,EI,ER,ES,EU,F,G,GD,GI,GJ,GM,GU,GW,HA,HB,HB0,HV,I,IS,IT9,JW,JX,LA,LX,LY,LZ,OE,OH,OH0,OJ0,OK,OM,ON,OY,OZ,PA,S5,SM,SP,SV,SV5,SV9,T7,TA1,TF,TK,UA,UR,YL,YO,YU,Z6,Z3" #"SET/FILTER DOC/PASS 1A,3A,4O,9A,9H,C3,CT,CU,DL,E7,EA,EA6,EI,ER,ES,EU,F,G,GD,GI,GJ,GM,GU,GW,HA,HB,HB0,HV,I,IS,IT9,JW,JX,LA,LX,LY,LZ,OE,OH,OH0,OJ0,OK,OM,ON,OY,OZ,PA,S5,SM,SP,SV,SV5,SV9,T7,TA1,TF,TK,UA,UR,YL,YO,YU,Z6,Z3,ZA,ZB"
login_prompt: "login:"
flex:
discovery: false # Radio must be on same LAN than the program
ip: 82.67.157.19 # if discovery is true no need to put an IP
spot_life: 600 #seconds
telnetserver: # Log4OM must be connected to this server ie: localhost:7301 if on same machine as this program else ip:7301
host: 0.0.0.0
port: 7301
gotify:
enable: true
url: https://gotify.rouggy.com/message
token: ALaGS4MVMWTEMcP
NewDXCC: true
NewBand: false
NewMode: false
NewBandAndMode: false

16888
country.xml Normal file

File diff suppressed because one or more lines are too long

552
database.go Normal file
View File

@@ -0,0 +1,552 @@
package main
import (
"context"
"database/sql"
"fmt"
"os"
"strconv"
"sync"
"time"
_ "github.com/go-sql-driver/mysql"
log "github.com/sirupsen/logrus"
)
type Contact struct {
Callsign string
Band string
Mode string
DXCC string
StationCallsign string
Country string
}
type Spotter struct {
Spotter string
NumberofSpots string
}
type QSO struct {
Callsign string `json:"callsign"`
Band string `json:"band"`
Mode string `json:"mode"`
Date string `json:"date"`
RSTSent string `json:"rstSent"`
RSTRcvd string `json:"rstRcvd"`
Country string `json:"country"`
DXCC string `json:"dxcc"`
}
type QSOStats struct {
Today int `json:"today"`
ThisWeek int `json:"thisWeek"`
ThisMonth int `json:"thisMonth"`
Total int `json:"total"`
}
type Log4OMContactsRepository struct {
db *sql.DB
Log *log.Logger
}
type FlexDXClusterRepository struct {
db *sql.DB
Log *log.Logger
}
func NewLog4OMContactsRepository(filePath string) *Log4OMContactsRepository {
if Cfg.Database.MySQL {
db, err := sql.Open("mysql", Cfg.Database.MySQLUser+":"+Cfg.Database.MySQLPassword+"@tcp("+Cfg.Database.MySQLHost+":"+Cfg.Database.MySQLPort+")/"+Cfg.Database.MySQLDbName)
if err != nil {
Log.Errorf("Cannot open db", err)
}
return &Log4OMContactsRepository{
db: db,
Log: Log}
} else if Cfg.Database.SQLite {
db, err := sql.Open("sqlite3", filePath)
if err != nil {
Log.Errorf("Cannot open db", err)
}
_, err = db.Exec("PRAGMA journal_mode=WAL")
if err != nil {
panic(err)
}
return &Log4OMContactsRepository{
db: db,
Log: Log}
}
return nil
}
func NewFlexDXDatabase(filePath string) *FlexDXClusterRepository {
db, err := sql.Open("sqlite3", filePath)
if err != nil {
fmt.Println("Cannot open db", err)
}
Log.Debugln("Opening SQLite database")
_, err = db.ExecContext(
context.Background(),
`CREATE TABLE IF NOT EXISTS "spots" (
"id" INTEGER NOT NULL UNIQUE,
"commandNumber" INTEGER NOT NULL UNIQUE,
"flexSpotNumber" INTEGER,
"dx" TEXT NOT NULL,
"freqMhz" TEXT,
"freqHz" TEXT,
"band" TEXT,
"mode" TEXT,
"spotter" TEXT,
"flexMode" TEXT,
"source" TEXT,
"time" TEXT,
"timestamp" INTEGER,
"lifeTime" TEXT,
"priority" TEXT,
"comment" TEXT,
"color" TEXT,
"backgroundColor" TEXT,
"countryName" TEXT,
"dxcc" TEXT,
"newDXCC" INTEGER DEFAULT 0,
"newBand" INTEGER DEFAULT 0,
"newMode" INTEGER DEFAULT 0,
"newSlot" INTEGER DEFAULT 0,
"worked" INTEGER DEFAULT 0,
PRIMARY KEY("id" AUTOINCREMENT)
)`,
)
if err != nil {
log.Warn("Cannot create table", err)
}
return &FlexDXClusterRepository{
db: db,
Log: Log,
}
}
func (r *Log4OMContactsRepository) CountEntries() int {
var contacts int
err := r.db.QueryRow("SELECT COUNT(*) FROM log").Scan(&contacts)
if err != nil {
log.Error("could not query database", err)
}
return contacts
}
func (r *Log4OMContactsRepository) ListByCountry(countryID string, contactsChan chan []Contact, wg *sync.WaitGroup) {
defer wg.Done()
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ?", countryID)
if err != nil {
log.Error("could not query database", err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
log.Error("could not query database", err)
}
contacts = append(contacts, c)
}
contactsChan <- contacts
}
func (r *Log4OMContactsRepository) ListByCountryMode(countryID string, mode string, contactsModeChan chan []Contact, wg *sync.WaitGroup) {
defer wg.Done()
if mode == "USB" || mode == "LSB" || mode == "SSB" {
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND (mode = ? OR mode = ? OR mode = ?)", countryID, "USB", "LSB", "SSB")
if err != nil {
log.Error("could not query database", err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
log.Error("could not query database", err)
}
contacts = append(contacts, c)
}
contactsModeChan <- contacts
} else {
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND mode = ?", countryID, mode)
if err != nil {
log.Error("could not query the database", err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
}
contacts = append(contacts, c)
}
contactsModeChan <- contacts
}
}
func (r *Log4OMContactsRepository) ListByCountryModeBand(countryID string, band string, mode string, contactsModeBandChan chan []Contact, wg *sync.WaitGroup) {
defer wg.Done()
if mode == "USB" || mode == "LSB" {
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND (mode = ? OR mode = ?) AND band = ?", countryID, "USB", "LSB", band)
if err != nil {
log.Error("could not query database", err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
log.Error("could not query database", err)
}
contacts = append(contacts, c)
}
contactsModeBandChan <- contacts
} else {
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND mode = ? AND band = ?", countryID, mode, band)
if err != nil {
log.Error("could not query the database", err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
}
contacts = append(contacts, c)
}
contactsModeBandChan <- contacts
}
}
func (r *Log4OMContactsRepository) ListByCountryBand(countryID string, band string, contactsBandChan chan []Contact, wg *sync.WaitGroup) {
defer wg.Done()
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE dxcc = ? AND band = ?", countryID, band)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
}
contacts = append(contacts, c)
}
contactsBandChan <- contacts
}
func (r *Log4OMContactsRepository) ListByCallSign(callSign string, band string, mode string, contactsCallChan chan []Contact, wg *sync.WaitGroup) {
defer wg.Done()
rows, err := r.db.Query("SELECT callsign, band, mode, dxcc, stationcallsign, country FROM log WHERE callsign = ? AND band = ? AND mode = ?", callSign, band, mode)
if err != nil {
fmt.Println(err)
}
defer rows.Close()
contacts := []Contact{}
for rows.Next() {
c := Contact{}
if err := rows.Scan(&c.Callsign, &c.Band, &c.Mode, &c.DXCC, &c.StationCallsign, &c.Country); err != nil {
fmt.Println(err)
}
contacts = append(contacts, c)
}
contactsCallChan <- contacts
}
func (r *Log4OMContactsRepository) GetRecentQSOs(limit string) []QSO {
query := fmt.Sprintf("SELECT callsign, band, mode, qsodate, rstsent, rstrcvd, country, dxcc FROM log ORDER BY qsodate DESC, qsodate DESC LIMIT %s", limit)
rows, err := r.db.Query(query)
if err != nil {
log.Error("could not query recent QSOs:", err)
return []QSO{}
}
defer rows.Close()
qsos := []QSO{}
for rows.Next() {
q := QSO{}
if err := rows.Scan(&q.Callsign, &q.Band, &q.Mode, &q.Date, &q.RSTSent, &q.RSTRcvd, &q.Country, &q.DXCC); err != nil {
log.Error("could not scan QSO:", err)
continue
}
qsos = append(qsos, q)
}
return qsos
}
func (r *Log4OMContactsRepository) GetQSOStats() QSOStats {
stats := QSOStats{}
// QSOs du jour
err := r.db.QueryRow("SELECT COUNT(*) FROM log WHERE qsodate >= DATE('now')").Scan(&stats.Today)
if err != nil {
log.Error("could not get today's QSOs:", err)
}
// QSOs de la semaine
err = r.db.QueryRow("SELECT COUNT(*) FROM log WHERE qsodate >= DATE('now', '-7 days')").Scan(&stats.ThisWeek)
if err != nil {
log.Error("could not get week's QSOs:", err)
}
// QSOs du mois
err = r.db.QueryRow("SELECT COUNT(*) FROM log WHERE qsodate >= DATE('now', 'start of month')").Scan(&stats.ThisMonth)
if err != nil {
log.Error("could not get month's QSOs:", err)
}
// Total QSOs
stats.Total = r.CountEntries()
return stats
}
func (r *Log4OMContactsRepository) GetDXCCCount() int {
var count int
err := r.db.QueryRow("SELECT COUNT(DISTINCT dxcc) FROM log WHERE dxcc != '' AND dxcc IS NOT NULL").Scan(&count)
if err != nil {
log.Error("could not get DXCC count:", err)
return 0
}
return count
}
//
// Flex from now on
//
func (r *FlexDXClusterRepository) GetAllSpots(limit string) []FlexSpot {
Spots := []FlexSpot{}
var query string
if limit == "0" {
query = "SELECT * from spots ORDER BY id DESC"
} else {
query = fmt.Sprintf("SELECT * from spots ORDER BY id DESC LIMIT %s", limit)
}
rows, err := r.db.Query(query)
if err != nil {
r.Log.Error(err)
return nil
}
defer rows.Close()
s := FlexSpot{}
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
return nil
}
Spots = append(Spots, s)
}
return Spots
}
func (r *FlexDXClusterRepository) GetSpotters() []Spotter {
sList := []Spotter{}
rows, err := r.db.Query("select spotter, count(*) as occurences from spots group by spotter order by occurences desc, spotter limit 15")
if err != nil {
r.Log.Error(err)
return nil
}
defer rows.Close()
s := Spotter{}
for rows.Next() {
if err := rows.Scan(&s.Spotter, &s.NumberofSpots); err != nil {
fmt.Println(err)
return nil
}
sList = append(sList, s)
}
return sList
}
func (r *FlexDXClusterRepository) FindDXSameBand(spot FlexSpot) (*FlexSpot, error) {
rows, err := r.db.Query("SELECT * from spots WHERE dx = ? AND band = ?", spot.DX, spot.Band)
if err != nil {
r.Log.Error(err)
return nil, err
}
defer rows.Close()
s := FlexSpot{}
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
return nil, err
}
}
return &s, nil
}
func (r *FlexDXClusterRepository) CreateSpot(spot FlexSpot) {
query := "INSERT INTO `spots` (`commandNumber`, `flexSpotNumber`, `dx`, `freqMhz`, `freqHz`, `band`, `mode`, `spotter`, `flexMode`, `source`, `time`, `timestamp`, `lifeTime`, `priority`, `comment`, `color`, `backgroundColor`, `countryName`, `dxcc`, `newDXCC`, `newBand`, `newMode`, `newSlot`, `worked`) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"
insertResult, err := r.db.ExecContext(context.Background(), query, spot.CommandNumber, spot.CommandNumber, spot.DX, spot.FrequencyMhz, spot.FrequencyHz, spot.Band, spot.Mode, spot.SpotterCallsign, spot.FlexMode, spot.Source, spot.UTCTime, time.Now().Unix(), spot.LifeTime, spot.Priority, spot.Comment, spot.Color, spot.BackgroundColor, spot.CountryName, spot.DXCC, spot.NewDXCC, spot.NewBand, spot.NewMode, spot.NewSlot, spot.Worked)
if err != nil {
Log.Errorf("cannot insert spot in database: %s", err)
}
_, err = insertResult.LastInsertId()
if err != nil {
Log.Errorf("impossible to retrieve last inserted id: %s", err)
}
}
func (r *FlexDXClusterRepository) UpdateSpotSameBand(spot FlexSpot) error {
_, err := r.db.Exec(`UPDATE spots SET commandNumber = ?, DX = ?, freqMhz = ?, freqHz = ?, band = ?, mode = ?, spotter = ?, flexMode = ?, source = ?, time = ?, timestamp = ?, lifeTime = ?, priority = ?, comment = ?, color = ?, backgroundColor = ?, countryName = ?, dxcc = ?, newDXCC = ?, newBand = ?, newMode = ?, newSlot = ?, worked = ? WHERE DX = ? AND band = ?`,
spot.CommandNumber, spot.DX, spot.FrequencyMhz, spot.FrequencyHz, spot.Band, spot.Mode, spot.SpotterCallsign, spot.FlexMode, spot.Source, spot.UTCTime, spot.TimeStamp, spot.LifeTime, spot.Priority, spot.Comment, spot.Color, spot.BackgroundColor, spot.CountryName, spot.DXCC, spot.NewDXCC, spot.NewBand, spot.NewMode, spot.NewSlot, spot.Worked, spot.DX, spot.Band)
if err != nil {
r.Log.Errorf("could not update database: %s", err)
return err
}
return nil
}
func (r *FlexDXClusterRepository) FindSpotByCommandNumber(commandNumber string) (*FlexSpot, error) {
rows, err := r.db.Query("SELECT * from spots WHERE commandNumber = ?", commandNumber)
if err != nil {
fmt.Println(err)
return nil, err
}
defer rows.Close()
s := FlexSpot{}
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
return nil, err
}
}
return &s, nil
}
func (r *FlexDXClusterRepository) FindSpotByFlexSpotNumber(spotNumber string) (*FlexSpot, error) {
rows, err := r.db.Query("SELECT * from spots WHERE flexSpotNumber = ?", spotNumber)
if err != nil {
fmt.Println(err)
return nil, err
}
defer rows.Close()
s := FlexSpot{}
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
return nil, err
}
}
return &s, nil
}
func (r *FlexDXClusterRepository) UpdateFlexSpotNumberByID(flexSpotNumber string, spot FlexSpot) (*FlexSpot, error) {
flexSpotNumberInt, _ := strconv.Atoi(flexSpotNumber)
rows, err := r.db.Query(`UPDATE spots SET flexSpotNumber = ? WHERE id = ? RETURNING *`, flexSpotNumberInt, spot.ID)
if err != nil {
r.Log.Errorf("could not update database: %s", err)
}
defer rows.Close()
s := FlexSpot{}
for rows.Next() {
if err := rows.Scan(&s.ID, &s.CommandNumber, &s.FlexSpotNumber, &s.DX, &s.FrequencyMhz, &s.FrequencyHz, &s.Band, &s.Mode, &s.SpotterCallsign, &s.FlexMode, &s.Source, &s.UTCTime, &s.TimeStamp, &s.LifeTime, &s.Priority,
&s.Comment, &s.Color, &s.BackgroundColor, &s.CountryName, &s.DXCC, &s.NewDXCC, &s.NewBand, &s.NewMode, &s.NewSlot, &s.Worked); err != nil {
fmt.Println(err)
return nil, err
}
}
return &s, nil
}
func (r *FlexDXClusterRepository) DeleteSpotByFlexSpotNumber(flexSpotNumber string) {
flexSpotNumberInt, _ := strconv.Atoi(flexSpotNumber)
query := "DELETE from spots WHERE flexSpotNumber = ?"
_, err := r.db.Exec(query, flexSpotNumberInt)
if err != nil {
r.Log.Errorf("could not delete spot %v from database", flexSpotNumberInt)
}
}
func DeleteDatabase(filePath string, log *log.Logger) {
_, err := os.Stat(filePath)
if !os.IsNotExist(err) {
err := os.Remove(filePath)
if err != nil {
log.Error("could not delete existing database")
}
log.Debug("deleting existing database")
}
}

592
flexradio.go Normal file
View File

@@ -0,0 +1,592 @@
package main
import (
"bufio"
"context"
"fmt"
"math"
"net"
"os"
"regexp"
"strings"
"time"
)
var CommandNumber int = 1
type FlexSpot struct {
ID int
CommandNumber int
FlexSpotNumber int
DX string
FrequencyMhz string
FrequencyHz string
Band string
Mode string
FlexMode string
Source string
SpotterCallsign string
TimeStamp int64
UTCTime string
LifeTime string
Priority string
OriginalComment string
Comment string
Color string
BackgroundColor string
NewDXCC bool
NewBand bool
NewMode bool
NewSlot bool
Worked bool
InWatchlist bool
CountryName string
DXCC string
}
type Discovery struct {
IP string
NickName string
Model string
Serial string
Version string
}
type FlexClient struct {
Address string
Port string
Timeout time.Duration
LogWriter *bufio.Writer
Reader *bufio.Reader
Writer *bufio.Writer
Conn *net.TCPConn
SpotChanToFlex chan TelnetSpot
MsgChan chan string
Repo FlexDXClusterRepository
TCPServer *TCPServer
HTTPServer *HTTPServer
IsConnected bool
ctx context.Context
cancel context.CancelFunc
reconnectAttempts int
maxReconnectAttempts int
baseReconnectDelay time.Duration
maxReconnectDelay time.Duration
Enabled bool
}
func NewFlexClient(repo FlexDXClusterRepository, TCPServer *TCPServer, SpotChanToFlex chan TelnetSpot, httpServer *HTTPServer) *FlexClient {
ctx, cancel := context.WithCancel(context.Background())
enabled := Cfg.General.FlexRadioSpot && (Cfg.Flex.IP != "" || Cfg.Flex.Discover)
return &FlexClient{
Port: "4992",
SpotChanToFlex: SpotChanToFlex,
MsgChan: TCPServer.MsgChan,
Repo: repo,
TCPServer: TCPServer,
IsConnected: false,
Enabled: enabled,
ctx: ctx,
cancel: cancel,
maxReconnectAttempts: -1, // -1 = infini
baseReconnectDelay: 5 * time.Second, // Délai initial
maxReconnectDelay: 5 * time.Minute, // Max 5 minutes
}
}
func (fc *FlexClient) calculateBackoff() time.Duration {
delay := time.Duration(float64(fc.baseReconnectDelay) * math.Pow(1.5, float64(fc.reconnectAttempts)))
if delay > fc.maxReconnectDelay {
delay = fc.maxReconnectDelay
}
return delay
}
func (fc *FlexClient) resolveAddress() (string, error) {
if Cfg.Flex.IP == "" && !Cfg.Flex.Discover {
return "", fmt.Errorf("you must either turn FlexRadio Discovery on or provide an IP address")
}
if Cfg.Flex.Discover {
Log.Debug("Attempting FlexRadio discovery...")
// Timeout sur la découverte (10 secondes max)
discoveryDone := make(chan struct {
success bool
discovery *Discovery
}, 1)
go func() {
ok, d := DiscoverFlexRadio()
discoveryDone <- struct {
success bool
discovery *Discovery
}{ok, d}
}()
select {
case result := <-discoveryDone:
if result.success {
Log.Infof("Found: %s with Nick: %s, Version: %s, Serial: %s - using IP: %s",
result.discovery.Model, result.discovery.NickName, result.discovery.Version,
result.discovery.Serial, result.discovery.IP)
return result.discovery.IP, nil
}
case <-time.After(10 * time.Second):
Log.Warn("Discovery timeout after 10 seconds")
}
if Cfg.Flex.IP == "" {
return "", fmt.Errorf("could not discover any FlexRadio on the network and no IP provided")
}
Log.Warn("Discovery failed, using configured IP")
}
return Cfg.Flex.IP, nil
}
func (fc *FlexClient) connect() error {
ip, err := fc.resolveAddress()
if err != nil {
return err
}
fc.Address = ip
addr, err := net.ResolveTCPAddr("tcp", fc.Address+":"+fc.Port)
if err != nil {
return fmt.Errorf("cannot resolve address %s:%s: %w", fc.Address, fc.Port, err)
}
Log.Debugf("Attempting to connect to FlexRadio at %s:%s (attempt %d)",
fc.Address, fc.Port, fc.reconnectAttempts+1)
conn, err := net.DialTimeout("tcp", addr.String(), 10*time.Second)
if err != nil {
return fmt.Errorf("could not connect to FlexRadio: %w", err)
}
tcpConn, ok := conn.(*net.TCPConn)
if !ok {
conn.Close()
return fmt.Errorf("connection is not a TCP connection")
}
fc.Conn = tcpConn
fc.Reader = bufio.NewReader(fc.Conn)
fc.Writer = bufio.NewWriter(fc.Conn)
fc.IsConnected = true
fc.reconnectAttempts = 0
if err := fc.Conn.SetKeepAlive(true); err != nil {
Log.Warn("Could not set keep alive:", err)
}
Log.Infof("✅ Connected to FlexRadio at %s:%s", fc.Address, fc.Port)
return nil
}
func (fc *FlexClient) StartFlexClient() {
fc.LogWriter = bufio.NewWriter(os.Stdout)
fc.Timeout = 600 * time.Second
if !fc.Enabled {
Log.Info("FlexRadio integration disabled in config - skipping")
// Consommer les spots pour éviter les blocages
go func() {
for {
select {
case <-fc.ctx.Done():
return
case <-fc.SpotChanToFlex:
// Ignorer les spots
}
}
}()
return
}
// Goroutine pour envoyer les spots au Flex
go func() {
for {
select {
case <-fc.ctx.Done():
return
case spot := <-fc.SpotChanToFlex:
fc.SendSpottoFlex(spot)
}
}
}()
for {
select {
case <-fc.ctx.Done():
Log.Info("Flex Client shutting down...")
return
default:
}
// Tentative de connexion
err := fc.connect()
if err != nil {
fc.IsConnected = false
fc.reconnectAttempts++
backoff := fc.calculateBackoff()
// Message moins alarmiste
if fc.reconnectAttempts == 1 {
Log.Warnf("FlexRadio not available: %v", err)
Log.Info("FlexDXCluster will continue without FlexRadio and retry connection periodically")
} else {
Log.Debugf("FlexRadio still not available. Next retry in %v", backoff)
}
// Attendre avant de réessayer
select {
case <-fc.ctx.Done():
return
case <-time.After(backoff):
continue
}
}
// Connexion réussie, initialiser le Flex
fc.initializeFlex()
// Démarrer la lecture (bloquant jusqu'à déconnexion)
fc.ReadLine()
// Si ReadLine se termine (déconnexion), réessayer
fc.IsConnected = false
Log.Warn("FlexRadio connection lost. Will retry...")
time.Sleep(5 * time.Second)
}
}
func (fc *FlexClient) initializeFlex() {
subSpotAllCmd := fmt.Sprintf("C%v|sub spot all", CommandNumber)
fc.Write(subSpotAllCmd)
CommandNumber++
clrSpotAllCmd := fmt.Sprintf("C%v|spot clear", CommandNumber)
fc.Write(clrSpotAllCmd)
CommandNumber++
Log.Debug("Subscribed to spots on FlexRadio and cleared all spots from panadapter")
}
func (fc *FlexClient) Close() {
fc.cancel()
if fc.Conn != nil {
fc.Conn.Close()
}
}
func (fc *FlexClient) SendSpottoFlex(spot TelnetSpot) {
freq := FreqMhztoHz(spot.Frequency)
flexSpot := FlexSpot{
CommandNumber: CommandNumber,
DX: spot.DX,
FrequencyMhz: freq,
FrequencyHz: spot.Frequency,
Band: spot.Band,
Mode: spot.Mode,
Source: "FlexDXCluster",
SpotterCallsign: spot.Spotter,
TimeStamp: time.Now().Unix(),
UTCTime: spot.Time,
LifeTime: Cfg.Flex.SpotLife,
OriginalComment: spot.Comment,
Comment: spot.Comment,
Color: "#ffeaeaea",
BackgroundColor: "#ff000000",
Priority: "5",
NewDXCC: spot.NewDXCC,
NewBand: spot.NewBand,
NewMode: spot.NewMode,
NewSlot: spot.NewSlot,
Worked: spot.CallsignWorked,
InWatchlist: false,
CountryName: spot.CountryName,
DXCC: spot.DXCC,
}
flexSpot.Comment = flexSpot.Comment + " [" + flexSpot.Mode + "] [" + flexSpot.SpotterCallsign + "] [" + flexSpot.UTCTime + "]"
if fc.HTTPServer != nil && fc.HTTPServer.Watchlist != nil {
if fc.HTTPServer.Watchlist.Matches(flexSpot.DX) {
flexSpot.InWatchlist = true
flexSpot.Comment = flexSpot.Comment + " [Watchlist]"
Log.Infof("🎯 Watchlist match: %s", flexSpot.DX)
}
}
// If new DXCC
if spot.NewDXCC {
flexSpot.Priority = "1"
flexSpot.Comment = flexSpot.Comment + " [New DXCC]"
if Cfg.General.SpotColorNewEntity != "" && Cfg.General.BackgroundColorNewEntity != "" {
flexSpot.Color = Cfg.General.SpotColorNewEntity
flexSpot.BackgroundColor = Cfg.General.BackgroundColorNewEntity
} else {
flexSpot.Color = "#ff3bf908"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.DX == Cfg.General.Callsign {
flexSpot.Priority = "1"
if Cfg.General.SpotColorMyCallsign != "" && Cfg.General.BackgroundColorMyCallsign != "" {
flexSpot.Color = Cfg.General.SpotColorMyCallsign
flexSpot.BackgroundColor = Cfg.General.BackgroundColorMyCallsign
} else {
flexSpot.Color = "#ffff0000"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.CallsignWorked {
flexSpot.Priority = "5"
flexSpot.Comment = flexSpot.Comment + " [Worked]"
if Cfg.General.SpotColorWorked != "" && Cfg.General.BackgroundColorWorked != "" {
flexSpot.Color = Cfg.General.SpotColorWorked
flexSpot.BackgroundColor = Cfg.General.BackgroundColorWorked
} else {
flexSpot.Color = "#ff000000"
flexSpot.BackgroundColor = "#ff00c0c0"
}
} else if spot.NewMode && spot.NewBand {
flexSpot.Priority = "1"
flexSpot.Comment = flexSpot.Comment + " [New Band & Mode]"
if Cfg.General.SpotColorNewBandMode != "" && Cfg.General.BackgroundColorNewBandMode != "" {
flexSpot.Color = Cfg.General.SpotColorNewBandMode
flexSpot.BackgroundColor = Cfg.General.BackgroundColorNewBandMode
} else {
flexSpot.Color = "#ffc603fc"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.NewMode && !spot.NewBand {
flexSpot.Priority = "2"
flexSpot.Comment = flexSpot.Comment + " [New Mode]"
if Cfg.General.SpotColorNewMode != "" && Cfg.General.BackgroundColorNewMode != "" {
flexSpot.Color = Cfg.General.SpotColorNewMode
flexSpot.BackgroundColor = Cfg.General.BackgroundColorNewMode
} else {
flexSpot.Color = "#fff9a908"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.NewBand && !spot.NewMode {
flexSpot.Color = "#fff9f508"
flexSpot.Priority = "3"
flexSpot.BackgroundColor = "#ff000000"
flexSpot.Comment = flexSpot.Comment + " [New Band]"
} else if !spot.NewBand && !spot.NewMode && !spot.NewDXCC && !spot.CallsignWorked && spot.NewSlot {
flexSpot.Color = "#ff91d2ff"
flexSpot.Priority = "5"
flexSpot.BackgroundColor = "#ff000000"
flexSpot.Comment = flexSpot.Comment + " [New Slot]"
} else if !spot.NewBand && !spot.NewMode && !spot.NewDXCC && !spot.CallsignWorked {
flexSpot.Color = "#ffeaeaea"
flexSpot.Priority = "5"
flexSpot.BackgroundColor = "#ff000000"
} else {
flexSpot.Color = "#ffeaeaea"
flexSpot.Priority = "5"
flexSpot.BackgroundColor = "#ff000000"
}
// Send notification to Gotify
Gotify(flexSpot)
flexSpot.Comment = strings.ReplaceAll(flexSpot.Comment, " ", "\u00A0")
srcFlexSpot, err := fc.Repo.FindDXSameBand(flexSpot)
if err != nil {
Log.Debugf("Could not find the DX in the database: %v", err)
}
var stringSpot string
if srcFlexSpot.DX == "" {
fc.Repo.CreateSpot(flexSpot)
CommandNumber++
if fc.HTTPServer != nil {
fc.HTTPServer.broadcast <- WSMessage{
Type: "spots",
Data: fc.Repo.GetAllSpots("1000"),
}
}
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot add rx_freq=%v callsign=%s mode=%s source=%s spotter_callsign=%s timestamp=%v lifetime_seconds=%s comment=%s color=%s background_color=%s priority=%s",
flexSpot.CommandNumber, flexSpot.FrequencyMhz,
flexSpot.DX, flexSpot.Mode, flexSpot.Source, flexSpot.SpotterCallsign,
flexSpot.TimeStamp, flexSpot.LifeTime, flexSpot.Comment, flexSpot.Color,
flexSpot.BackgroundColor, flexSpot.Priority)
CommandNumber++
fc.SendSpot(stringSpot)
}
} else if srcFlexSpot.DX != "" && srcFlexSpot.Band == flexSpot.Band {
fc.Repo.DeleteSpotByFlexSpotNumber(string(flexSpot.FlexSpotNumber))
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot remove %v", flexSpot.CommandNumber, srcFlexSpot.FlexSpotNumber)
fc.SendSpot(stringSpot)
CommandNumber++
}
fc.Repo.CreateSpot(flexSpot)
CommandNumber++
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot add rx_freq=%v callsign=%s mode=%s source=%s spotter_callsign=%s timestamp=%v lifetime_seconds=%s comment=%s color=%s background_color=%s priority=%s",
flexSpot.CommandNumber, flexSpot.FrequencyMhz,
flexSpot.DX, flexSpot.Mode, flexSpot.Source, flexSpot.SpotterCallsign,
flexSpot.TimeStamp, flexSpot.LifeTime, flexSpot.Comment, flexSpot.Color,
flexSpot.BackgroundColor, flexSpot.Priority)
CommandNumber++
fc.SendSpot(stringSpot)
}
} else if srcFlexSpot.DX != "" && srcFlexSpot.Band != flexSpot.Band {
fc.Repo.CreateSpot(flexSpot)
CommandNumber++
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot add rx_freq=%v callsign=%s mode=%s source=%s spotter_callsign=%s timestamp=%v lifetime_seconds=%s comment=%s color=%s background_color=%s priority=%s",
flexSpot.CommandNumber, flexSpot.FrequencyMhz,
flexSpot.DX, flexSpot.Mode, flexSpot.Source, flexSpot.SpotterCallsign,
flexSpot.TimeStamp, flexSpot.LifeTime, flexSpot.Comment, flexSpot.Color,
flexSpot.BackgroundColor, flexSpot.Priority)
CommandNumber++
fc.SendSpot(stringSpot)
}
}
}
func (fc *FlexClient) SendSpot(stringSpot string) {
if fc.IsConnected {
fc.Write(stringSpot)
}
}
func (fc *FlexClient) ReadLine() {
defer func() {
if fc.Conn != nil {
fc.Conn.Close()
}
}()
for {
select {
case <-fc.ctx.Done():
return
default:
}
// Timeout sur la lecture
fc.Conn.SetReadDeadline(time.Now().Add(5 * time.Minute))
message, err := fc.Reader.ReadString(byte('\n'))
if err != nil {
Log.Debugf("Error reading from FlexRadio: %s", err)
return
}
fc.Conn.SetReadDeadline(time.Time{})
regRespSpot := *regexp.MustCompile(`R(\d+)\|0\|(\d+)\n`)
respSpot := regRespSpot.FindStringSubmatch(message)
if len(respSpot) > 0 {
spot, _ := fc.Repo.FindSpotByCommandNumber(respSpot[1])
_, err := fc.Repo.UpdateFlexSpotNumberByID(respSpot[2], *spot)
if err != nil {
Log.Errorf("Could not update Flex spot number in database: %s", err)
}
}
// Response when a spot is clicked
regTriggerSpot := *regexp.MustCompile(`.*spot (\d+) triggered.*\n`)
respTrigger := regTriggerSpot.FindStringSubmatch(message)
if len(respTrigger) > 0 {
spot, err := fc.Repo.FindSpotByFlexSpotNumber(respTrigger[1])
if err != nil {
Log.Errorf("could not find spot by flex spot number in database: %s", err)
}
// Sending the callsign to Log4OM
SendUDPMessage("<CALLSIGN>" + spot.DX)
}
// Status when a spot is deleted
regSpotDeleted := *regexp.MustCompile(`S\d+\|spot (\d+) removed`)
respDelete := regSpotDeleted.FindStringSubmatch(message)
if len(respDelete) > 0 {
fc.Repo.DeleteSpotByFlexSpotNumber(respDelete[1])
}
}
}
func (fc *FlexClient) Write(data string) (n int, err error) {
if fc.Conn == nil || fc.Writer == nil || !fc.IsConnected {
return 0, fmt.Errorf("not connected to FlexRadio")
}
n, err = fc.Writer.Write([]byte(data + "\n"))
if err == nil {
err = fc.Writer.Flush()
}
return
}
func DiscoverFlexRadio() (bool, *Discovery) {
if Cfg.Flex.Discover {
Log.Debugln("FlexRadio Discovery is turned on...searching for radio on the network")
pc, err := net.ListenPacket("udp", ":4992")
if err != nil {
Log.Errorf("Could not receive UDP packets to discover FlexRadio: %v", err)
return false, nil
}
defer pc.Close()
pc.SetReadDeadline(time.Now().Add(10 * time.Second))
buf := make([]byte, 1024)
for {
n, _, err := pc.ReadFrom(buf)
if err != nil {
// Timeout atteint
return false, nil
}
discoverRe := regexp.MustCompile(`discovery_protocol_version=.*\smodel=(.*)\sserial=(.*)\sversion=(.*)\snickname=(.*)\scallsign=.*\sip=(.*)\sport=.*`)
match := discoverRe.FindStringSubmatch(string(buf[:n]))
if len(match) > 0 {
d := &Discovery{
NickName: match[4],
Model: match[1],
Serial: match[2],
Version: match[3],
IP: match[5],
}
return true, d
}
}
} else {
Log.Infoln("FlexRadio Discovery is turned off...using IP provided in the config file")
}
return false, nil
}

25
go.mod Normal file
View File

@@ -0,0 +1,25 @@
module git.rouggy.com/rouggy/FlexDXCluster
go 1.23.1
require (
github.com/go-sql-driver/mysql v1.9.3
github.com/mattn/go-sqlite3 v1.14.23
github.com/sirupsen/logrus v1.9.3
github.com/x-cray/logrus-prefixed-formatter v0.5.2
gopkg.in/yaml.v2 v2.4.0
)
require (
filippo.io/edwards25519 v1.1.0 // indirect
github.com/gorilla/mux v1.8.1 // indirect
github.com/gorilla/websocket v1.5.3 // indirect
github.com/mattn/go-colorable v0.1.13 // indirect
github.com/mattn/go-isatty v0.0.16 // indirect
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d // indirect
github.com/onsi/ginkgo v1.16.5 // indirect
github.com/onsi/gomega v1.35.1 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/sys v0.26.0 // indirect
golang.org/x/term v0.24.0 // indirect
)

119
go.sum Normal file
View File

@@ -0,0 +1,119 @@
filippo.io/edwards25519 v1.1.0 h1:FNf4tywRC1HmFuKW5xopWpigGjJKiJSV0Cqo0cJWDaA=
filippo.io/edwards25519 v1.1.0/go.mod h1:BxyFTGdWcka3PhytdK4V28tE5sGfRvvvRV7EaN4VDT4=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.4.7/go.mod h1:jwhsz4b93w/PPRr/qN1Yymfu8t87LnFCMoQvtojpjFo=
github.com/fsnotify/fsnotify v1.4.9 h1:hsms1Qyu0jgnwNXIxa+/V/PDsU6CfLf6CNO8H7IWoS4=
github.com/fsnotify/fsnotify v1.4.9/go.mod h1:znqG4EE+3YCdAaPaxE2ZRY/06pZUdp0tY4IgpuI1SZQ=
github.com/go-sql-driver/mysql v1.9.3 h1:U/N249h2WzJ3Ukj8SowVFjdtZKfu9vlLZxjPXV1aweo=
github.com/go-sql-driver/mysql v1.9.3/go.mod h1:qn46aNg1333BRMNU69Lq93t8du/dwxI64Gl8i5p1WMU=
github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0/go.mod h1:fyg7847qk6SyHyPtNmDHnmrv/HOrqktSC+C9fM+CJOE=
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
github.com/golang/protobuf v1.4.2/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/gorilla/mux v1.8.1 h1:TuBL49tXwgrFYWhqrNgrUNEY92u81SPhu7sTdzQEiWY=
github.com/gorilla/mux v1.8.1/go.mod h1:AKf9I4AEqPTmMytcMc0KkNouC66V3BtZ4qD5fmWSiMQ=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

89
gotify.go Normal file
View File

@@ -0,0 +1,89 @@
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"strings"
)
type GotifyMessage struct {
Title string `json:"title"`
Message string `json:"message"`
Priority int `json:"priority"`
}
func Gotify(spot FlexSpot) {
ExceptionList := "4U1UN 5Z4B OH2B CS3B"
if Cfg.Gotify.Enable && !strings.Contains(ExceptionList, spot.DX) {
message := fmt.Sprintf("DX: %s\nFrom: %s\nFreq: %s\nMode: %s\nCountry: %s\nTime: %s\n", spot.DX, spot.SpotterCallsign, spot.FrequencyMhz, spot.Mode, spot.CountryName, spot.UTCTime)
gotifyMsg := GotifyMessage{
Title: "",
Message: message,
Priority: 10,
}
if spot.NewDXCC && Cfg.Gotify.NewDXCC {
title := "FlexDXCluster New DXCC"
gotifyMsg.Title = title
gotifyMsg.Message = message
sendToGotify(gotifyMsg)
}
if spot.NewBand && spot.NewMode && Cfg.Gotify.NewBandAndMode {
title := "FlexDXCluster New Mode & Band"
gotifyMsg.Title = title
gotifyMsg.Message = message
sendToGotify(gotifyMsg)
}
if spot.NewMode && Cfg.Gotify.NewMode && !spot.NewBand {
title := "FlexDXCluster New Mode"
gotifyMsg.Title = title
gotifyMsg.Message = message
sendToGotify(gotifyMsg)
}
if spot.NewBand && Cfg.Gotify.NewBand && !spot.NewMode {
title := "FlexDXCluster New Band"
gotifyMsg.Title = title
gotifyMsg.Message = message
sendToGotify(gotifyMsg)
}
}
}
func sendToGotify(mess GotifyMessage) {
jsonData, err := json.Marshal(mess)
if err != nil {
Log.Errorln("Error marshaling JSON:", err)
return
}
req, err := http.NewRequest("POST", Cfg.Gotify.URL, bytes.NewBuffer(jsonData))
if err != nil {
Log.Errorln("Error creating request:", err)
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Add("Authorization", "Bearer "+Cfg.Gotify.Token)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
Log.Errorln("Error sending request:", err)
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
Log.Errorln("Gotify server returned non-OK status:", resp.Status)
} else {
Log.Debugln("Push successfully sent to Gotify")
}
}

613
httpserver.go Normal file
View File

@@ -0,0 +1,613 @@
package main
import (
"encoding/json"
"encoding/xml"
"fmt"
"io"
"net/http"
"os"
"sync"
"time"
"github.com/gorilla/mux"
"github.com/gorilla/websocket"
log "github.com/sirupsen/logrus"
)
type HTTPServer struct {
Router *mux.Router
FlexRepo *FlexDXClusterRepository
ContactRepo *Log4OMContactsRepository
TCPServer *TCPServer
TCPClient *TCPClient
FlexClient *FlexClient
Port string
Log *log.Logger
statsCache Stats
statsMutex sync.RWMutex
lastUpdate time.Time
wsClients map[*websocket.Conn]bool
wsMutex sync.RWMutex
broadcast chan WSMessage
Watchlist *Watchlist
}
type Stats struct {
TotalSpots int `json:"totalSpots"`
ActiveSpotters int `json:"activeSpotters"`
NewDXCC int `json:"newDXCC"`
ConnectedClients int `json:"connectedClients"`
TotalContacts int `json:"totalContacts"`
ClusterStatus string `json:"clusterStatus"`
FlexStatus string `json:"flexStatus"`
MyCallsign string `json:"myCallsign"`
Mode string `json:"mode"`
Filters Filters `json:"filters"`
}
type Filters struct {
Skimmer bool `json:"skimmer"`
FT8 bool `json:"ft8"`
FT4 bool `json:"ft4"`
}
type APIResponse struct {
Success bool `json:"success"`
Data interface{} `json:"data,omitempty"`
Error string `json:"error,omitempty"`
Message string `json:"message,omitempty"`
}
type WSMessage struct {
Type string `json:"type"`
Data interface{} `json:"data"`
}
type SendCallsignRequest struct {
Callsign string `json:"callsign"`
}
var upgrader = websocket.Upgrader{
CheckOrigin: func(r *http.Request) bool {
return true // Allow all origins in development
},
}
func NewHTTPServer(flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContactsRepository,
tcpServer *TCPServer, tcpClient *TCPClient, flexClient *FlexClient, port string) *HTTPServer {
server := &HTTPServer{
Router: mux.NewRouter(),
FlexRepo: flexRepo,
ContactRepo: contactRepo,
TCPServer: tcpServer,
TCPClient: tcpClient,
FlexClient: flexClient,
Port: port,
Log: Log,
wsClients: make(map[*websocket.Conn]bool),
broadcast: make(chan WSMessage, 256),
Watchlist: NewWatchlist("watchlist.json"),
}
server.setupRoutes()
go server.handleBroadcasts()
go server.broadcastUpdates()
return server
}
func (s *HTTPServer) setupRoutes() {
// Enable CORS
s.Router.Use(s.corsMiddleware)
// API Routes
api := s.Router.PathPrefix("/api").Subrouter()
api.HandleFunc("/stats", s.getStats).Methods("GET", "OPTIONS")
api.HandleFunc("/spots", s.getSpots).Methods("GET", "OPTIONS")
api.HandleFunc("/spots/{id}", s.getSpotByID).Methods("GET", "OPTIONS")
api.HandleFunc("/spotters", s.getTopSpotters).Methods("GET", "OPTIONS")
api.HandleFunc("/contacts", s.getContacts).Methods("GET", "OPTIONS")
api.HandleFunc("/filters", s.updateFilters).Methods("POST", "OPTIONS")
api.HandleFunc("/shutdown", s.shutdownApp).Methods("POST", "OPTIONS")
api.HandleFunc("/send-callsign", s.handleSendCallsign).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist", s.getWatchlist).Methods("GET", "OPTIONS")
api.HandleFunc("/watchlist/add", s.addToWatchlist).Methods("POST", "OPTIONS")
api.HandleFunc("/watchlist/remove", s.removeFromWatchlist).Methods("DELETE", "OPTIONS")
api.HandleFunc("/solar", s.HandleSolarData).Methods("GET", "OPTIONS")
api.HandleFunc("/log/recent", s.getRecentQSOs).Methods("GET", "OPTIONS")
api.HandleFunc("/log/stats", s.getLogStats).Methods("GET", "OPTIONS")
api.HandleFunc("/log/dxcc-progress", s.getDXCCProgress).Methods("GET", "OPTIONS")
// WebSocket endpoint
api.HandleFunc("/ws", s.handleWebSocket).Methods("GET")
// Serve static files (dashboard)
s.Router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static")))
}
func (s *HTTPServer) handleWebSocket(w http.ResponseWriter, r *http.Request) {
conn, err := upgrader.Upgrade(w, r, nil)
if err != nil {
s.Log.Errorf("WebSocket upgrade failed: %v", err)
return
}
s.wsMutex.Lock()
s.wsClients[conn] = true
clientCount := len(s.wsClients)
s.wsMutex.Unlock()
s.Log.Infof("New WebSocket client connected (total: %d)", clientCount)
// Send initial data
s.sendInitialData(conn)
// Keep connection alive and handle client messages
go s.handleWebSocketClient(conn)
}
func (s *HTTPServer) handleWebSocketClient(conn *websocket.Conn) {
defer func() {
s.wsMutex.Lock()
delete(s.wsClients, conn)
clientCount := len(s.wsClients)
s.wsMutex.Unlock()
conn.Close()
s.Log.Infof("WebSocket client disconnected (remaining: %d)", clientCount)
}()
// Read messages from client (for ping/pong)
for {
_, _, err := conn.ReadMessage()
if err != nil {
if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) {
s.Log.Errorf("WebSocket error: %v", err)
}
break
}
}
}
func (s *HTTPServer) sendInitialData(conn *websocket.Conn) {
// Send initial stats
stats := s.calculateStats()
conn.WriteJSON(WSMessage{Type: "stats", Data: stats})
// Send initial spots
spots := s.FlexRepo.GetAllSpots("1000")
conn.WriteJSON(WSMessage{Type: "spots", Data: spots})
// Send initial spotters
spotters := s.FlexRepo.GetSpotters()
conn.WriteJSON(WSMessage{Type: "spotters", Data: spotters})
// Send initial watchlist
watchlist := s.Watchlist.GetAll()
conn.WriteJSON(WSMessage{Type: "watchlist", Data: watchlist})
// Send initial log data
qsos := s.ContactRepo.GetRecentQSOs("5")
conn.WriteJSON(WSMessage{Type: "log", Data: qsos})
logStats := s.ContactRepo.GetQSOStats()
conn.WriteJSON(WSMessage{Type: "logStats", Data: logStats})
dxccCount := s.ContactRepo.GetDXCCCount()
dxccData := map[string]interface{}{
"worked": dxccCount,
"total": 340,
"percentage": float64(dxccCount) / 340.0 * 100.0,
}
conn.WriteJSON(WSMessage{Type: "dxccProgress", Data: dxccData})
}
func (s *HTTPServer) handleBroadcasts() {
for msg := range s.broadcast {
s.wsMutex.RLock()
for client := range s.wsClients {
err := client.WriteJSON(msg)
if err != nil {
s.Log.Errorf("WebSocket write error: %v", err)
client.Close()
s.wsMutex.RUnlock()
s.wsMutex.Lock()
delete(s.wsClients, client)
s.wsMutex.Unlock()
s.wsMutex.RLock()
}
}
s.wsMutex.RUnlock()
}
}
func (s *HTTPServer) broadcastUpdates() {
statsTicker := time.NewTicker(500 * time.Millisecond)
logTicker := time.NewTicker(10 * time.Second)
defer statsTicker.Stop()
defer logTicker.Stop()
for {
select {
case <-statsTicker.C:
s.wsMutex.RLock()
clientCount := len(s.wsClients)
s.wsMutex.RUnlock()
if clientCount == 0 {
continue
}
// Broadcast stats
stats := s.calculateStats()
s.broadcast <- WSMessage{Type: "stats", Data: stats}
// Broadcast spots
spots := s.FlexRepo.GetAllSpots("2000")
s.broadcast <- WSMessage{Type: "spots", Data: spots}
// Broadcast spotters
spotters := s.FlexRepo.GetSpotters()
s.broadcast <- WSMessage{Type: "spotters", Data: spotters}
case <-logTicker.C:
s.wsMutex.RLock()
clientCount := len(s.wsClients)
s.wsMutex.RUnlock()
if clientCount == 0 {
continue
}
// Broadcast log data every 10 seconds
qsos := s.ContactRepo.GetRecentQSOs("5")
s.broadcast <- WSMessage{Type: "log", Data: qsos}
stats := s.ContactRepo.GetQSOStats()
s.broadcast <- WSMessage{Type: "logStats", Data: stats}
dxccCount := s.ContactRepo.GetDXCCCount()
dxccData := map[string]interface{}{
"worked": dxccCount,
"total": 340,
"percentage": float64(dxccCount) / 340.0 * 100.0,
}
s.broadcast <- WSMessage{Type: "dxccProgress", Data: dxccData}
}
}
}
func (s *HTTPServer) getRecentQSOs(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
if limitStr == "" {
limitStr = "50"
}
qsos := s.ContactRepo.GetRecentQSOs(limitStr)
s.sendJSON(w, APIResponse{Success: true, Data: qsos})
}
func (s *HTTPServer) getLogStats(w http.ResponseWriter, r *http.Request) {
stats := s.ContactRepo.GetQSOStats()
s.sendJSON(w, APIResponse{Success: true, Data: stats})
}
func (s *HTTPServer) getDXCCProgress(w http.ResponseWriter, r *http.Request) {
workedCount := s.ContactRepo.GetDXCCCount()
data := map[string]interface{}{
"worked": workedCount,
"total": 340,
"percentage": float64(workedCount) / 340.0 * 100.0,
}
s.sendJSON(w, APIResponse{Success: true, Data: data})
}
func (s *HTTPServer) calculateStats() Stats {
allSpots := s.FlexRepo.GetAllSpots("0")
spotters := s.FlexRepo.GetSpotters()
contacts := s.ContactRepo.CountEntries()
newDXCCCount := 0
for _, spot := range allSpots {
if spot.NewDXCC {
newDXCCCount++
}
}
clusterStatus := "disconnected"
if s.TCPClient != nil && s.TCPClient.LoggedIn {
clusterStatus = "connected"
}
flexStatus := "disconnected"
if s.FlexClient != nil && s.FlexClient.IsConnected {
flexStatus = "connected"
}
return Stats{
TotalSpots: len(allSpots),
ActiveSpotters: len(spotters),
NewDXCC: newDXCCCount,
ConnectedClients: len(s.TCPServer.Clients),
TotalContacts: contacts,
ClusterStatus: clusterStatus,
FlexStatus: flexStatus,
MyCallsign: Cfg.General.Callsign,
Filters: Filters{
Skimmer: Cfg.Cluster.Skimmer,
FT8: Cfg.Cluster.FT8,
FT4: Cfg.Cluster.FT4,
},
}
}
func (s *HTTPServer) corsMiddleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Access-Control-Allow-Origin", "*")
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization")
if r.Method == "OPTIONS" {
w.WriteHeader(http.StatusOK)
return
}
next.ServeHTTP(w, r)
})
}
func (s *HTTPServer) getStats(w http.ResponseWriter, r *http.Request) {
stats := s.calculateStats()
s.sendJSON(w, APIResponse{Success: true, Data: stats})
}
func (s *HTTPServer) getSpots(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
if limitStr == "" {
limitStr = "50"
}
spots := s.FlexRepo.GetAllSpots(limitStr)
s.sendJSON(w, APIResponse{Success: true, Data: spots})
}
func (s *HTTPServer) getSpotByID(w http.ResponseWriter, r *http.Request) {
vars := mux.Vars(r)
id := vars["id"]
spot, err := s.FlexRepo.FindSpotByFlexSpotNumber(id)
if err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Spot not found"})
return
}
s.sendJSON(w, APIResponse{Success: true, Data: spot})
}
func (s *HTTPServer) getTopSpotters(w http.ResponseWriter, r *http.Request) {
spotters := s.FlexRepo.GetSpotters()
s.sendJSON(w, APIResponse{Success: true, Data: spotters})
}
func (s *HTTPServer) getContacts(w http.ResponseWriter, r *http.Request) {
count := s.ContactRepo.CountEntries()
data := map[string]interface{}{"totalContacts": count}
s.sendJSON(w, APIResponse{Success: true, Data: data})
}
type FilterRequest struct {
Skimmer *bool `json:"skimmer,omitempty"`
FT8 *bool `json:"ft8,omitempty"`
FT4 *bool `json:"ft4,omitempty"`
}
func (s *HTTPServer) updateFilters(w http.ResponseWriter, r *http.Request) {
var req FilterRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
commands := []string{}
if req.Skimmer != nil {
if *req.Skimmer {
commands = append(commands, "set/skimmer")
Cfg.Cluster.Skimmer = true
} else {
commands = append(commands, "set/noskimmer")
Cfg.Cluster.Skimmer = false
}
}
if req.FT8 != nil {
if *req.FT8 {
commands = append(commands, "set/ft8")
Cfg.Cluster.FT8 = true
} else {
commands = append(commands, "set/noft8")
Cfg.Cluster.FT8 = false
}
}
if req.FT4 != nil {
if *req.FT4 {
commands = append(commands, "set/ft4")
Cfg.Cluster.FT4 = true
} else {
commands = append(commands, "set/noft4")
Cfg.Cluster.FT4 = false
}
}
for _, cmd := range commands {
s.TCPClient.CmdChan <- cmd
}
s.sendJSON(w, APIResponse{Success: true, Data: map[string]string{"message": "Filters updated successfully"}})
}
func (s *HTTPServer) getWatchlist(w http.ResponseWriter, r *http.Request) {
callsigns := s.Watchlist.GetAll()
s.sendJSON(w, APIResponse{Success: true, Data: callsigns})
}
func (s *HTTPServer) addToWatchlist(w http.ResponseWriter, r *http.Request) {
var req struct {
Callsign string `json:"callsign"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
if req.Callsign == "" {
s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"})
return
}
if err := s.Watchlist.Add(req.Callsign); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: err.Error()})
return
}
s.Log.Infof("Added %s to watchlist", req.Callsign)
// Broadcast updated watchlist to all clients
s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()}
s.sendJSON(w, APIResponse{Success: true, Message: "Callsign added to watchlist"})
}
func (s *HTTPServer) removeFromWatchlist(w http.ResponseWriter, r *http.Request) {
var req struct {
Callsign string `json:"callsign"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
if req.Callsign == "" {
s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"})
return
}
if err := s.Watchlist.Remove(req.Callsign); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: err.Error()})
return
}
s.Log.Debugf("Removed %s from watchlist", req.Callsign)
// Broadcast updated watchlist to all clients
s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()}
s.sendJSON(w, APIResponse{Success: true, Message: "Callsign removed from watchlist"})
}
func (s *HTTPServer) HandleSolarData(w http.ResponseWriter, r *http.Request) {
// Récupérer les données depuis hamqsl.com
resp, err := http.Get("https://www.hamqsl.com/solarxml.php")
if err != nil {
s.Log.Errorf("Error fetching solar data: %v", err)
s.sendJSON(w, APIResponse{Success: false, Error: "Failed to fetch solar data"})
return
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
s.Log.Errorf("Error reading solar data: %v", err)
s.sendJSON(w, APIResponse{Success: false, Error: "Failed to read solar data"})
return
}
var solarXML SolarXML
err = xml.Unmarshal(body, &solarXML)
if err != nil {
s.Log.Errorf("Error parsing solar XML: %v", err)
s.sendJSON(w, APIResponse{Success: false, Error: "Failed to parse solar data"})
return
}
response := map[string]interface{}{
"sfi": solarXML.Data.SolarFlux,
"sunspots": solarXML.Data.Sunspots,
"aIndex": solarXML.Data.AIndex,
"kIndex": solarXML.Data.KIndex,
"updated": solarXML.Data.Updated,
}
s.sendJSON(w, APIResponse{Success: true, Data: response})
}
func (s *HTTPServer) handleSendCallsign(w http.ResponseWriter, r *http.Request) {
var req struct {
Callsign string `json:"callsign"`
Frequency string `json:"frequency"`
Mode string `json:"mode"`
}
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
if req.Callsign == "" {
s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"})
return
}
SendUDPMessage("<CALLSIGN>" + req.Callsign)
s.Log.Infof("Sent callsign %s to Log4OM via UDP (127.0.0.1:2241)", req.Callsign)
if req.Frequency != "" && s.FlexClient != nil && s.FlexClient.IsConnected {
tuneCmd := fmt.Sprintf("C%v|slice tune 0 %s", CommandNumber, req.Frequency)
s.FlexClient.Write(tuneCmd)
CommandNumber++
time.Sleep(time.Millisecond * 500)
modeCmd := fmt.Sprintf("C%v|slice s 0 mode=%s", CommandNumber, req.Mode)
s.FlexClient.Write(modeCmd)
CommandNumber++
s.Log.Infof("Sent TUNE command to Flex: %s", tuneCmd)
}
s.sendJSON(w, APIResponse{
Success: true,
Message: "Callsign sent to Log4OM and radio tuned",
Data: map[string]string{"callsign": req.Callsign, "frequency": req.Frequency},
})
}
func (s *HTTPServer) sendJSON(w http.ResponseWriter, data interface{}) {
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(data)
}
func (s *HTTPServer) shutdownApp(w http.ResponseWriter, r *http.Request) {
s.Log.Info("Shutdown request received from dashboard")
s.sendJSON(w, APIResponse{Success: true, Data: map[string]string{"message": "Shutting down FlexDXCluster"}})
go func() {
time.Sleep(500 * time.Millisecond)
s.Log.Info("Initiating shutdown...")
os.Exit(0)
}()
}
func (s *HTTPServer) Start() {
s.Log.Infof("HTTP Server starting on port %s", s.Port)
s.Log.Infof("Dashboard available at http://localhost:%s", s.Port)
if err := http.ListenAndServe(":"+s.Port, s.Router); err != nil {
s.Log.Fatalf("Failed to start HTTP server: %v", err)
}
}

BIN
images/FlexDXCluster.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
images/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

72
log.go Normal file
View File

@@ -0,0 +1,72 @@
package main
import (
"io"
"os"
log "github.com/sirupsen/logrus"
prefixed "github.com/x-cray/logrus-prefixed-formatter"
)
var Log *log.Logger
func NewLog() *log.Logger {
if Cfg.General.DeleteLogFileAtStart {
if _, err := os.Stat("flexradio.log"); err == nil {
os.Remove("flexradio.log")
}
}
var w io.Writer
if Cfg.General.LogToFile {
f, err := os.OpenFile("flexradio.log", os.O_RDWR|os.O_CREATE|os.O_APPEND, 0666)
if err != nil {
panic(err)
}
w = io.MultiWriter(os.Stdout, f)
} else {
w = io.Writer(os.Stdout)
}
Log = &log.Logger{
Out: w,
Formatter: &prefixed.TextFormatter{
DisableColors: false,
TimestampFormat: "02-01-2006 15:04:05",
FullTimestamp: true,
ForceFormatting: true,
},
}
if Cfg.General.LogLevel == "DEBUG" {
Log.Level = log.DebugLevel
} else if Cfg.General.LogLevel == "INFO" {
Log.Level = log.InfoLevel
} else if Cfg.General.LogLevel == "WARN" {
Log.Level = log.WarnLevel
} else {
Log.Level = log.InfoLevel
}
return Log
}
// Info ...
// func Info(format string, v ...interface{}) {
// log.Infof(format, v...)
// }
// // Warn ...
// func Warn(format string, v ...interface{}) {
// log.Warnf(format, v...)
// }
// // Error ...
// func Error(format string, v ...interface{}) {
// log.Errorf(format, v...)
// }
// func Debug(format string, v ...interface{}) {
// log.Debugf(format, v...)
// }

94
main.go Normal file
View File

@@ -0,0 +1,94 @@
package main
import (
"flag"
"log"
"os"
"path/filepath"
"sync"
)
var Mutex sync.Mutex
func ParseFlags() (string, error) {
// String that contains the configured configuration path
var configPath string
exe, _ := os.Executable()
defaultCfgPath := filepath.Dir(exe)
defaultCfgPath = filepath.Join(defaultCfgPath, "/config.yml")
// Set up a CLI flag called "-config" to allow users
// to supply the configuration file
flag.StringVar(&configPath, "config", defaultCfgPath, "path to config file")
// Actually parse the flags
flag.Parse()
// Validate the path first
if err := ValidateConfigPath(configPath); err != nil {
return "", err
}
// Return the configuration path
return configPath, nil
}
func main() {
// Generate our config based on the config supplied
// by the user in the flags
cfgPath, err := ParseFlags()
if err != nil {
log.Fatal(err)
}
cfg := NewConfig(cfgPath)
log := NewLog()
log.Info("Running FlexDXCluster version 2.0")
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)
}
// Load country.xml to get all the DXCC number
Countries := LoadCountryFile()
log.Debug("XML Country File has been loaded properly.")
// Database to keep track of all spots
fRepo := NewFlexDXDatabase("flex.sqlite")
defer fRepo.db.Close()
// Database connection to Log4OM
cRepo := NewLog4OMContactsRepository(cfg.SQLite.SQLitePath)
defer cRepo.db.Close()
contacts := cRepo.CountEntries()
log.Infof("Log4OM Database Contains %v Contacts", contacts)
defer cRepo.db.Close()
// Initialize servers and clients
TCPServer := NewTCPServer(cfg.TelnetServer.Host, cfg.TelnetServer.Port)
TCPClient := NewTCPClient(TCPServer, Countries, cRepo)
FlexClient := NewFlexClient(*fRepo, TCPServer, TCPClient.SpotChanToFlex, nil)
// Initialize HTTP Server for Dashboard
HTTPServer := NewHTTPServer(fRepo, cRepo, TCPServer, TCPClient, FlexClient, "8080")
FlexClient.HTTPServer = HTTPServer
// Start all services
go FlexClient.StartFlexClient()
go TCPClient.StartClient()
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)
CheckSignal(TCPClient, TCPServer, FlexClient, fRepo, cRepo)
}

BIN
rsrc_windows_amd64.syso Normal file

Binary file not shown.

22
solar.go Normal file
View File

@@ -0,0 +1,22 @@
package main
import (
"encoding/xml"
)
type SolarData struct {
SolarFlux string `xml:"solarflux"`
Sunspots string `xml:"sunspots"`
AIndex string `xml:"aindex"`
KIndex string `xml:"kindex"`
SolarWind string `xml:"solarwind"`
HeliumLine string `xml:"heliumline"`
ProtonFlux string `xml:"protonflux"`
GeomagField string `xml:"geomagfield"`
Updated string `xml:"updated"`
}
type SolarXML struct {
XMLName xml.Name `xml:"solar"`
Data SolarData `xml:"solardata"`
}

392
spot.go Normal file
View File

@@ -0,0 +1,392 @@
package main
import (
"regexp"
"strconv"
"strings"
"sync"
_ "github.com/mattn/go-sqlite3"
)
type TelnetSpot struct {
DX string
Spotter string
Frequency string
Mode string
Band string
Time string
DXCC string
CountryName string
Comment string
CommandNumber int
FlexSpotNumber int
NewDXCC bool
NewBand bool
NewMode bool
NewSlot bool
CallsignWorked bool
}
// var spotNumber = 1
func ProcessTelnetSpot(re *regexp.Regexp, spotRaw string, SpotChanToFlex chan TelnetSpot, SpotChanToHTTPServer chan TelnetSpot, Countries Countries, contactRepo *Log4OMContactsRepository) {
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
}
// Send spots to FlexRadio
SpotChanToFlex <- spot
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)
}
} else {
// Log.Infof("Could not decode: %s", strings.Trim(spotRaw, "\n"))
}
// Log.Infof("Spots Processed: %v", spotNumber)
// spotNumber++
}
func (spot *TelnetSpot) GetBand() {
freq := FreqMhztoHz(spot.Frequency)
switch true {
case strings.HasPrefix(freq, "1.8"):
spot.Band = "160M"
if spot.Mode == "SSB" {
spot.Mode = "LSB"
}
case strings.HasPrefix(freq, "3."):
spot.Band = "80M"
if spot.Mode == "SSB" {
spot.Mode = "LSB"
}
case strings.HasPrefix(freq, "5."):
spot.Band = "60M"
if spot.Mode == "SSB" {
spot.Mode = "LSB"
}
case strings.HasPrefix(freq, "7."):
spot.Band = "40M"
if spot.Mode == "SSB" {
spot.Mode = "LSB"
}
case strings.HasPrefix(freq, "10."):
spot.Band = "30M"
case strings.HasPrefix(freq, "14."):
spot.Band = "20M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
case strings.HasPrefix(freq, "18."):
spot.Band = "17M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
case strings.HasPrefix(freq, "21."):
spot.Band = "15M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
case strings.HasPrefix(freq, "24."):
spot.Band = "12M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
case strings.HasPrefix(freq, "28."):
spot.Band = "10M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
case strings.HasPrefix(freq, "29."):
spot.Band = "10M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
case strings.HasPrefix(freq, "50."):
spot.Band = "6M"
if spot.Mode == "SSB" {
spot.Mode = "USB"
}
default:
spot.Band = "N/A"
}
}
func (spot *TelnetSpot) GuessMode() {
if spot.Mode == "" {
freqInt, err := strconv.ParseFloat(spot.Frequency, 32)
if err != nil {
Log.Errorf("could not convert frequency string in float64:", err)
}
switch spot.Band {
case "160M":
if freqInt >= 1800 && freqInt <= 1840 {
spot.Mode = "CW"
}
if freqInt >= 1840 && freqInt <= 1844 {
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 >= 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 < 18095 {
spot.Mode = "CW"
}
if freqInt >= 18095 && 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 < 24915 {
spot.Mode = "CW"
}
if freqInt >= 24915 && freqInt < 24919 {
spot.Mode = "FT8"
}
if freqInt >= 24919 && freqInt < 24930 {
spot.Mode = "CW"
}
if freqInt >= 24930 && freqInt <= 24990 {
spot.Mode = "USB"
}
case "10M":
if freqInt >= 28000 && freqInt < 28074 {
spot.Mode = "CW"
}
if freqInt >= 28074 && freqInt < 28080 {
spot.Mode = "FT8"
}
if freqInt >= 28080 && freqInt < 28100 {
spot.Mode = "RTTY"
}
if freqInt >= 28100 && freqInt < 28300 {
spot.Mode = "CW"
}
if freqInt >= 28300 && 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"
}
}
}
if spot.Mode == "" {
Log.Errorf("Could not identify mode for %s on %s", spot.DX, spot.Frequency)
}
}

1580
static/index.html Normal file

File diff suppressed because it is too large Load Diff

70
utils.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"fmt"
"log"
"net"
"os"
"os/signal"
"strconv"
"syscall"
)
func FreqMhztoHz(freq string) string {
frequency, err := strconv.ParseFloat(freq, 64)
if err != nil {
log.Println("could not convert frequency string to int", err)
}
frequency = frequency / 1000
return strconv.FormatFloat(frequency, 'f', 6, 64)
}
func FreqHztoMhz(freq string) string {
frequency, err := strconv.ParseFloat(freq, 64)
if err != nil {
log.Println("could not convert frequency string to int", err)
}
frequency = frequency * 1000
return strconv.FormatFloat(frequency, 'f', 6, 64)
}
func CheckSignal(TCPClient *TCPClient, TCPServer *TCPServer, FlexClient *FlexClient, fRepo *FlexDXClusterRepository, cRepo *Log4OMContactsRepository) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
// 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)
}
}
func SendUDPMessage(message string) {
conn, err := net.Dial("udp", "127.0.0.1:2241")
if err != nil {
fmt.Printf("Some error %v", err)
return
}
conn.Write([]byte(message))
conn.Close()
}

112
watchlist.go Normal file
View File

@@ -0,0 +1,112 @@
package main
import (
"encoding/json"
"os"
"strings"
"sync"
)
type Watchlist struct {
Callsigns []string `json:"callsigns"`
mu sync.RWMutex
filename string
}
func NewWatchlist(filename string) *Watchlist {
wl := &Watchlist{
Callsigns: []string{},
filename: filename,
}
wl.Load()
return wl
}
func (wl *Watchlist) Load() error {
wl.mu.Lock()
defer wl.mu.Unlock()
data, err := os.ReadFile(wl.filename)
if err != nil {
if os.IsNotExist(err) {
// File doesn't exist, start with empty watchlist
return nil
}
return err
}
return json.Unmarshal(data, &wl.Callsigns)
}
// save est une méthode privée sans lock (appelée par Add/Remove qui ont déjà le lock)
func (wl *Watchlist) save() error {
data, err := json.Marshal(wl.Callsigns)
if err != nil {
return err
}
return os.WriteFile(wl.filename, data, 0644)
}
func (wl *Watchlist) Add(callsign string) error {
wl.mu.Lock()
defer wl.mu.Unlock()
callsign = strings.ToUpper(strings.TrimSpace(callsign))
// Check if already exists
for _, c := range wl.Callsigns {
if c == callsign {
return nil // Already in watchlist
}
}
wl.Callsigns = append(wl.Callsigns, callsign)
return wl.save() // ← Appel de save() minuscule (sans lock)
}
func (wl *Watchlist) Remove(callsign string) error {
wl.mu.Lock()
defer wl.mu.Unlock()
callsign = strings.ToUpper(strings.TrimSpace(callsign))
for i, c := range wl.Callsigns {
if c == callsign {
wl.Callsigns = append(wl.Callsigns[:i], wl.Callsigns[i+1:]...)
return wl.save() // ← Appel de save() minuscule (sans lock)
}
}
return nil
}
func (wl *Watchlist) GetAll() []string {
wl.mu.RLock()
defer wl.mu.RUnlock()
result := make([]string, len(wl.Callsigns))
copy(result, wl.Callsigns)
return result
}
func (wl *Watchlist) Matches(callsign string) bool {
wl.mu.RLock()
defer wl.mu.RUnlock()
callsign = strings.ToUpper(callsign)
for _, pattern := range wl.Callsigns {
// Exact match
if callsign == pattern {
return true
}
// Prefix match (e.g., VK9 matches VK9XX)
if strings.HasPrefix(callsign, pattern) {
return true
}
}
return false
}

1
watchlist.json Normal file
View File

@@ -0,0 +1 @@
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA"]

139
xml.go Normal file
View File

@@ -0,0 +1,139 @@
package main
import (
"encoding/xml"
"io"
"os"
"regexp"
"strings"
"unicode/utf8"
)
type Countries struct {
XMLName xml.Name `xml:"Countries"`
Countries []Country `xml:"Country"`
}
type Country struct {
XMLName xml.Name `xml:"Country"`
ArrlPrefix string `xml:"ArrlPrefix"`
Comment string `xml:"Comment"`
Continent string `xml:"Continent"`
CountryName string `xml:"CountryName"`
CqZone string `xml:"CqZone"`
CqZoneList string `xml:"CqZoneList"`
Dxcc string `xml:"Dxcc"`
ItuZone string `xml:"ItuZone"`
IaruRegion string `xml:"IaruRegion"`
ItuZoneList string `xml:"ItuZoneList"`
Latitude string `xml:"Latitude"`
Longitude string `xml:"Longitude"`
Active string `xml:"Active"`
CountryTag string `xml:"CountryTag"`
CountryPrefixList CountryPrefixList `xml:"CountryPrefixList"`
}
type CountryPrefixList struct {
XMLName xml.Name `xml:"CountryPrefixList"`
CountryPrefixList []CountryPrefix `xml:"CountryPrefix"`
}
type CountryPrefix struct {
XMLName xml.Name `xml:"CountryPrefix"`
PrefixList string `xml:"PrefixList"`
StartDate string `xml:"StartDate"`
EndDate string `xml:"EndDate"`
}
type DXCC struct {
Callsign string
CountryName string
DXCC string
RegEx string
RegExSplit []string
RegExCharacters int
Ended bool
}
func LoadCountryFile() Countries {
// Open our xmlFile
xmlFile, err := os.Open("country.xml")
// if we os.Open returns an error then handle it
if err != nil {
os.Exit(1)
}
// defer the closing of our xmlFile so that we can parse it later on
defer xmlFile.Close()
// read our opened xmlFile as a byte array.
byteValue, _ := io.ReadAll(xmlFile)
var countries Countries
xml.Unmarshal(byteValue, &countries)
return countries
}
func GetDXCC(dxCall string, Countries Countries) DXCC {
DXCCList := []DXCC{}
d := DXCC{}
// Get all the matching DXCC for current callsign
for i := 0; i < len(Countries.Countries); i++ {
regExp := regexp.MustCompile(Countries.Countries[i].CountryPrefixList.CountryPrefixList[len(Countries.Countries[i].CountryPrefixList.CountryPrefixList)-1].PrefixList)
match := regExp.FindStringSubmatch(dxCall)
if len(match) != 0 {
d = DXCC{
Callsign: dxCall,
CountryName: Countries.Countries[i].CountryName,
DXCC: Countries.Countries[i].Dxcc,
RegEx: Countries.Countries[i].CountryPrefixList.CountryPrefixList[len(Countries.Countries[i].CountryPrefixList.CountryPrefixList)-1].PrefixList,
}
if Countries.Countries[i].CountryPrefixList.CountryPrefixList[len(Countries.Countries[i].CountryPrefixList.CountryPrefixList)-1].EndDate == "" {
d.Ended = false
} else {
d.Ended = true
}
DXCCList = append(DXCCList, d)
}
}
for i := 0; i < len(DXCCList); i++ {
DXCCList[i].RegExSplit = strings.Split(DXCCList[i].RegEx, "|")
for j := 0; j < len(DXCCList[i].RegExSplit); j++ {
regExp := regexp.MustCompile(DXCCList[i].RegExSplit[j])
matched := regExp.FindStringSubmatch(dxCall)
if len(matched) > 0 {
DXCCList[i].RegExCharacters = utf8.RuneCountInString(DXCCList[i].RegExSplit[j])
}
}
}
if len(DXCCList) > 0 {
DXCCMatch := DXCCList[0]
higherMatch := 0
if len(DXCCList) > 1 {
for i := 0; i < len(DXCCList); i++ {
if DXCCList[i].RegExCharacters > higherMatch && !DXCCList[i].Ended {
DXCCMatch = DXCCList[i]
higherMatch = DXCCList[i].RegExCharacters
}
}
} else {
DXCCMatch = DXCCList[0]
}
return DXCCMatch
} else {
Log.Errorf("Could not find %s in country list", dxCall)
}
return DXCC{}
}