first commit
This commit is contained in:
132
internal/bands/bands.go
Normal file
132
internal/bands/bands.go
Normal file
@@ -0,0 +1,132 @@
|
||||
package bands
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
type BandDefinition struct {
|
||||
Name string
|
||||
MinFreqMHz float64
|
||||
MaxFreqMHz float64
|
||||
UseUSB bool
|
||||
}
|
||||
|
||||
var AmateurBands = []BandDefinition{
|
||||
{Name: "160M", MinFreqMHz: 1.800, MaxFreqMHz: 2.000, UseUSB: false},
|
||||
{Name: "80M", MinFreqMHz: 3.500, MaxFreqMHz: 3.800, UseUSB: false},
|
||||
{Name: "60M", MinFreqMHz: 5.330, MaxFreqMHz: 5.405, UseUSB: false},
|
||||
{Name: "40M", MinFreqMHz: 7.000, MaxFreqMHz: 7.300, UseUSB: false},
|
||||
{Name: "30M", MinFreqMHz: 10.100, MaxFreqMHz: 10.150, UseUSB: true},
|
||||
{Name: "20M", MinFreqMHz: 14.000, MaxFreqMHz: 14.350, UseUSB: true},
|
||||
{Name: "17M", MinFreqMHz: 18.068, MaxFreqMHz: 18.168, UseUSB: true},
|
||||
{Name: "15M", MinFreqMHz: 21.000, MaxFreqMHz: 21.450, UseUSB: true},
|
||||
{Name: "12M", MinFreqMHz: 24.890, MaxFreqMHz: 24.990, UseUSB: true},
|
||||
{Name: "10M", MinFreqMHz: 28.000, MaxFreqMHz: 29.700, UseUSB: true},
|
||||
{Name: "6M", MinFreqMHz: 50.000, MaxFreqMHz: 54.000, UseUSB: true},
|
||||
{Name: "QO-100", MinFreqMHz: 10489.500, MaxFreqMHz: 10490.000, UseUSB: true},
|
||||
}
|
||||
|
||||
func FrequencyToBand(freqMHz float64) string {
|
||||
for _, band := range AmateurBands {
|
||||
if freqMHz >= band.MinFreqMHz && freqMHz < band.MaxFreqMHz {
|
||||
return band.Name
|
||||
}
|
||||
}
|
||||
return "N/A"
|
||||
}
|
||||
|
||||
// FrequencyKHzToBand convertit depuis kHz (format interne du projet)
|
||||
func FrequencyKHzToBand(freqKHz float64) string {
|
||||
return FrequencyToBand(freqKHz / 1000.0)
|
||||
}
|
||||
|
||||
func FrequencyStringToBand(freqStr string) string {
|
||||
freqMHz, err := strconv.ParseFloat(freqStr, 64)
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
return FrequencyToBand(freqMHz)
|
||||
}
|
||||
|
||||
func GetBandDefinition(bandName string) *BandDefinition {
|
||||
for _, band := range AmateurBands {
|
||||
if band.Name == bandName {
|
||||
return &band
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func IsUSBBand(bandName string) bool {
|
||||
band := GetBandDefinition(bandName)
|
||||
if band == nil {
|
||||
return false
|
||||
}
|
||||
return band.UseUSB
|
||||
}
|
||||
|
||||
func IsLSBBand(bandName string) bool {
|
||||
band := GetBandDefinition(bandName)
|
||||
if band == nil {
|
||||
return false
|
||||
}
|
||||
return !band.UseUSB
|
||||
}
|
||||
|
||||
func NormalizeSSBMode(mode string, band string) string {
|
||||
if mode != "SSB" {
|
||||
return mode
|
||||
}
|
||||
if IsUSBBand(band) {
|
||||
return "USB"
|
||||
}
|
||||
return "LSB"
|
||||
}
|
||||
|
||||
func NormalizeSSBModeByFrequency(mode string, freqMHz float64) string {
|
||||
if mode != "SSB" {
|
||||
return mode
|
||||
}
|
||||
band := FrequencyToBand(freqMHz)
|
||||
return NormalizeSSBMode(mode, band)
|
||||
}
|
||||
|
||||
func GetAllBandNames() []string {
|
||||
names := make([]string, len(AmateurBands))
|
||||
for i, band := range AmateurBands {
|
||||
names[i] = band.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
func IsBandValid(bandName string) bool {
|
||||
return GetBandDefinition(bandName) != nil
|
||||
}
|
||||
|
||||
func GetBandFrequencyRange(bandName string) string {
|
||||
band := GetBandDefinition(bandName)
|
||||
if band == nil {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("%.3f - %.3f MHz", band.MinFreqMHz, band.MaxFreqMHz)
|
||||
}
|
||||
|
||||
// FreqMHzString formate une fréquence kHz en string MHz pour FlexRadio
|
||||
func FreqMHzString(freqKHz float64) string {
|
||||
return fmt.Sprintf("%.6f", freqKHz/1000.0)
|
||||
}
|
||||
|
||||
// GetBandFromFrequencyString gère kHz et MHz automatiquement
|
||||
func GetBandFromFrequencyString(freqStr string) string {
|
||||
freqStr = strings.TrimSpace(freqStr)
|
||||
freqMHz, err := strconv.ParseFloat(freqStr, 64)
|
||||
if err != nil {
|
||||
return "N/A"
|
||||
}
|
||||
if freqMHz > 1000 {
|
||||
freqMHz = freqMHz / 1000.0
|
||||
}
|
||||
return FrequencyToBand(freqMHz)
|
||||
}
|
||||
332
internal/cluster/client.go
Normal file
332
internal/cluster/client.go
Normal file
@@ -0,0 +1,332 @@
|
||||
package cluster
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"fmt"
|
||||
"math"
|
||||
"net"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/user/flexdxcluster2/internal/spot"
|
||||
)
|
||||
|
||||
const (
|
||||
MaxReconnectAttempts = 10
|
||||
BaseReconnectDelay = 1 * time.Second
|
||||
MaxReconnectDelay = 60 * time.Second
|
||||
ConnectionTimeout = 10 * time.Second
|
||||
ReadTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// Config reprend ClusterConfig de l'ancien code
|
||||
type Config struct {
|
||||
Name string
|
||||
Server string
|
||||
Port string
|
||||
Login string
|
||||
Password string
|
||||
Skimmer bool
|
||||
FT8 bool
|
||||
FT4 bool
|
||||
Beacon bool
|
||||
Command string
|
||||
LoginPrompt string
|
||||
Enabled bool
|
||||
Master bool
|
||||
Type string // dxspider, cc_cluster, ar_cluster — vide = auto
|
||||
}
|
||||
|
||||
// Client gère la connexion TCP à un cluster DX
|
||||
type Client struct {
|
||||
cfg Config
|
||||
conn net.Conn
|
||||
reader *bufio.Reader
|
||||
writer *bufio.Writer
|
||||
mu sync.Mutex
|
||||
loggedIn bool
|
||||
clusterType string
|
||||
ctx context.Context
|
||||
cancel context.CancelFunc
|
||||
reconnects int
|
||||
|
||||
// SpotChan reçoit les spots parsés — consommé par le SpotProcessor
|
||||
SpotChan chan *spot.Spot
|
||||
// ConsoleChan reçoit toutes les lignes brutes — pour l'onglet Console
|
||||
ConsoleChan chan string
|
||||
// CmdChan reçoit les commandes à envoyer au cluster depuis l'UI
|
||||
CmdChan chan string
|
||||
}
|
||||
|
||||
func New(cfg Config) *Client {
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
return &Client{
|
||||
cfg: cfg,
|
||||
clusterType: cfg.Type,
|
||||
ctx: ctx,
|
||||
cancel: cancel,
|
||||
SpotChan: make(chan *spot.Spot, 200),
|
||||
ConsoleChan: make(chan string, 200),
|
||||
CmdChan: make(chan string, 100),
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Name() string { return c.cfg.Name }
|
||||
func (c *Client) IsMaster() bool { return c.cfg.Master }
|
||||
func (c *Client) ClusterType() string { return c.clusterType }
|
||||
|
||||
// Start démarre le client avec reconnexion automatique
|
||||
func (c *Client) Start() {
|
||||
// Goroutine commandes UI → cluster
|
||||
go c.commandLoop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if err := c.connect(); err != nil {
|
||||
c.reconnects++
|
||||
if c.reconnects >= MaxReconnectAttempts {
|
||||
return
|
||||
}
|
||||
delay := c.backoff()
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case <-time.After(delay):
|
||||
continue
|
||||
}
|
||||
}
|
||||
|
||||
c.readLoop()
|
||||
c.loggedIn = false
|
||||
time.Sleep(2 * time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) Close() {
|
||||
c.cancel()
|
||||
if c.conn != nil {
|
||||
c.write([]byte("bye\r\n"))
|
||||
c.conn.Close()
|
||||
}
|
||||
}
|
||||
|
||||
// ReloadFilters renvoie les commandes de filtres au cluster
|
||||
func (c *Client) ReloadFilters() {
|
||||
if c.loggedIn {
|
||||
c.setFilters()
|
||||
}
|
||||
}
|
||||
|
||||
// SendCommand envoie une commande arbitraire au cluster depuis l'UI
|
||||
func (c *Client) SendCommand(cmd string) {
|
||||
select {
|
||||
case c.CmdChan <- cmd:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) connect() error {
|
||||
addr := c.cfg.Server + ":" + c.cfg.Port
|
||||
conn, err := net.DialTimeout("tcp", addr, ConnectionTimeout)
|
||||
if err != nil {
|
||||
return fmt.Errorf("connect %s: %w", addr, err)
|
||||
}
|
||||
c.conn = conn
|
||||
c.reader = bufio.NewReader(conn)
|
||||
c.writer = bufio.NewWriter(conn)
|
||||
c.loggedIn = false
|
||||
c.reconnects = 0
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) commandLoop() {
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
case cmd := <-c.CmdChan:
|
||||
c.write([]byte(cmd + "\r\n"))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) readLoop() {
|
||||
defer c.conn.Close()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-c.ctx.Done():
|
||||
return
|
||||
default:
|
||||
}
|
||||
|
||||
if !c.loggedIn {
|
||||
c.conn.SetReadDeadline(time.Now().Add(30 * time.Second))
|
||||
msg, err := c.reader.ReadBytes(':')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.conn.SetReadDeadline(time.Time{})
|
||||
|
||||
line := string(msg)
|
||||
c.detectType(line)
|
||||
c.sendConsole(line)
|
||||
|
||||
if strings.Contains(line, c.cfg.LoginPrompt) || strings.Contains(line, "login:") {
|
||||
time.Sleep(time.Second)
|
||||
c.write([]byte(c.cfg.Login + "\n\r"))
|
||||
c.loggedIn = true
|
||||
go func() {
|
||||
time.Sleep(3 * time.Second)
|
||||
c.setFilters()
|
||||
}()
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
c.conn.SetReadDeadline(time.Now().Add(ReadTimeout))
|
||||
msg, err := c.reader.ReadBytes('\n')
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
c.conn.SetReadDeadline(time.Time{})
|
||||
|
||||
line := string(msg)
|
||||
if strings.TrimSpace(line) == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
if c.clusterType == "" {
|
||||
c.detectType(line)
|
||||
}
|
||||
|
||||
if strings.Contains(line, "password") {
|
||||
c.write([]byte(c.cfg.Password + "\r\n"))
|
||||
}
|
||||
|
||||
if strings.Contains(line, "Hello") || strings.Contains(line, "Welcome") {
|
||||
if c.cfg.Command != "" {
|
||||
c.write([]byte(c.cfg.Command + "\n\r"))
|
||||
}
|
||||
}
|
||||
|
||||
// Tenter de parser comme spot DX
|
||||
isDX := strings.Contains(line, "DX de ") || spot.ShortSpotDetectRe.MatchString(line)
|
||||
if isDX && !c.shouldSkip(line) {
|
||||
if parsed := spot.ParseLine(line, c.cfg.Name); parsed != nil {
|
||||
select {
|
||||
case c.SpotChan <- parsed:
|
||||
default:
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Console — marquer les messages "To ALL"
|
||||
consoleMsg := line
|
||||
if strings.HasPrefix(strings.TrimSpace(line), "To ALL de ") {
|
||||
consoleMsg = "TO_ALL:" + strings.TrimSpace(line)
|
||||
}
|
||||
c.sendConsole(consoleMsg)
|
||||
}
|
||||
}
|
||||
|
||||
// shouldSkip applique le filtre applicatif selon la config du cluster
|
||||
func (c *Client) shouldSkip(line string) bool {
|
||||
upper := strings.ToUpper(line)
|
||||
if strings.Contains(upper, "FT8") && !c.cfg.FT8 {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(upper, "FT4") && !c.cfg.FT4 {
|
||||
return true
|
||||
}
|
||||
if (strings.Contains(upper, "CW SKIMMER") || strings.Contains(upper, "SKIMMER")) && !c.cfg.Skimmer {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(upper, "BEACON") && !c.cfg.Beacon {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (c *Client) detectType(line string) {
|
||||
if c.cfg.Type != "" {
|
||||
c.clusterType = c.cfg.Type
|
||||
return
|
||||
}
|
||||
lower := strings.ToLower(line)
|
||||
switch {
|
||||
case strings.Contains(lower, "dxspider"):
|
||||
c.clusterType = "dxspider"
|
||||
case strings.Contains(lower, "cc cluster") || strings.Contains(lower, "cc-cluster"):
|
||||
c.clusterType = "cc_cluster"
|
||||
case strings.Contains(lower, "ar-cluster") || strings.Contains(lower, "arcluster"):
|
||||
c.clusterType = "ar_cluster"
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) setFilters() {
|
||||
switch c.clusterType {
|
||||
case "dxspider":
|
||||
c.setFiltersDXSpider()
|
||||
case "ar_cluster":
|
||||
c.setFiltersAR()
|
||||
default:
|
||||
c.setFiltersCC()
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) setFiltersCC() {
|
||||
if c.cfg.FT8 { c.write([]byte("set/ft8\r\n")) } else { c.write([]byte("set/noft8\r\n")) }
|
||||
if c.cfg.FT4 { c.write([]byte("set/ft4\r\n")) } else { c.write([]byte("set/noft4\r\n")) }
|
||||
if c.cfg.Skimmer { c.write([]byte("set/skimmer\r\n")) } else { c.write([]byte("set/noskimmer\r\n")) }
|
||||
if c.cfg.Beacon { c.write([]byte("set/beacon\r\n")) } else { c.write([]byte("set/nobeacon\r\n")) }
|
||||
}
|
||||
|
||||
func (c *Client) setFiltersDXSpider() {
|
||||
if c.cfg.Skimmer && c.cfg.FT8 && c.cfg.FT4 {
|
||||
c.write([]byte("SET/SKIMMER CW FT8 FT4\r\n"))
|
||||
} else if c.cfg.Skimmer && c.cfg.FT8 {
|
||||
c.write([]byte("SET/SKIMMER CW FT8\r\n"))
|
||||
} else if c.cfg.Skimmer {
|
||||
c.write([]byte("SET/SKIMMER CW\r\n"))
|
||||
} else {
|
||||
c.write([]byte("UNSET/SKIMMER\r\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) setFiltersAR() {
|
||||
if c.cfg.FT8 { c.write([]byte("set/ft8\r\n")) } else { c.write([]byte("set/noft8\r\n")) }
|
||||
if c.cfg.FT4 { c.write([]byte("set/ft4\r\n")) } else { c.write([]byte("set/noft4\r\n")) }
|
||||
}
|
||||
|
||||
func (c *Client) write(data []byte) {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
if c.conn == nil || c.writer == nil {
|
||||
return
|
||||
}
|
||||
c.writer.Write(data)
|
||||
c.writer.Flush()
|
||||
}
|
||||
|
||||
func (c *Client) sendConsole(line string) {
|
||||
select {
|
||||
case c.ConsoleChan <- line:
|
||||
default:
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) backoff() time.Duration {
|
||||
d := time.Duration(float64(BaseReconnectDelay) * math.Pow(2, float64(c.reconnects)))
|
||||
if d > MaxReconnectDelay {
|
||||
return MaxReconnectDelay
|
||||
}
|
||||
return d
|
||||
}
|
||||
103
internal/db/db.go
Normal file
103
internal/db/db.go
Normal file
@@ -0,0 +1,103 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
|
||||
_ "github.com/mattn/go-sqlite3"
|
||||
)
|
||||
|
||||
// DB est le wrapper principal autour de la connexion SQLite
|
||||
type DB struct {
|
||||
conn *sql.DB
|
||||
}
|
||||
|
||||
// Open ouvre (ou crée) la base SQLite et applique les migrations
|
||||
func Open(path string) (*DB, error) {
|
||||
conn, err := sql.Open("sqlite3", path+"?_journal_mode=WAL&_busy_timeout=5000")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open db: %w", err)
|
||||
}
|
||||
|
||||
conn.SetMaxOpenConns(1)
|
||||
conn.SetMaxIdleConns(1)
|
||||
|
||||
db := &DB{conn: conn}
|
||||
if err := db.migrate(); err != nil {
|
||||
conn.Close()
|
||||
return nil, fmt.Errorf("migrate: %w", err)
|
||||
}
|
||||
|
||||
return db, nil
|
||||
}
|
||||
|
||||
func (db *DB) Close() error {
|
||||
return db.conn.Close()
|
||||
}
|
||||
|
||||
func (db *DB) Conn() *sql.DB {
|
||||
return db.conn
|
||||
}
|
||||
|
||||
// migrate crée les tables si elles n'existent pas
|
||||
func (db *DB) migrate() error {
|
||||
ctx := context.Background()
|
||||
|
||||
_, err := db.conn.ExecContext(ctx, `
|
||||
CREATE TABLE IF NOT EXISTS spots (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dx TEXT NOT NULL,
|
||||
spotter TEXT NOT NULL,
|
||||
frequency_khz REAL NOT NULL,
|
||||
frequency_mhz TEXT NOT NULL,
|
||||
band TEXT NOT NULL,
|
||||
mode TEXT NOT NULL,
|
||||
comment TEXT DEFAULT '',
|
||||
original_comment TEXT DEFAULT '',
|
||||
time TEXT DEFAULT '',
|
||||
timestamp INTEGER NOT NULL,
|
||||
dxcc TEXT DEFAULT '',
|
||||
country_name TEXT DEFAULT '',
|
||||
new_dxcc INTEGER DEFAULT 0,
|
||||
new_band INTEGER DEFAULT 0,
|
||||
new_mode INTEGER DEFAULT 0,
|
||||
new_slot INTEGER DEFAULT 0,
|
||||
callsign_worked INTEGER DEFAULT 0,
|
||||
in_watchlist INTEGER DEFAULT 0,
|
||||
pota_ref TEXT DEFAULT '',
|
||||
sota_ref TEXT DEFAULT '',
|
||||
park_name TEXT DEFAULT '',
|
||||
summit_name TEXT DEFAULT '',
|
||||
flex_spot_number INTEGER DEFAULT 0,
|
||||
command_number INTEGER DEFAULT 0,
|
||||
color TEXT DEFAULT '#ffeaeaea',
|
||||
background_color TEXT DEFAULT '#ff000000',
|
||||
priority TEXT DEFAULT '5',
|
||||
life_time TEXT DEFAULT '900',
|
||||
cluster_name TEXT DEFAULT '',
|
||||
source INTEGER DEFAULT 0
|
||||
)`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create spots table: %w", err)
|
||||
}
|
||||
|
||||
// Index pour les recherches fréquentes
|
||||
_, err = db.conn.ExecContext(ctx, `
|
||||
CREATE INDEX IF NOT EXISTS idx_spots_dx_band ON spots(dx, band);
|
||||
CREATE INDEX IF NOT EXISTS idx_spots_timestamp ON spots(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_spots_flex_number ON spots(flex_spot_number);
|
||||
CREATE INDEX IF NOT EXISTS idx_spots_command_number ON spots(command_number);
|
||||
`)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create indexes: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteAll supprime tous les spots (appelé au démarrage comme l'ancien code)
|
||||
func (db *DB) DeleteAll(ctx context.Context) error {
|
||||
_, err := db.conn.ExecContext(ctx, `DELETE FROM spots`)
|
||||
return err
|
||||
}
|
||||
214
internal/db/spots_store.go
Normal file
214
internal/db/spots_store.go
Normal file
@@ -0,0 +1,214 @@
|
||||
package db
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/user/flexdxcluster2/internal/spot"
|
||||
)
|
||||
|
||||
type SpotsStore struct {
|
||||
db *DB
|
||||
}
|
||||
|
||||
func NewSpotsStore(db *DB) *SpotsStore {
|
||||
return &SpotsStore{db: db}
|
||||
}
|
||||
|
||||
// Create insère un nouveau spot et retourne son ID
|
||||
func (s *SpotsStore) Create(ctx context.Context, sp spot.Spot) (int64, error) {
|
||||
res, err := s.db.conn.ExecContext(ctx, `
|
||||
INSERT INTO spots (
|
||||
dx, spotter, frequency_khz, frequency_mhz, band, mode,
|
||||
comment, original_comment, time, timestamp,
|
||||
dxcc, country_name,
|
||||
new_dxcc, new_band, new_mode, new_slot, callsign_worked, in_watchlist,
|
||||
pota_ref, sota_ref, park_name, summit_name,
|
||||
flex_spot_number, command_number,
|
||||
color, background_color, priority, life_time,
|
||||
cluster_name, source
|
||||
) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`,
|
||||
sp.DX, sp.Spotter, sp.FrequencyKHz, sp.FrequencyMHz, sp.Band, sp.Mode,
|
||||
sp.Comment, sp.OriginalComment, sp.Time, sp.Timestamp,
|
||||
sp.DXCC, sp.CountryName,
|
||||
boolToInt(sp.NewDXCC), boolToInt(sp.NewBand), boolToInt(sp.NewMode),
|
||||
boolToInt(sp.NewSlot), boolToInt(sp.CallsignWorked), boolToInt(sp.InWatchlist),
|
||||
sp.POTARef, sp.SOTARef, sp.ParkName, sp.SummitName,
|
||||
sp.FlexSpotNumber, sp.CommandNumber,
|
||||
sp.Color, sp.BackgroundColor, sp.Priority, sp.LifeTime,
|
||||
sp.ClusterName, int(sp.Source),
|
||||
)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("create spot: %w", err)
|
||||
}
|
||||
return res.LastInsertId()
|
||||
}
|
||||
|
||||
// GetAll retourne tous les spots, optionnellement filtrés par bande
|
||||
func (s *SpotsStore) GetAll(ctx context.Context, band string) ([]spot.Spot, error) {
|
||||
var rows *sql.Rows
|
||||
var err error
|
||||
|
||||
if band == "" || band == "0" || band == "ALL" {
|
||||
rows, err = s.db.conn.QueryContext(ctx,
|
||||
`SELECT * FROM spots ORDER BY timestamp DESC`)
|
||||
} else {
|
||||
rows, err = s.db.conn.QueryContext(ctx,
|
||||
`SELECT * FROM spots WHERE band = ? ORDER BY timestamp DESC`, band)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
return scanSpots(rows)
|
||||
}
|
||||
|
||||
// FindByDXAndBand cherche un spot existant pour le même DX sur la même bande
|
||||
func (s *SpotsStore) FindByDXAndBand(ctx context.Context, dx, band string) (*spot.Spot, error) {
|
||||
row := s.db.conn.QueryRowContext(ctx,
|
||||
`SELECT * FROM spots WHERE dx = ? AND band = ? ORDER BY timestamp DESC LIMIT 1`,
|
||||
dx, band)
|
||||
sp, err := scanSpot(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return sp, err
|
||||
}
|
||||
|
||||
// FindByCommandNumber cherche un spot par son numéro de commande Flex
|
||||
func (s *SpotsStore) FindByCommandNumber(ctx context.Context, cmdNum int) (*spot.Spot, error) {
|
||||
row := s.db.conn.QueryRowContext(ctx,
|
||||
`SELECT * FROM spots WHERE command_number = ? LIMIT 1`, cmdNum)
|
||||
sp, err := scanSpot(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return sp, err
|
||||
}
|
||||
|
||||
// FindByFlexSpotNumber cherche un spot par son numéro de spot Flex
|
||||
func (s *SpotsStore) FindByFlexSpotNumber(ctx context.Context, flexNum int) (*spot.Spot, error) {
|
||||
row := s.db.conn.QueryRowContext(ctx,
|
||||
`SELECT * FROM spots WHERE flex_spot_number = ? LIMIT 1`, flexNum)
|
||||
sp, err := scanSpot(row)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
return sp, err
|
||||
}
|
||||
|
||||
// UpdateFlexSpotNumber met à jour le numéro de spot Flex après confirmation du Flex
|
||||
func (s *SpotsStore) UpdateFlexSpotNumber(ctx context.Context, cmdNum, flexNum int) error {
|
||||
_, err := s.db.conn.ExecContext(ctx,
|
||||
`UPDATE spots SET flex_spot_number = ? WHERE command_number = ?`,
|
||||
flexNum, cmdNum)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByFlexSpotNumber supprime un spot par son numéro Flex
|
||||
func (s *SpotsStore) DeleteByFlexSpotNumber(ctx context.Context, flexNum int) error {
|
||||
_, err := s.db.conn.ExecContext(ctx,
|
||||
`DELETE FROM spots WHERE flex_spot_number = ?`, flexNum)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteByID supprime un spot par son ID
|
||||
func (s *SpotsStore) DeleteByID(ctx context.Context, id int64) error {
|
||||
_, err := s.db.conn.ExecContext(ctx,
|
||||
`DELETE FROM spots WHERE id = ?`, id)
|
||||
return err
|
||||
}
|
||||
|
||||
// DeleteExpired supprime les spots expirés selon leur lifetime
|
||||
func (s *SpotsStore) DeleteExpired(ctx context.Context, lifetimeSeconds int64) ([]spot.Spot, error) {
|
||||
cutoff := time.Now().Unix() - lifetimeSeconds
|
||||
rows, err := s.db.conn.QueryContext(ctx,
|
||||
`SELECT * FROM spots WHERE timestamp < ?`, cutoff)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
expired, err := scanSpots(rows)
|
||||
rows.Close()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
_, err = s.db.conn.ExecContext(ctx,
|
||||
`DELETE FROM spots WHERE timestamp < ?`, cutoff)
|
||||
return expired, err
|
||||
}
|
||||
|
||||
// --- Helpers ---
|
||||
|
||||
func boolToInt(b bool) int {
|
||||
if b {
|
||||
return 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func scanSpots(rows *sql.Rows) ([]spot.Spot, error) {
|
||||
var spots []spot.Spot
|
||||
for rows.Next() {
|
||||
sp, err := scanSpotFromRows(rows)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
spots = append(spots, *sp)
|
||||
}
|
||||
return spots, rows.Err()
|
||||
}
|
||||
|
||||
func scanSpot(row *sql.Row) (*spot.Spot, error) {
|
||||
var sp spot.Spot
|
||||
var newDXCC, newBand, newMode, newSlot, worked, inWatchlist, source int
|
||||
err := row.Scan(
|
||||
&sp.ID, &sp.DX, &sp.Spotter, &sp.FrequencyKHz, &sp.FrequencyMHz,
|
||||
&sp.Band, &sp.Mode, &sp.Comment, &sp.OriginalComment, &sp.Time, &sp.Timestamp,
|
||||
&sp.DXCC, &sp.CountryName,
|
||||
&newDXCC, &newBand, &newMode, &newSlot, &worked, &inWatchlist,
|
||||
&sp.POTARef, &sp.SOTARef, &sp.ParkName, &sp.SummitName,
|
||||
&sp.FlexSpotNumber, &sp.CommandNumber,
|
||||
&sp.Color, &sp.BackgroundColor, &sp.Priority, &sp.LifeTime,
|
||||
&sp.ClusterName, &source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sp.NewDXCC = newDXCC == 1
|
||||
sp.NewBand = newBand == 1
|
||||
sp.NewMode = newMode == 1
|
||||
sp.NewSlot = newSlot == 1
|
||||
sp.CallsignWorked = worked == 1
|
||||
sp.InWatchlist = inWatchlist == 1
|
||||
sp.Source = spot.SpotSource(source)
|
||||
return &sp, nil
|
||||
}
|
||||
|
||||
func scanSpotFromRows(rows *sql.Rows) (*spot.Spot, error) {
|
||||
var sp spot.Spot
|
||||
var newDXCC, newBand, newMode, newSlot, worked, inWatchlist, source int
|
||||
err := rows.Scan(
|
||||
&sp.ID, &sp.DX, &sp.Spotter, &sp.FrequencyKHz, &sp.FrequencyMHz,
|
||||
&sp.Band, &sp.Mode, &sp.Comment, &sp.OriginalComment, &sp.Time, &sp.Timestamp,
|
||||
&sp.DXCC, &sp.CountryName,
|
||||
&newDXCC, &newBand, &newMode, &newSlot, &worked, &inWatchlist,
|
||||
&sp.POTARef, &sp.SOTARef, &sp.ParkName, &sp.SummitName,
|
||||
&sp.FlexSpotNumber, &sp.CommandNumber,
|
||||
&sp.Color, &sp.BackgroundColor, &sp.Priority, &sp.LifeTime,
|
||||
&sp.ClusterName, &source,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
sp.NewDXCC = newDXCC == 1
|
||||
sp.NewBand = newBand == 1
|
||||
sp.NewMode = newMode == 1
|
||||
sp.NewSlot = newSlot == 1
|
||||
sp.CallsignWorked = worked == 1
|
||||
sp.InWatchlist = inWatchlist == 1
|
||||
sp.Source = spot.SpotSource(source)
|
||||
return &sp, nil
|
||||
}
|
||||
229
internal/modes/modes.go
Normal file
229
internal/modes/modes.go
Normal file
@@ -0,0 +1,229 @@
|
||||
package modes
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"github.com/user/flexdxcluster2/internal/bands"
|
||||
)
|
||||
|
||||
type ModeRange struct {
|
||||
MinFreqMHz float64
|
||||
MaxFreqMHz float64
|
||||
Mode string
|
||||
}
|
||||
|
||||
var BandModeRanges = map[string][]ModeRange{
|
||||
"160M": {
|
||||
{1.800, 1.838, "CW"},
|
||||
{1.838, 1.843, "FT8"},
|
||||
{1.843, 2.000, "LSB"},
|
||||
},
|
||||
"80M": {
|
||||
{3.500, 3.560, "CW"},
|
||||
{3.560, 3.575, "FT8"},
|
||||
{3.575, 3.578, "FT4"},
|
||||
{3.578, 3.590, "RTTY"},
|
||||
{3.590, 3.800, "LSB"},
|
||||
},
|
||||
"60M": {
|
||||
{5.330, 5.357, "CW"},
|
||||
{5.357, 5.359, "FT8"},
|
||||
{5.359, 5.405, "USB"},
|
||||
},
|
||||
"40M": {
|
||||
{7.000, 7.040, "CW"},
|
||||
{7.040, 7.047, "RTTY"},
|
||||
{7.047, 7.050, "FT4"},
|
||||
{7.050, 7.100, "FT8"},
|
||||
{7.100, 7.300, "LSB"},
|
||||
},
|
||||
"30M": {
|
||||
{10.100, 10.130, "CW"},
|
||||
{10.130, 10.142, "FT8"},
|
||||
{10.142, 10.150, "FT4"},
|
||||
},
|
||||
"20M": {
|
||||
{14.000, 14.070, "CW"},
|
||||
{14.070, 14.078, "FT8"},
|
||||
{14.078, 14.083, "FT4"},
|
||||
{14.083, 14.100, "FT8"},
|
||||
{14.100, 14.112, "RTTY"},
|
||||
{14.112, 14.350, "USB"},
|
||||
},
|
||||
"17M": {
|
||||
{18.068, 18.090, "CW"},
|
||||
{18.090, 18.104, "FT8"},
|
||||
{18.104, 18.106, "FT4"},
|
||||
{18.106, 18.110, "FT8"},
|
||||
{18.110, 18.168, "USB"},
|
||||
},
|
||||
"15M": {
|
||||
{21.000, 21.070, "CW"},
|
||||
{21.070, 21.100, "FT8"},
|
||||
{21.100, 21.130, "RTTY"},
|
||||
{21.130, 21.143, "FT4"},
|
||||
{21.143, 21.450, "USB"},
|
||||
},
|
||||
"12M": {
|
||||
{24.890, 24.910, "CW"},
|
||||
{24.910, 24.918, "FT8"},
|
||||
{24.918, 24.930, "FT4"},
|
||||
{24.930, 24.990, "USB"},
|
||||
},
|
||||
"10M": {
|
||||
{28.000, 28.070, "CW"},
|
||||
{28.070, 28.110, "FT8"},
|
||||
{28.110, 28.179, "RTTY"},
|
||||
{28.179, 28.190, "FT4"},
|
||||
{28.190, 29.000, "USB"},
|
||||
{29.000, 29.700, "FM"},
|
||||
},
|
||||
"6M": {
|
||||
{50.000, 50.100, "CW"},
|
||||
{50.100, 50.313, "USB"},
|
||||
{50.313, 50.318, "FT8"},
|
||||
{50.318, 50.323, "FT4"},
|
||||
{50.323, 51.000, "USB"},
|
||||
{51.000, 54.000, "FM"},
|
||||
},
|
||||
"QO-100": {
|
||||
{10489.500, 10489.540, "CW"},
|
||||
{10489.540, 10489.650, "FT8"},
|
||||
{10489.650, 10489.990, "USB"},
|
||||
},
|
||||
}
|
||||
|
||||
func GuessMode(freqMHz float64) string {
|
||||
band := bands.FrequencyToBand(freqMHz)
|
||||
if band == "N/A" {
|
||||
if freqMHz < 10.0 {
|
||||
return "LSB"
|
||||
}
|
||||
return "USB"
|
||||
}
|
||||
return GuessModeForBand(freqMHz, band)
|
||||
}
|
||||
|
||||
func GuessModeForBand(freqMHz float64, band string) string {
|
||||
ranges, exists := BandModeRanges[band]
|
||||
if !exists {
|
||||
if bands.IsUSBBand(band) {
|
||||
return "USB"
|
||||
}
|
||||
return "LSB"
|
||||
}
|
||||
for _, r := range ranges {
|
||||
if freqMHz >= r.MinFreqMHz && freqMHz < r.MaxFreqMHz {
|
||||
return r.Mode
|
||||
}
|
||||
}
|
||||
if bands.IsUSBBand(band) {
|
||||
return "USB"
|
||||
}
|
||||
return "LSB"
|
||||
}
|
||||
|
||||
func ExtractModeFromComment(comment string) string {
|
||||
if comment == "" {
|
||||
return ""
|
||||
}
|
||||
commentUpper := strings.ToUpper(comment)
|
||||
|
||||
if strings.Contains(commentUpper, "FT8") ||
|
||||
(strings.Contains(commentUpper, "DB") && strings.Contains(commentUpper, "HZ")) {
|
||||
return "FT8"
|
||||
}
|
||||
if strings.Contains(commentUpper, "FT4") {
|
||||
return "FT4"
|
||||
}
|
||||
if strings.Contains(commentUpper, "WPM") || strings.Contains(commentUpper, " CW ") ||
|
||||
strings.HasSuffix(commentUpper, "CW") || strings.HasPrefix(commentUpper, "CW ") {
|
||||
return "CW"
|
||||
}
|
||||
|
||||
digitalModes := []string{"RTTY", "PSK31", "PSK63", "PSK", "MFSK", "OLIVIA", "JT65", "JT9"}
|
||||
for _, mode := range digitalModes {
|
||||
if strings.Contains(commentUpper, mode) {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
|
||||
voiceModes := []string{"USB", "LSB", "SSB", "FM", "AM"}
|
||||
for _, mode := range voiceModes {
|
||||
if strings.Contains(commentUpper, " "+mode+" ") ||
|
||||
strings.HasPrefix(commentUpper, mode+" ") ||
|
||||
strings.HasSuffix(commentUpper, " "+mode) ||
|
||||
commentUpper == mode {
|
||||
return mode
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// DetermineMode — priorité : mode explicite > commentaire > fréquence
|
||||
func DetermineMode(explicitMode string, comment string, freqMHz float64) string {
|
||||
if explicitMode != "" {
|
||||
explicitMode = strings.ToUpper(explicitMode)
|
||||
if explicitMode == "SSB" {
|
||||
return bands.NormalizeSSBModeByFrequency(explicitMode, freqMHz)
|
||||
}
|
||||
return explicitMode
|
||||
}
|
||||
|
||||
modeFromComment := ExtractModeFromComment(comment)
|
||||
if modeFromComment != "" {
|
||||
if modeFromComment == "SSB" {
|
||||
return bands.NormalizeSSBModeByFrequency(modeFromComment, freqMHz)
|
||||
}
|
||||
return modeFromComment
|
||||
}
|
||||
|
||||
return GuessMode(freqMHz)
|
||||
}
|
||||
|
||||
func IsCWMode(mode string) bool { return strings.ToUpper(mode) == "CW" }
|
||||
func IsSSBMode(mode string) bool {
|
||||
m := strings.ToUpper(mode)
|
||||
return m == "SSB" || m == "USB" || m == "LSB"
|
||||
}
|
||||
func IsDigitalMode(mode string) bool {
|
||||
m := strings.ToUpper(mode)
|
||||
for _, dm := range []string{"FT8", "FT4", "RTTY", "PSK31", "PSK63", "PSK", "MFSK", "OLIVIA", "JT65", "JT9"} {
|
||||
if m == dm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
func IsPhoneMode(mode string) bool {
|
||||
m := strings.ToUpper(mode)
|
||||
for _, pm := range []string{"SSB", "USB", "LSB", "FM", "AM"} {
|
||||
if m == pm {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func ParseModeFromRawSpot(rawSpot string) string {
|
||||
re := regexp.MustCompile(`\b(CW|SSB|USB|LSB|FM|AM|FT8|FT4|RTTY|PSK\d*)\b`)
|
||||
return re.FindString(strings.ToUpper(rawSpot))
|
||||
}
|
||||
|
||||
func GetModeColor(mode string) string {
|
||||
switch strings.ToUpper(mode) {
|
||||
case "CW":
|
||||
return "#10b981"
|
||||
case "FT8", "FT4":
|
||||
return "#8b5cf6"
|
||||
case "RTTY":
|
||||
return "#f59e0b"
|
||||
case "FM":
|
||||
return "#ec4899"
|
||||
}
|
||||
if IsSSBMode(mode) {
|
||||
return "#3b82f6"
|
||||
}
|
||||
return "#6b7280"
|
||||
}
|
||||
20
internal/solar/solar.go
Normal file
20
internal/solar/solar.go
Normal file
@@ -0,0 +1,20 @@
|
||||
package solar
|
||||
|
||||
import "encoding/xml"
|
||||
|
||||
type Data struct {
|
||||
SolarFlux string `xml:"solarflux" json:"solarFlux"`
|
||||
Sunspots string `xml:"sunspots" json:"sunspots"`
|
||||
AIndex string `xml:"aindex" json:"aIndex"`
|
||||
KIndex string `xml:"kindex" json:"kIndex"`
|
||||
SolarWind string `xml:"solarwind" json:"solarWind"`
|
||||
HeliumLine string `xml:"heliumline" json:"heliumLine"`
|
||||
ProtonFlux string `xml:"protonflux" json:"protonFlux"`
|
||||
GeomagField string `xml:"geomagfield" json:"geomagField"`
|
||||
Updated string `xml:"updated" json:"updated"`
|
||||
}
|
||||
|
||||
type XML struct {
|
||||
XMLName xml.Name `xml:"solar"`
|
||||
Data Data `xml:"solardata"`
|
||||
}
|
||||
89
internal/spot/parser.go
Normal file
89
internal/spot/parser.go
Normal file
@@ -0,0 +1,89 @@
|
||||
package spot
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Regex pour les deux formats de spots cluster
|
||||
var (
|
||||
// Format standard : DX de SPOTTER: FREQ DX MODE COMMENT TIME
|
||||
SpotRe = regexp.MustCompile(`(?i)DX\sde\s([\w\d\/]+?)(?:-[#\d-]+)?\s*:\s*(\d+\.\d+)\s+([\w\d\/]+)\s+(?:(CW|SSB|FT8|FT4|RTTY|USB|LSB|FM)\s+)?(.+?)\s+(\d{4}Z)`)
|
||||
|
||||
// Format court : FREQ DX DATE TIME COMMENT <SPOTTER>
|
||||
SpotReShort = regexp.MustCompile(`^(\d+\.\d+)\s+([\w\d\/]+)\s+\d{2}-\w{3}-\d{4}\s+(\d{4}Z)\s+(.+?)\s*<([\w\d\/]+)>\s*$`)
|
||||
|
||||
// Détection rapide du format court
|
||||
ShortSpotDetectRe = regexp.MustCompile(`^\d+\.\d+\s+[\w\d\/]+\s+\d{2}-\w{3}-\d{4}`)
|
||||
)
|
||||
|
||||
// ParseResult contient le spot parsé et une éventuelle erreur
|
||||
type ParseResult struct {
|
||||
Spot *Spot
|
||||
Err error
|
||||
Skipped bool // true si la ligne n'est pas un spot (pas une erreur)
|
||||
}
|
||||
|
||||
// ParseLine tente de parser une ligne brute du cluster en Spot
|
||||
// Retourne nil si la ligne n'est pas un spot DX
|
||||
func ParseLine(line string, clusterName string) *Spot {
|
||||
// Détecter si c'est un spot
|
||||
isSpot := strings.Contains(line, "DX de ") || ShortSpotDetectRe.MatchString(line)
|
||||
if !isSpot {
|
||||
return nil
|
||||
}
|
||||
|
||||
match := SpotRe.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
return parseStandardFormat(match, clusterName)
|
||||
}
|
||||
|
||||
match = SpotReShort.FindStringSubmatch(line)
|
||||
if len(match) > 0 {
|
||||
return parseShortFormat(match, clusterName)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func parseStandardFormat(match []string, clusterName string) *Spot {
|
||||
freqKHz := parseFreq(match[2])
|
||||
return &Spot{
|
||||
Spotter: match[1],
|
||||
FrequencyKHz: freqKHz,
|
||||
DX: match[3],
|
||||
Mode: match[4],
|
||||
Comment: strings.TrimSpace(match[5]),
|
||||
Time: match[6],
|
||||
ClusterName: clusterName,
|
||||
Source: SourceCluster,
|
||||
}
|
||||
}
|
||||
|
||||
func parseShortFormat(match []string, clusterName string) *Spot {
|
||||
freqKHz := parseFreq(match[1])
|
||||
return &Spot{
|
||||
FrequencyKHz: freqKHz,
|
||||
DX: match[2],
|
||||
Time: match[3],
|
||||
Comment: strings.TrimSpace(match[4]),
|
||||
Spotter: match[5],
|
||||
ClusterName: clusterName,
|
||||
Source: SourceCluster,
|
||||
}
|
||||
}
|
||||
|
||||
// parseFreq parse une fréquence string en kHz float64
|
||||
// Gère kHz (>1000) et MHz (<1000) automatiquement
|
||||
func parseFreq(s string) float64 {
|
||||
f, err := strconv.ParseFloat(s, 64)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
// Si < 1000 c'est en MHz, on convertit en kHz
|
||||
if f < 1000 {
|
||||
return f * 1000.0
|
||||
}
|
||||
return f
|
||||
}
|
||||
97
internal/spot/spot.go
Normal file
97
internal/spot/spot.go
Normal file
@@ -0,0 +1,97 @@
|
||||
package spot
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SpotSource identifie l'origine d'un spot
|
||||
type SpotSource int
|
||||
|
||||
const (
|
||||
SourceCluster SpotSource = iota // Spot reçu depuis un cluster DX telnet
|
||||
SourceManual // Spot ajouté manuellement depuis l'UI
|
||||
)
|
||||
|
||||
// Spot est la struct universelle — remplace TelnetSpot + FlexSpot
|
||||
// Plus de conversion entre les deux, tout passe par cette struct unique
|
||||
type Spot struct {
|
||||
// --- Persistance ---
|
||||
ID int64
|
||||
|
||||
// --- Identité ---
|
||||
DX string
|
||||
Spotter string
|
||||
|
||||
// --- Fréquence ---
|
||||
// FrequencyKHz est la source de vérité interne
|
||||
// FrequencyMHz est le format string attendu par FlexRadio (ex: "14.195000")
|
||||
FrequencyKHz float64
|
||||
FrequencyMHz string
|
||||
Band string
|
||||
|
||||
// --- Mode ---
|
||||
Mode string
|
||||
|
||||
// --- Métadonnées cluster ---
|
||||
Comment string
|
||||
Time string // Format "1234Z"
|
||||
Timestamp int64 // Unix timestamp
|
||||
ReceivedAt time.Time
|
||||
ClusterName string
|
||||
Source SpotSource
|
||||
|
||||
// --- DXCC ---
|
||||
DXCC string
|
||||
CountryName string
|
||||
|
||||
// --- Flags Log4OM (calculés depuis la DB Log4OM) ---
|
||||
NewDXCC bool
|
||||
NewBand bool
|
||||
NewMode bool
|
||||
NewSlot bool
|
||||
CallsignWorked bool
|
||||
|
||||
// --- Watchlist ---
|
||||
InWatchlist bool
|
||||
WatchlistNotify bool
|
||||
|
||||
// --- POTA / SOTA ---
|
||||
POTARef string
|
||||
SOTARef string
|
||||
ParkName string
|
||||
SummitName string
|
||||
|
||||
// --- FlexRadio panadapter ---
|
||||
FlexSpotNumber int
|
||||
CommandNumber int
|
||||
Color string
|
||||
BackgroundColor string
|
||||
Priority string
|
||||
LifeTime string
|
||||
OriginalComment string
|
||||
}
|
||||
|
||||
// FreqMHzString retourne la fréquence formatée pour FlexRadio
|
||||
// ex: 14195.0 kHz → "14.195000"
|
||||
func (s *Spot) FreqMHzString() string {
|
||||
if s.FrequencyMHz != "" {
|
||||
return s.FrequencyMHz
|
||||
}
|
||||
if s.FrequencyKHz > 1000 {
|
||||
return formatMHz(s.FrequencyKHz / 1000.0)
|
||||
}
|
||||
return formatMHz(s.FrequencyKHz)
|
||||
}
|
||||
|
||||
// FreqKHz retourne la fréquence en kHz depuis le champ disponible
|
||||
func (s *Spot) FreqKHz() float64 {
|
||||
if s.FrequencyKHz > 0 {
|
||||
return s.FrequencyKHz
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func formatMHz(mhz float64) string {
|
||||
return fmt.Sprintf("%.6f", mhz)
|
||||
}
|
||||
57
internal/stats/stats.go
Normal file
57
internal/stats/stats.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package stats
|
||||
|
||||
import "sync"
|
||||
|
||||
type SpotStats struct {
|
||||
mu sync.RWMutex
|
||||
Received int64
|
||||
Processed int64
|
||||
Rejected int64
|
||||
}
|
||||
|
||||
var Global = &SpotStats{}
|
||||
|
||||
func (s *SpotStats) IncrReceived() {
|
||||
s.mu.Lock()
|
||||
s.Received++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SpotStats) IncrProcessed() {
|
||||
s.mu.Lock()
|
||||
s.Processed++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SpotStats) IncrRejected() {
|
||||
s.mu.Lock()
|
||||
s.Rejected++
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
func (s *SpotStats) Get() (received, processed, rejected int64) {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.Received, s.Processed, s.Rejected
|
||||
}
|
||||
|
||||
func (s *SpotStats) SuccessRate() float64 {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
if s.Received == 0 {
|
||||
return 0.0
|
||||
}
|
||||
return float64(s.Processed) / float64(s.Received) * 100.0
|
||||
}
|
||||
|
||||
// Snapshot retourne un map JSON-friendly pour le frontend
|
||||
func (s *SpotStats) Snapshot() map[string]interface{} {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return map[string]interface{}{
|
||||
"received": s.Received,
|
||||
"processed": s.Processed,
|
||||
"rejected": s.Rejected,
|
||||
"successRate": s.SuccessRate(),
|
||||
}
|
||||
}
|
||||
260
internal/watchlist/watchlist.go
Normal file
260
internal/watchlist/watchlist.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package watchlist
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
type Entry struct {
|
||||
Callsign string `json:"callsign"`
|
||||
LastSeen time.Time `json:"lastSeen"`
|
||||
LastSeenStr string `json:"lastSeenStr"`
|
||||
AddedAt time.Time `json:"addedAt"`
|
||||
SpotCount int `json:"spotCount"`
|
||||
IsContest bool `json:"isContest"`
|
||||
Notify bool `json:"notify"`
|
||||
|
||||
// ActiveSpotIDs n'est pas sérialisé — reconstruit en mémoire
|
||||
activeSpotIDs map[int64]bool
|
||||
}
|
||||
|
||||
type Watchlist struct {
|
||||
entries map[string]*Entry
|
||||
filePath string
|
||||
mu sync.RWMutex
|
||||
}
|
||||
|
||||
func New(filePath string) *Watchlist {
|
||||
w := &Watchlist{
|
||||
entries: make(map[string]*Entry),
|
||||
filePath: filePath,
|
||||
}
|
||||
w.load()
|
||||
return w
|
||||
}
|
||||
|
||||
func (w *Watchlist) load() {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
data, err := os.ReadFile(w.filePath)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
var entries []Entry
|
||||
if err := json.Unmarshal(data, &entries); err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
for i := range entries {
|
||||
e := &entries[i]
|
||||
e.activeSpotIDs = make(map[int64]bool)
|
||||
if e.LastSeen.IsZero() {
|
||||
e.LastSeenStr = "Never"
|
||||
} else {
|
||||
e.LastSeenStr = FormatLastSeen(e.LastSeen)
|
||||
}
|
||||
w.entries[e.Callsign] = e
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watchlist) save() error {
|
||||
entries := make([]Entry, 0, len(w.entries))
|
||||
for _, e := range w.entries {
|
||||
entries = append(entries, *e)
|
||||
}
|
||||
data, err := json.MarshalIndent(entries, "", " ")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return os.WriteFile(w.filePath, data, 0644)
|
||||
}
|
||||
|
||||
func (w *Watchlist) Add(callsign string) error {
|
||||
return w.add(callsign, false)
|
||||
}
|
||||
|
||||
func (w *Watchlist) AddContest(callsign string) error {
|
||||
return w.add(callsign, true)
|
||||
}
|
||||
|
||||
func (w *Watchlist) add(callsign string, isContest bool) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if callsign == "" {
|
||||
return fmt.Errorf("callsign cannot be empty")
|
||||
}
|
||||
if _, exists := w.entries[callsign]; exists {
|
||||
return fmt.Errorf("callsign already in watchlist")
|
||||
}
|
||||
|
||||
w.entries[callsign] = &Entry{
|
||||
Callsign: callsign,
|
||||
AddedAt: time.Now(),
|
||||
LastSeenStr: "Never",
|
||||
activeSpotIDs: make(map[int64]bool),
|
||||
IsContest: isContest,
|
||||
}
|
||||
|
||||
return w.save()
|
||||
}
|
||||
|
||||
func (w *Watchlist) Remove(callsign string) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
if _, exists := w.entries[callsign]; !exists {
|
||||
return fmt.Errorf("callsign not in watchlist")
|
||||
}
|
||||
delete(w.entries, callsign)
|
||||
return w.save()
|
||||
}
|
||||
|
||||
func (w *Watchlist) SetNotify(callsign string, notify bool) error {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
|
||||
callsign = strings.ToUpper(strings.TrimSpace(callsign))
|
||||
e, exists := w.entries[callsign]
|
||||
if !exists {
|
||||
return fmt.Errorf("callsign not found")
|
||||
}
|
||||
e.Notify = notify
|
||||
return w.save()
|
||||
}
|
||||
|
||||
func (w *Watchlist) Matches(callsign string) bool {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
_, ok := w.entries[strings.ToUpper(callsign)]
|
||||
return ok
|
||||
}
|
||||
|
||||
func (w *Watchlist) GetEntry(callsign string) *Entry {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
e, ok := w.entries[strings.ToUpper(callsign)]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
copy := *e
|
||||
return ©
|
||||
}
|
||||
|
||||
func (w *Watchlist) GetAll() []Entry {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
entries := make([]Entry, 0, len(w.entries))
|
||||
for _, e := range w.entries {
|
||||
cp := *e
|
||||
if !cp.LastSeen.IsZero() {
|
||||
cp.LastSeenStr = FormatLastSeen(cp.LastSeen)
|
||||
}
|
||||
entries = append(entries, cp)
|
||||
}
|
||||
return entries
|
||||
}
|
||||
|
||||
func (w *Watchlist) GetAllCallsigns() []string {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
callsigns := make([]string, 0, len(w.entries))
|
||||
for cs := range w.entries {
|
||||
callsigns = append(callsigns, cs)
|
||||
}
|
||||
return callsigns
|
||||
}
|
||||
|
||||
func (w *Watchlist) MarkSeen(callsign string) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
e, ok := w.entries[strings.ToUpper(callsign)]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
e.LastSeen = time.Now()
|
||||
e.LastSeenStr = FormatLastSeen(e.LastSeen)
|
||||
e.SpotCount++
|
||||
}
|
||||
|
||||
func (w *Watchlist) AddActiveSpot(callsign string, spotID int64) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
e, ok := w.entries[strings.ToUpper(callsign)]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if e.activeSpotIDs == nil {
|
||||
e.activeSpotIDs = make(map[int64]bool)
|
||||
}
|
||||
e.activeSpotIDs[spotID] = true
|
||||
}
|
||||
|
||||
func (w *Watchlist) RemoveActiveSpot(spotID int64) {
|
||||
w.mu.Lock()
|
||||
defer w.mu.Unlock()
|
||||
for _, e := range w.entries {
|
||||
delete(e.activeSpotIDs, spotID)
|
||||
}
|
||||
}
|
||||
|
||||
func (w *Watchlist) GetAllWithActiveStatus() []map[string]interface{} {
|
||||
w.mu.RLock()
|
||||
defer w.mu.RUnlock()
|
||||
result := make([]map[string]interface{}, 0, len(w.entries))
|
||||
for _, e := range w.entries {
|
||||
lastSeenStr := "Never"
|
||||
if !e.LastSeen.IsZero() {
|
||||
lastSeenStr = FormatLastSeen(e.LastSeen)
|
||||
}
|
||||
result = append(result, map[string]interface{}{
|
||||
"callsign": e.Callsign,
|
||||
"lastSeen": e.LastSeen,
|
||||
"lastSeenStr": lastSeenStr,
|
||||
"addedAt": e.AddedAt,
|
||||
"spotCount": e.SpotCount,
|
||||
"notify": e.Notify,
|
||||
"isContest": e.IsContest,
|
||||
"hasActiveSpots": len(e.activeSpotIDs) > 0,
|
||||
"activeCount": len(e.activeSpotIDs),
|
||||
})
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func FormatLastSeen(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return "Never"
|
||||
}
|
||||
d := time.Since(t)
|
||||
switch {
|
||||
case d < time.Minute:
|
||||
return "Just now"
|
||||
case d < time.Hour:
|
||||
m := int(d.Minutes())
|
||||
if m == 1 {
|
||||
return "1 minute ago"
|
||||
}
|
||||
return fmt.Sprintf("%d minutes ago", m)
|
||||
case d < 24*time.Hour:
|
||||
h := int(d.Hours())
|
||||
if h == 1 {
|
||||
return "1 hour ago"
|
||||
}
|
||||
return fmt.Sprintf("%d hours ago", h)
|
||||
default:
|
||||
days := int(d.Hours() / 24)
|
||||
if days == 1 {
|
||||
return "1 day ago"
|
||||
}
|
||||
return fmt.Sprintf("%d days ago", days)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user