first commit
This commit is contained in:
317
TCPClient.go
Normal file
317
TCPClient.go
Normal 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
139
TCPServer.go
Normal 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
111
config.go
Normal 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
57
config.yml
Normal 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
16888
country.xml
Normal file
File diff suppressed because one or more lines are too long
552
database.go
Normal file
552
database.go
Normal 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
592
flexradio.go
Normal 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
25
go.mod
Normal 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
119
go.sum
Normal 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
89
gotify.go
Normal 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
613
httpserver.go
Normal 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
BIN
images/FlexDXCluster.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 202 KiB |
BIN
images/background.jpg
Normal file
BIN
images/background.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.2 MiB |
72
log.go
Normal file
72
log.go
Normal 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
94
main.go
Normal 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
BIN
rsrc_windows_amd64.syso
Normal file
Binary file not shown.
22
solar.go
Normal file
22
solar.go
Normal 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
392
spot.go
Normal 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
1580
static/index.html
Normal file
File diff suppressed because it is too large
Load Diff
70
utils.go
Normal file
70
utils.go
Normal 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
112
watchlist.go
Normal 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
1
watchlist.json
Normal file
@@ -0,0 +1 @@
|
||||
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA"]
|
||||
139
xml.go
Normal file
139
xml.go
Normal 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{}
|
||||
}
|
||||
Reference in New Issue
Block a user