first commit

This commit is contained in:
Gregory Salaun
2025-10-04 01:16:27 +02:00
commit 7fbecf7cee
22 changed files with 20141 additions and 0 deletions

0
.vscode/launch.json vendored Normal file
View File

12
Dockerfile Normal file
View File

@@ -0,0 +1,12 @@
FROM golang:1.23.1
WORKDIR /app
COPY go.mod go.sum ./
COPY config.go config.yml database.go flexradio.go spot.go main.go TCPClient.go TCPServer.go utils.go log.go xml.go ./
COPY templates/* .
RUN go build -o bin main.go
ENTRYPOINT ["/app/bin"]

209
TCPClient.go Normal file
View File

@@ -0,0 +1,209 @@
package main
import (
"bufio"
"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
}
func NewTCPClient(TCPServer *TCPServer, Countries Countries) *TCPClient {
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,
}
}
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) StartClient() {
var err error
c.setDefaultParams()
c.Conn, err = net.Dial("tcp", c.Address+":"+c.Port)
if err != nil {
Log.Error("Cannot connect to Telnet Client:", err)
}
c.Reader = bufio.NewReader(c.Conn)
c.Writer = bufio.NewWriter(c.Conn)
go func() {
for message := range c.TCPServer.CmdChan {
Log.Infof("Received Command: %s", message)
c.Write([]byte(message + "\r\n"))
}
}()
go c.ReadLine()
}
func (c *TCPClient) Close() {
c.Writer.Write([]byte("bye\r\n"))
}
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() {
for {
if !c.LoggedIn {
message, err := c.Reader.ReadBytes(':')
if err != nil {
Log.Errorf("Error reading message: %s", err)
c.Conn.Close()
c.StartClient()
}
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 {
message, err := c.Reader.ReadBytes('\n')
messageString := string(message)
if messageString != "" {
if err != nil {
Log.Errorf("Error reading message: %s", err)
c.Conn.Close()
c.StartClient()
}
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, "Error reading from server: read tcp") {
Log.Error("Disconnected from Telnet Server, reconnecting")
c.Close()
c.StartClient()
}
if strings.Contains(messageString, "DX") {
ProcessTelnetSpot(spotRe, messageString, c.SpotChanToFlex, c.SpotChanToHTTPServer, c.Countries)
}
// Send the spot message to TCP server
c.MsgChan <- messageString
}
}
}
}
// Write sends raw data to remove telnet server
func (c *TCPClient) Write(data []byte) (n int, err error) {
Mutex.Lock()
n, err = c.Writer.Write(data)
if err != nil {
Log.Errorf("Error while sending command to telnet client: %s", err)
} else {
err = c.Writer.Flush()
}
Mutex.Unlock()
return
}

139
TCPServer.go Normal file
View File

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

111
config.go Normal file
View File

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

57
config.yml Normal file
View File

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

16888
country.xml Normal file

File diff suppressed because one or more lines are too long

470
database.go Normal file
View File

@@ -0,0 +1,470 @@
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 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 *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")
}
}

378
flexradio.go Normal file
View File

@@ -0,0 +1,378 @@
package main
import (
"bufio"
"fmt"
"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
Comment string
Color string
BackgroundColor string
NewDXCC bool
NewBand bool
NewMode bool
NewSlot bool
Worked bool
CountryName string
DXCC string // Added DXCC field
}
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
IsConnected bool
}
func NewFlexClient(repo FlexDXClusterRepository, TCPServer *TCPServer, SpotChanToFlex chan TelnetSpot) *FlexClient {
return &FlexClient{
Port: "4992",
SpotChanToFlex: SpotChanToFlex,
MsgChan: TCPServer.MsgChan,
Repo: repo,
TCPServer: TCPServer,
IsConnected: false,
}
}
func (fc *FlexClient) StartFlexClient() {
if Cfg.Flex.IP == "" && !Cfg.Flex.Discover {
Log.Errorln("You must either turn FlexRadio Discovery on or provide an IP address for the Flex")
} else if Cfg.Flex.Discover {
ok, d := DiscoverFlexRadio()
if ok {
fc.Address = d.IP
Log.Infof("Found: %s with Nick: %s, Version: %s, Serial: %s - using IP: %s", d.Model, d.NickName, d.Version, d.Serial, d.IP)
} else {
Log.Errorln("Could not discover any FlexRadio on the network, please provide an IP address in the config file.")
fc.StartFlexClient()
}
} else if Cfg.Flex.IP != "" {
fc.Address = Cfg.Flex.IP
}
if fc.Address != "" {
addr, err := net.ResolveTCPAddr("tcp", fc.Address+":"+fc.Port)
if err != nil {
Log.Error("Cannot resolve Telnet Client address")
}
fc.LogWriter = bufio.NewWriter(os.Stdout)
fc.Timeout = 600 * time.Second
Log.Debugf("Trying to connect to FlexRadio at %s:%s", fc.Address, fc.Port)
fc.Conn, err = net.DialTCP("tcp", nil, addr)
if err != nil {
Log.Errorf("Could not connect to FlexRadio on %s", Cfg.Flex.IP)
Log.Error("Retrying to connect to FlexRadio in 5 seconds")
time.Sleep(time.Second * 5)
fc.StartFlexClient()
}
Log.Infof("Connected to FlexRadio at %s:%s", fc.Address, fc.Port)
fc.IsConnected = true
go func() {
for message := range fc.SpotChanToFlex {
fc.SendSpottoFlex(message)
}
}()
fc.Reader = bufio.NewReader(fc.Conn)
fc.Writer = bufio.NewWriter(fc.Conn)
err = fc.Conn.SetKeepAlive(true)
if err != nil {
Log.Error("error while setting keep alive")
}
go fc.ReadLine()
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 spot on FlexRadio and Deleted all spots from panadapter")
} else {
Log.Errorln("You must either turn FlexRadio Discovery on or provide an IP address for the Flex")
}
}
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,
Comment: spot.Comment,
Color: "#ffeaeaea",
BackgroundColor: "#ff000000",
Priority: "5",
NewDXCC: spot.NewDXCC,
NewBand: spot.NewBand,
NewMode: spot.NewMode,
NewSlot: spot.NewSlot,
Worked: spot.CallsignWorked,
CountryName: spot.CountryName,
DXCC: spot.DXCC,
}
flexSpot.Comment = flexSpot.Comment + " [" + flexSpot.Mode + "] [" + flexSpot.SpotterCallsign + "] [" + flexSpot.UTCTime + "]"
// 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: ", err)
}
var stringSpot string
if srcFlexSpot.DX == "" {
fc.Repo.CreateSpot(flexSpot)
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++
} else if srcFlexSpot.DX != "" && srcFlexSpot.Band == flexSpot.Band {
fc.Repo.DeleteSpotByFlexSpotNumber(string(flexSpot.FlexSpotNumber))
stringSpot = fmt.Sprintf("C%v|spot remove %v", flexSpot.CommandNumber, srcFlexSpot.FlexSpotNumber)
fc.SendSpot(stringSpot)
CommandNumber++
fc.Repo.CreateSpot(flexSpot)
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++
} else if srcFlexSpot.DX != "" && srcFlexSpot.Band != flexSpot.Band {
fc.Repo.CreateSpot(flexSpot)
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) {
fc.Write(stringSpot)
}
func (fc *FlexClient) ReadLine() {
for {
message, err := fc.Reader.ReadString(byte('\n'))
if err != nil {
Log.Errorf("Error reading message from FlexRadio, closing program: %s", err)
os.Exit(1)
}
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])
}
}
}
// Write sends raw data to remove telnet server
func (fc *FlexClient) Write(data string) (n int, err error) {
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: ", err)
}
buf := make([]byte, 1024)
for {
n, _, err := pc.ReadFrom(buf)
if err != nil {
Log.Errorln("Could not read data on UDP port 4992")
}
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
}

24
go.mod Normal file
View File

@@ -0,0 +1,24 @@
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/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
)

117
go.sum Normal file
View File

@@ -0,0 +1,117 @@
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/hpcloud/tail v1.0.0/go.mod h1:ab1qPbhIpdTxEkNHXyeSf5vhxWSCs/tWer42PpOxQnU=
github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
github.com/mattn/go-isatty v0.0.16 h1:bq3VjFmv/sOjHtdEhmkEV4x1AJtvUvOJ2PFAZ5+peKQ=
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
github.com/mattn/go-sqlite3 v1.14.23 h1:gbShiuAP1W5j9UOksQ06aiiqPMxYecovVGwmTxWtuw0=
github.com/mattn/go-sqlite3 v1.14.23/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d h1:5PJl274Y63IEHC+7izoQE9x6ikvDFZS2mDVS3drnohI=
github.com/mgutz/ansi v0.0.0-20200706080929-d51e80ef957d/go.mod h1:01TrycV0kFyexm33Z7vhZRXopbI8J3TDReVlkTgMUxE=
github.com/nxadm/tail v1.4.4/go.mod h1:kenIhsEOeOJmVchQTgglprH7qJGnHDVpk1VPCcaMI8A=
github.com/nxadm/tail v1.4.8 h1:nPr65rt6Y5JFSKQO7qToXr7pePgD6Gwiw05lkbyAQTE=
github.com/nxadm/tail v1.4.8/go.mod h1:+ncqLTQzXmGhMZNUePPaPqPvBxHAIsmXswZKocGu+AU=
github.com/onsi/ginkgo v1.6.0/go.mod h1:lLunBs/Ym6LB5Z9jYTR76FiuTmxDTDusOGeTQH+WWjE=
github.com/onsi/ginkgo v1.12.1/go.mod h1:zj2OWP4+oCPe1qIXoGWkgMRwljMUYCdkwsT2108oapk=
github.com/onsi/ginkgo v1.16.5 h1:8xi0RTUf59SOSfEtZMvwTvXYMzG4gV23XVHOZiXNtnE=
github.com/onsi/ginkgo v1.16.5/go.mod h1:+E8gABHa3K6zRBolWtd+ROzc/U5bkGt0FwiG042wbpU=
github.com/onsi/gomega v1.7.1/go.mod h1:XdKZgCCFLUoM/7CFJVPcG8C1xQ1AJ0vpAezJrB7JYyY=
github.com/onsi/gomega v1.10.1/go.mod h1:iN09h71vgCQne3DLsj+A5owkum+a2tYe+TOCB1ybHNo=
github.com/onsi/gomega v1.35.1 h1:Cwbd75ZBPxFSuZ6T+rN/WCb/gOc6YgFBXLlZLhC7Ds4=
github.com/onsi/gomega v1.35.1/go.mod h1:PvZbdDc8J6XJEpDK4HCuRBm8a6Fzp9/DmhC9C7yFlog=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.5.1/go.mod h1:5W2xD1RspED5o8YsWQXVCued0rvSQ+mT+I5cxcmMvtA=
github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2 h1:00txxvfBM9muc0jiLIEAkAcIMJzfthRT6usrui8uGmg=
github.com/x-cray/logrus-prefixed-formatter v0.5.2/go.mod h1:2duySbKsL6M18s5GU7VPsoEPHyzalCE06qoARUCeBBE=
github.com/yuin/goldmark v1.2.1/go.mod h1:3hX8gzYuyVAZsxl0MRgGTJEmQBFcNTphYh9decYSb74=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20191011191535-87dc89f01550/go.mod h1:yigFU9vqHzYiE8UmvKecakEJjdnWj3jj499lnFckfCI=
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
golang.org/x/net v0.0.0-20180906233101-161cd47e91fd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20200520004742-59133d7f0dd7/go.mod h1:qpuaurCH72eLCgpAm/N6yyVIVM9cpaDIP3A8BGJEC5A=
golang.org/x/net v0.0.0-20201021035429-f5854403a974/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
golang.org/x/net v0.30.0 h1:AcW1SDZMkb8IpzCdQUaIq2sP4sZ4zw+55h6ynffypl4=
golang.org/x/net v0.30.0/go.mod h1:2wGyMJ5iFasEhkwi13ChkO/t1ECNC4X4eBKkVFyYFlU=
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20201020160332-67f06af15bc9/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sys v0.0.0-20180909124046-d0be0721c37e/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20190904154756-749cb33beabd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191005200804-aed5e4c7ecf9/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20191120155948-bd437916bb0e/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200323222414-85ca7c5b95cd/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210112080510-489259a85091/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.26.0 h1:KHjCJyddX0LoSTb3J+vWpupP9p0oznkqVk/IfjymZbo=
golang.org/x/sys v0.26.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.19.0 h1:kTxAhCbGbxhK0IwgSKiMO5awPoDQ0RpfiVYBfK860YM=
golang.org/x/text v0.19.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
golang.org/x/tools v0.0.0-20201224043029-2b0845dc783e/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191011141410-1b5146add898/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
golang.org/x/xerrors v0.0.0-20200804184101-5ec99f83aff1/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/fsnotify.v1 v1.4.7/go.mod h1:Tz8NjZHkW78fSQdbUxIjBTcgA1z1m8ZHf0WmKUhAMys=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7 h1:uRGJdciOHaEIrze2W8Q3AKkepLTh2hOroT7a+7czfdQ=
gopkg.in/tomb.v1 v1.0.0-20141024135613-dd632973f1e7/go.mod h1:dt/ZhP58zS4L8KSrWDmTeBkI65Dw0HsyUHuEVlX15mw=
gopkg.in/yaml.v2 v2.2.2/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.2.4/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.3.0/go.mod h1:hI93XBmqTisBFMUTm0b8Fm+jr3Dg1NNxqwp+5A1VGuI=
gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY=
gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=

89
gotify.go Normal file
View File

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

298
httpserver.go Normal file
View File

@@ -0,0 +1,298 @@
package main
import (
"encoding/json"
"net/http"
"os"
"sync"
"time"
"github.com/gorilla/mux"
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
}
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"`
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"`
}
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,
}
server.setupRoutes()
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("/send-command", s.sendCommand).Methods("POST", "OPTIONS")
api.HandleFunc("/filters", s.updateFilters).Methods("POST", "OPTIONS")
api.HandleFunc("/shutdown", s.shutdownApp).Methods("POST", "OPTIONS")
// Serve static files (dashboard)
s.Router.PathPrefix("/").Handler(http.FileServer(http.Dir("./static")))
}
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) {
// Cache stats for 5 seconds to avoid overwhelming the database
s.statsMutex.RLock()
if time.Since(s.lastUpdate) < 5*time.Second {
stats := s.statsCache
s.statsMutex.RUnlock()
s.sendJSON(w, APIResponse{Success: true, Data: stats})
return
}
s.statsMutex.RUnlock()
// Calculate fresh stats
s.statsMutex.Lock()
defer s.statsMutex.Unlock()
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"
}
s.statsCache = Stats{
TotalSpots: len(allSpots),
ActiveSpotters: len(spotters),
NewDXCC: newDXCCCount,
ConnectedClients: len(s.TCPServer.Clients),
TotalContacts: contacts,
ClusterStatus: clusterStatus,
FlexStatus: flexStatus,
Filters: Filters{
Skimmer: Cfg.Cluster.Skimmer,
FT8: Cfg.Cluster.FT8,
FT4: Cfg.Cluster.FT4,
},
}
s.lastUpdate = time.Now()
s.sendJSON(w, APIResponse{Success: true, Data: s.statsCache})
}
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 CommandRequest struct {
Command string `json:"command"`
}
func (s *HTTPServer) sendCommand(w http.ResponseWriter, r *http.Request) {
var req CommandRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"})
return
}
if req.Command == "" {
s.sendJSON(w, APIResponse{Success: false, Error: "Command is required"})
return
}
// Send command to cluster via TCPClient
s.TCPClient.CmdChan <- req.Command
s.Log.Infof("Command sent via HTTP API: %s", req.Command)
s.sendJSON(w, APIResponse{Success: true, Data: map[string]string{"message": "Command sent successfully"}})
}
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) 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"}})
// Shutdown gracefully after a short delay to allow response to be sent
go func() {
time.Sleep(500 * time.Millisecond)
s.Log.Info("Initiating shutdown...")
os.Exit(0)
}()
}
func (s *HTTPServer) Start() {
s.Log.Infof("HTTP Server starting on port %s", s.Port)
s.Log.Infof("Dashboard available at http://localhost:%s", s.Port)
if err := http.ListenAndServe(":"+s.Port, s.Router); err != nil {
s.Log.Fatalf("Failed to start HTTP server: %v", err)
}
}

BIN
images/FlexDXCluster.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 202 KiB

BIN
images/background.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 MiB

72
log.go Normal file
View File

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

91
main.go Normal file
View File

@@ -0,0 +1,91 @@
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 1.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)
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)
FlexClient := NewFlexClient(*fRepo, TCPServer, TCPClient.SpotChanToFlex)
// Initialize HTTP Server for Dashboard
HTTPServer := NewHTTPServer(fRepo, cRepo, TCPServer, TCPClient, FlexClient, "8080")
// Start all services
go FlexClient.StartFlexClient()
go TCPClient.StartClient()
go TCPServer.StartServer()
go HTTPServer.Start()
log.Infof("Telnet Server: %s:%s", cfg.TelnetServer.Host, cfg.TelnetServer.Port)
log.Infof("Cluster: %s:%s", cfg.Cluster.Server, cfg.Cluster.Port)
CheckSignal(TCPClient, TCPServer, FlexClient, fRepo, cRepo)
}

BIN
rsrc_windows_amd64.syso Normal file

Binary file not shown.

395
spot.go Normal file
View File

@@ -0,0 +1,395 @@
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) {
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
contactRepo := NewLog4OMContactsRepository(Cfg.SQLite.SQLitePath)
defer contactRepo.db.Close()
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)
}
}

582
static/index.html Normal file
View File

@@ -0,0 +1,582 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>FlexDXCluster Dashboard</title>
<script src="https://cdn.tailwindcss.com"></script>
<style>
body, html {
margin: 0;
padding: 0;
overflow: hidden;
height: 100vh;
width: 100vw;
}
.animate-spin { animation: spin 1s linear infinite; }
@keyframes spin { from { transform: rotate(0deg); } to { transform: rotate(360deg); } }
.animate-pulse { animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; }
@keyframes pulse { 0%, 100% { opacity: 1; } 50% { opacity: .5; } }
.full-height { height: 100vh; display: flex; flex-direction: column; }
.scrollable { overflow-y: auto; flex: 1; }
::-webkit-scrollbar { width: 8px; }
::-webkit-scrollbar-track { background: rgb(15 23 42); }
::-webkit-scrollbar-thumb { background: rgb(51 65 85); border-radius: 4px; }
::-webkit-scrollbar-thumb:hover { background: rgb(71 85 105); }
</style>
</head>
<body class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white">
<div id="app" class="full-height p-4"></div>
<script>
const API_BASE_URL = 'http://localhost:8080/api';
let state = {
spots: [],
filteredSpots: [],
stats: {
totalSpots: 0,
activeSpotters: 0,
newDXCC: 0,
connectedClients: 0,
totalContacts: 0,
clusterStatus: 'disconnected',
flexStatus: 'disconnected',
filters: { skimmer: false, ft8: false, ft4: false }
},
topSpotters: [],
loading: true,
command: '',
spotFilters: {
showAll: true,
showNewDXCC: false,
showNewBand: false,
showNewMode: false,
showNewBandMode: false,
showNewSlot: false,
showWorked: false,
band160M: false,
band80M: false,
band60M: false,
band40M: false,
band30M: false,
band20M: false,
band17M: false,
band15M: false,
band12M: false,
band10M: false,
band6M: false
}
};
async function fetchData() {
try {
const [statsRes, spotsRes, spottersRes] = await Promise.all([
fetch(`${API_BASE_URL}/stats`),
fetch(`${API_BASE_URL}/spots?limit=100`),
fetch(`${API_BASE_URL}/spotters`)
]);
const statsData = await statsRes.json();
const spotsData = await spotsRes.json();
const spottersData = await spottersRes.json();
if (statsData.success) state.stats = statsData.data;
if (spotsData.success) {
state.spots = spotsData.data || [];
applySpotFilters();
}
if (spottersData.success) state.topSpotters = spottersData.data || [];
state.loading = false;
render();
} catch (error) {
console.error('Error fetching data:', error);
state.loading = false;
render();
}
}
function applySpotFilters() {
if (state.spotFilters.showAll) {
state.filteredSpots = state.spots;
return;
}
const bandFiltersActive = state.spotFilters.band160M || state.spotFilters.band80M ||
state.spotFilters.band60M || state.spotFilters.band40M || state.spotFilters.band30M ||
state.spotFilters.band20M || state.spotFilters.band17M || state.spotFilters.band15M ||
state.spotFilters.band12M || state.spotFilters.band10M || state.spotFilters.band6M;
const typeFiltersActive = state.spotFilters.showNewDXCC || state.spotFilters.showNewBand ||
state.spotFilters.showNewMode || state.spotFilters.showNewBandMode ||
state.spotFilters.showNewSlot || state.spotFilters.showWorked;
state.filteredSpots = state.spots.filter(spot => {
let matchesBand = false;
let matchesType = false;
if (bandFiltersActive) {
matchesBand = (
(state.spotFilters.band160M && spot.Band === '160M') ||
(state.spotFilters.band80M && spot.Band === '80M') ||
(state.spotFilters.band60M && spot.Band === '60M') ||
(state.spotFilters.band40M && spot.Band === '40M') ||
(state.spotFilters.band30M && spot.Band === '30M') ||
(state.spotFilters.band20M && spot.Band === '20M') ||
(state.spotFilters.band17M && spot.Band === '17M') ||
(state.spotFilters.band15M && spot.Band === '15M') ||
(state.spotFilters.band12M && spot.Band === '12M') ||
(state.spotFilters.band10M && spot.Band === '10M') ||
(state.spotFilters.band6M && spot.Band === '6M')
);
}
if (typeFiltersActive) {
if (state.spotFilters.showNewDXCC) {
const isNewDXCC = !!spot.NewDXCC;
const isNewBand = !!spot.NewBand;
const isNewMode = !!spot.NewMode;
if (isNewDXCC && !(isNewBand && isNewMode)) {
matchesType = true;
}
}
if (state.spotFilters.showNewBandMode && !!spot.NewBand && !!spot.NewMode) matchesType = true;
if (state.spotFilters.showNewBand && !!spot.NewBand && !spot.NewMode) matchesType = true;
if (state.spotFilters.showNewMode && !!spot.NewMode && !spot.NewBand) matchesType = true;
if (state.spotFilters.showNewSlot && !!spot.NewSlot && !spot.NewDXCC && !spot.NewBand && !spot.NewMode) matchesType = true;
if (state.spotFilters.showWorked && !!spot.Worked) matchesType = true;
}
if (bandFiltersActive && !typeFiltersActive) {
return matchesBand;
} else if (typeFiltersActive && !bandFiltersActive) {
return matchesType;
} else if (bandFiltersActive && typeFiltersActive) {
return matchesBand && matchesType;
}
return false;
});
}
function toggleSpotFilter(filterName) {
if (filterName === 'showAll') {
state.spotFilters = {
showAll: true,
showNewDXCC: false,
showNewBand: false,
showNewMode: false,
showNewBandMode: false,
showNewSlot: false,
showWorked: false,
band160M: false,
band80M: false,
band60M: false,
band40M: false,
band30M: false,
band20M: false,
band17M: false,
band15M: false,
band12M: false,
band10M: false,
band6M: false
};
} else {
state.spotFilters.showAll = false;
state.spotFilters[filterName] = !state.spotFilters[filterName];
const anyActive = Object.keys(state.spotFilters).some(key =>
key !== 'showAll' && state.spotFilters[key]
);
if (!anyActive) {
state.spotFilters.showAll = true;
}
}
applySpotFilters();
render();
}
async function sendCommand() {
const command = state.command.trim();
if (!command) return;
try {
const response = await fetch(`${API_BASE_URL}/send-command`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ command })
});
const data = await response.json();
if (data.success) {
state.command = '';
alert('Command sent successfully!');
render();
}
} catch (error) {
console.error('Error sending command:', error);
alert('Failed to send command');
}
}
async function updateFilter(filterName, value) {
try {
const response = await fetch(`${API_BASE_URL}/filters`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ [filterName]: value })
});
const data = await response.json();
if (data.success) {
state.stats.filters[filterName] = value;
fetchData();
}
} catch (error) {
console.error('Error updating filters:', error);
}
}
async function shutdownApp() {
// if (!confirm('Are you sure you want to shutdown FlexDXCluster?')) {
// return;
// }
try {
const response = await fetch(`${API_BASE_URL}/shutdown`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' }
});
const data = await response.json();
if (data.success) {
// alert('FlexDXCluster is shutting down...');
// Redirect to a shutdown page or close
document.body.innerHTML = '<div class="min-h-screen flex items-center justify-center bg-slate-900 text-white"><div class="text-center"><h1 class="text-4xl font-bold mb-4">FlexDXCluster Stopped</h1><p class="text-slate-400">The application has been shut down successfully.</p></div></div>';
}
} catch (error) {
console.error('Error shutting down:', error);
alert('Failed to shutdown application');
}
}
function getPriorityColor(spot) {
if (spot.NewDXCC) return 'bg-green-500/20 text-green-400 border-green-500/50';
if (spot.NewBand && spot.NewMode) return 'bg-purple-500/20 text-purple-400 border-purple-500/50';
if (spot.NewBand) return 'bg-yellow-500/20 text-yellow-400 border-yellow-500/50';
if (spot.NewMode) return 'bg-orange-500/20 text-orange-400 border-orange-500/50';
if (spot.Worked) return 'bg-cyan-500/20 text-cyan-400 border-cyan-500/50';
return 'bg-gray-500/20 text-gray-400 border-gray-500/50';
}
function getStatusLabel(spot) {
if (spot.NewDXCC) return 'New DXCC';
if (spot.NewBand && spot.NewMode) return 'New B&M';
if (spot.NewBand) return 'New Band';
if (spot.NewMode) return 'New Mode';
if (spot.NewSlot) return 'New Slot';
if (spot.Worked) return 'Worked';
return '';
}
function countSpotsByType(type) {
switch(type) {
case 'newDXCC': return state.spots.filter(s => s.NewDXCC).length;
case 'newBand': return state.spots.filter(s => s.NewBand && !s.NewMode).length;
case 'newMode': return state.spots.filter(s => s.NewMode && !s.NewBand).length;
case 'newBandMode': return state.spots.filter(s => s.NewBand && s.NewMode).length;
case 'newSlot': return state.spots.filter(s => s.NewSlot && !s.NewDXCC && !s.NewBand && !s.NewMode).length;
case 'worked': return state.spots.filter(s => s.Worked).length;
case '160M': return state.spots.filter(s => s.Band === '160M').length;
case '80M': return state.spots.filter(s => s.Band === '80M').length;
case '60M': return state.spots.filter(s => s.Band === '60M').length;
case '40M': return state.spots.filter(s => s.Band === '40M').length;
case '30M': return state.spots.filter(s => s.Band === '30M').length;
case '20M': return state.spots.filter(s => s.Band === '20M').length;
case '17M': return state.spots.filter(s => s.Band === '17M').length;
case '15M': return state.spots.filter(s => s.Band === '15M').length;
case '12M': return state.spots.filter(s => s.Band === '12M').length;
case '10M': return state.spots.filter(s => s.Band === '10M').length;
case '6M': return state.spots.filter(s => s.Band === '6M').length;
default: return state.spots.length;
}
}
function render() {
const app = document.getElementById('app');
if (state.loading) {
app.innerHTML = `
<div class="h-full flex items-center justify-center">
<div class="text-center">
<div class="w-12 h-12 border-4 border-blue-400 border-t-transparent rounded-full animate-spin mx-auto mb-4"></div>
<p class="text-slate-400">Loading dashboard...</p>
</div>
</div>
`;
return;
}
app.innerHTML = `
<div class="flex items-center justify-between mb-3">
<div class="flex items-center gap-3">
<svg class="w-8 h-8 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.348 14.651a3.75 3.75 0 010-5.303m5.304 0a3.75 3.75 0 010 5.303m-7.425 2.122a6.75 6.75 0 010-9.546m9.546 0a6.75 6.75 0 010 9.546M5.106 18.894c-3.808-3.808-3.808-9.98 0-13.789m13.788 0c3.808 3.808 3.808 9.981 0 13.79M12 12h.008v.007H12V12zm.375 0a.375.375 0 11-.75 0 .375.375 0 01.75 0z" />
</svg>
<div>
<h1 class="text-2xl font-bold bg-gradient-to-r from-blue-400 to-purple-400 bg-clip-text text-transparent">FlexDXCluster</h1>
<p class="text-xs text-slate-400">F4BPO • ${state.stats.totalContacts} Contacts</p>
</div>
</div>
<div class="flex items-center gap-3">
<div class="flex items-center gap-2 text-xs">
<div class="w-2 h-2 rounded-full ${state.stats.clusterStatus === 'connected' ? 'bg-green-500 animate-pulse' : 'bg-red-500'}"></div>
<span class="text-slate-400">Cluster</span>
</div>
<div class="flex items-center gap-2 text-xs">
<div class="w-2 h-2 rounded-full ${state.stats.flexStatus === 'connected' ? 'bg-green-500 animate-pulse' : 'bg-red-500'}"></div>
<span class="text-slate-400">Flex</span>
</div>
<button onclick="shutdownApp()" class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
Shutdown
</button>
</div>
</div>
<div class="grid grid-cols-4 gap-3 mb-3">
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-blue-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
<div class="text-xl font-bold text-blue-400">${state.stats.totalSpots}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Total Spots</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3.055 11H5a2 2 0 012 2v1a2 2 0 002 2 2 2 0 012 2v2.945M8 3.935V5.5A2.5 2.5 0 0010.5 8h.5a2 2 0 012 2 2 2 0 104 0 2 2 0 012-2h1.064M15 20.488V18a2 2 0 012-2h3.064M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
</svg>
<div class="text-xl font-bold text-green-400">${state.stats.newDXCC}</div>
</div>
<p class="text-xs text-slate-400 mt-1">New DXCC</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-purple-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
<div class="text-xl font-bold text-purple-400">${state.stats.activeSpotters}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Spotters</p>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex items-center justify-between">
<svg class="w-6 h-6 text-orange-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8.111 16.404a5.5 5.5 0 017.778 0M12 20h.01m-7.08-7.071c3.904-3.905 10.236-3.905 14.141 0M1.394 9.393c5.857-5.857 15.355-5.857 21.213 0" />
</svg>
<div class="text-xl font-bold text-orange-400">${state.stats.connectedClients}</div>
</div>
<p class="text-xs text-slate-400 mt-1">Clients</p>
</div>
</div>
<div class="grid grid-cols-2 gap-3 mb-3">
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex gap-2 items-center">
<input id="commandInput" type="text" value="${state.command}" placeholder="Command..."
class="flex-1 px-3 py-1.5 text-sm bg-slate-900/50 border border-slate-600 rounded focus:outline-none focus:border-blue-500 text-white" />
<button onclick="sendCommand()" class="px-4 py-1.5 text-sm bg-blue-600 hover:bg-blue-700 rounded transition-colors">Send</button>
</div>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50">
<div class="flex gap-3 text-sm">
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" ${state.stats.filters.skimmer ? 'checked' : ''}
onchange="updateFilter('skimmer', this.checked)" class="w-4 h-4 rounded" />
<span>Skimmer</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" ${state.stats.filters.ft8 ? 'checked' : ''}
onchange="updateFilter('ft8', this.checked)" class="w-4 h-4 rounded" />
<span>FT8</span>
</label>
<label class="flex items-center gap-1.5 cursor-pointer">
<input type="checkbox" ${state.stats.filters.ft4 ? 'checked' : ''}
onchange="updateFilter('ft4', this.checked)" class="w-4 h-4 rounded" />
<span>FT4</span>
</label>
</div>
</div>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg p-3 border border-slate-700/50 mb-3">
<div class="grid grid-cols-2 gap-3">
<div>
<h3 class="text-xs font-bold text-slate-400 mb-2">TYPE FILTERS</h3>
<div class="flex flex-wrap gap-2">
<button onclick="toggleSpotFilter('showAll')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showAll ? 'bg-blue-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
All (${state.spots.length})
</button>
<button onclick="toggleSpotFilter('showNewDXCC')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showNewDXCC ? 'bg-green-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
DXCC (${countSpotsByType('newDXCC')})
</button>
<button onclick="toggleSpotFilter('showNewBandMode')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showNewBandMode ? 'bg-purple-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
B&M (${countSpotsByType('newBandMode')})
</button>
<button onclick="toggleSpotFilter('showNewBand')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showNewBand ? 'bg-yellow-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Band (${countSpotsByType('newBand')})
</button>
<button onclick="toggleSpotFilter('showNewMode')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showNewMode ? 'bg-orange-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Mode (${countSpotsByType('newMode')})
</button>
<button onclick="toggleSpotFilter('showNewSlot')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showNewSlot ? 'bg-sky-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Slot (${countSpotsByType('newSlot')})
</button>
<button onclick="toggleSpotFilter('showWorked')"
class="px-3 py-1 text-sm rounded transition-colors ${state.spotFilters.showWorked ? 'bg-cyan-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
Worked (${countSpotsByType('worked')})
</button>
</div>
</div>
<div>
<h3 class="text-xs font-bold text-slate-400 mb-2">BAND FILTERS</h3>
<div class="flex flex-wrap gap-2">
<button onclick="toggleSpotFilter('band160M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band160M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
160M (${countSpotsByType('160M')})
</button>
<button onclick="toggleSpotFilter('band80M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band80M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
80M (${countSpotsByType('80M')})
</button>
<button onclick="toggleSpotFilter('band60M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band60M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
60M (${countSpotsByType('60M')})
</button>
<button onclick="toggleSpotFilter('band40M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band40M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
40M (${countSpotsByType('40M')})
</button>
<button onclick="toggleSpotFilter('band30M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band30M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
30M (${countSpotsByType('30M')})
</button>
<button onclick="toggleSpotFilter('band20M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band20M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
20M (${countSpotsByType('20M')})
</button>
<button onclick="toggleSpotFilter('band17M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band17M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
17M (${countSpotsByType('17M')})
</button>
<button onclick="toggleSpotFilter('band15M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band15M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
15M (${countSpotsByType('15M')})
</button>
<button onclick="toggleSpotFilter('band12M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band12M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
12M (${countSpotsByType('12M')})
</button>
<button onclick="toggleSpotFilter('band10M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band10M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
10M (${countSpotsByType('10M')})
</button>
<button onclick="toggleSpotFilter('band6M')"
class="px-2 py-1 text-xs rounded transition-colors ${state.spotFilters.band6M ? 'bg-indigo-600 text-white' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}">
6M (${countSpotsByType('6M')})
</button>
</div>
</div>
</div>
</div>
<div class="grid grid-cols-4 gap-3" style="height: calc(100vh - 360px);">
<div class="col-span-3 bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden">
<div class="p-3 border-b border-slate-700/50">
<h2 class="text-lg font-bold">Recent Spots (${state.filteredSpots.length})</h2>
</div>
<div class="scrollable">
<table class="w-full">
<thead class="bg-slate-900/50 sticky top-0">
<tr class="text-left text-xs text-slate-400">
<th class="p-2">DX</th>
<th class="p-2">Freq</th>
<th class="p-2">Band</th>
<th class="p-2">Mode</th>
<th class="p-2">Spotter</th>
<th class="p-2">Time</th>
<th class="p-2">Country</th>
<th class="p-2">Status</th>
</tr>
</thead>
<tbody>
${state.filteredSpots.map(spot => `
<tr class="border-b border-slate-700/30 hover:bg-slate-700/30 transition-colors text-sm">
<td class="p-2 font-bold text-blue-400">${spot.DX}</td>
<td class="p-2 font-mono text-xs">${spot.FrequencyMhz}</td>
<td class="p-2"><span class="px-1.5 py-0.5 bg-slate-700/50 rounded text-xs">${spot.Band}</span></td>
<td class="p-2"><span class="px-1.5 py-0.5 bg-purple-500/20 text-purple-400 rounded text-xs">${spot.Mode}</span></td>
<td class="p-2 text-slate-300 text-xs">${spot.SpotterCallsign}</td>
<td class="p-2 text-slate-400 text-xs">${spot.UTCTime}</td>
<td class="p-2 text-xs">${spot.CountryName || 'N/A'}</td>
<td class="p-2">
${getStatusLabel(spot) ? `<span class="px-1.5 py-0.5 rounded text-xs font-semibold border ${getPriorityColor(spot)}">${getStatusLabel(spot)}</span>` : ''}
</td>
</tr>
`).join('')}
</tbody>
</table>
</div>
</div>
<div class="bg-slate-800/50 backdrop-blur rounded-lg border border-slate-700/50 flex flex-col overflow-hidden">
<div class="p-3 border-b border-slate-700/50">
<h2 class="text-lg font-bold">Top Spotters</h2>
</div>
<div class="scrollable p-3">
${state.topSpotters.map((spotter, index) => `
<div class="flex items-center justify-between mb-2 p-2 bg-slate-900/30 rounded hover:bg-slate-700/30 transition-colors">
<div class="flex items-center gap-2">
<div class="w-6 h-6 bg-gradient-to-br from-blue-500 to-purple-500 rounded-full flex items-center justify-center text-xs font-bold">
${index + 1}
</div>
<span class="text-sm font-semibold">${spotter.Spotter}</span>
</div>
<span class="text-slate-400 font-mono text-xs">${spotter.NumberofSpots}</span>
</div>
`).join('')}
</div>
</div>
</div>
`;
const commandInput = document.getElementById('commandInput');
if (commandInput) {
commandInput.addEventListener('input', (e) => {
state.command = e.target.value;
});
commandInput.addEventListener('keypress', (e) => {
if (e.key === 'Enter') sendCommand();
});
}
}
fetchData();
setInterval(fetchData, 1000);
</script>
</body>
</html>

70
utils.go Normal file
View File

@@ -0,0 +1,70 @@
package main
import (
"fmt"
"log"
"net"
"os"
"os/signal"
"strconv"
"syscall"
)
func FreqMhztoHz(freq string) string {
frequency, err := strconv.ParseFloat(freq, 64)
if err != nil {
log.Println("could not convert frequency string to int", err)
}
frequency = frequency / 1000
return strconv.FormatFloat(frequency, 'f', 6, 64)
}
func FreqHztoMhz(freq string) string {
frequency, err := strconv.ParseFloat(freq, 64)
if err != nil {
log.Println("could not convert frequency string to int", err)
}
frequency = frequency * 1000
return strconv.FormatFloat(frequency, 'f', 6, 64)
}
func CheckSignal(TCPClient *TCPClient, TCPServer *TCPServer, FlexClient *FlexClient, fRepo *FlexDXClusterRepository, cRepo *Log4OMContactsRepository) {
sigCh := make(chan os.Signal, 1)
signal.Notify(sigCh, syscall.SIGHUP, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGTERM)
// Gracely closing all connextions if signal is received
for sig := range sigCh {
Log.Infof("received signal: %v, shutting down all connections.", sig)
TCPClient.Close()
TCPServer.Conn.Close()
FlexClient.Conn.Close()
if err := fRepo.db.Close(); err != nil {
Log.Error("failed to close the database connection properly")
os.Exit(1)
}
if err := cRepo.db.Close(); err != nil {
Log.Error("failed to close Log4OM database connection properly")
os.Exit(1)
}
os.Exit(0)
}
}
func SendUDPMessage(message string) {
conn, err := net.Dial("udp", "127.0.0.1:2241")
if err != nil {
fmt.Printf("Some error %v", err)
return
}
conn.Write([]byte(message))
conn.Close()
}

139
xml.go Normal file
View File

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