first commit

This commit is contained in:
2026-03-17 20:20:23 +01:00
commit 354c7a9d99
32 changed files with 3253 additions and 0 deletions

132
internal/bands/bands.go Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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(),
}
}

View 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 &copy
}
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)
}
}