This commit is contained in:
2025-10-15 00:28:53 +05:30
parent 5b46ac98ad
commit af52fe8c72
12 changed files with 433 additions and 290 deletions

View File

@@ -15,7 +15,7 @@ import (
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 spotRe *regexp.Regexp = regexp.MustCompile(`DX\sde\s([\w\d]+).*:\s+(\d+.\d)\s+([\w\d\/]+)\s+(CW|cw|SSB|ssb|FT8|ft8|FT4|ft4|RTTY|rtty|USB|usb|LSB|lsb)?\s+(.*)\s\s\s+([\d]+\w{1})`)
var defaultLoginRe *regexp.Regexp = regexp.MustCompile("[\\w\\d-_]+ login:")
var defaultPasswordRe *regexp.Regexp = regexp.MustCompile("Password:")
@@ -51,7 +51,7 @@ type TCPClient struct {
maxReconnectDelay time.Duration
}
func NewTCPClient(TCPServer *TCPServer, Countries Countries, contactRepo *Log4OMContactsRepository) *TCPClient {
func NewTCPClient(TCPServer *TCPServer, Countries Countries, contactRepo *Log4OMContactsRepository, spotChanToHTTPServer chan TelnetSpot) *TCPClient {
ctx, cancel := context.WithCancel(context.Background())
return &TCPClient{
@@ -61,9 +61,9 @@ func NewTCPClient(TCPServer *TCPServer, Countries Countries, contactRepo *Log4OM
Password: Cfg.Cluster.Password,
MsgChan: TCPServer.MsgChan,
CmdChan: TCPServer.CmdChan,
SpotChanToHTTPServer: spotChanToHTTPServer,
SpotChanToFlex: make(chan TelnetSpot, 100),
TCPServer: *TCPServer,
SpotChanToHTTPServer: make(chan TelnetSpot, 100),
Countries: Countries,
ContactRepo: contactRepo,
ctx: ctx,

View File

@@ -8,7 +8,6 @@ import (
"net"
"os"
"regexp"
"strings"
"time"
)
@@ -60,7 +59,6 @@ type FlexClient struct {
Reader *bufio.Reader
Writer *bufio.Writer
Conn *net.TCPConn
SpotChanToFlex chan TelnetSpot
MsgChan chan string
Repo FlexDXClusterRepository
TCPServer *TCPServer
@@ -82,10 +80,10 @@ func NewFlexClient(repo FlexDXClusterRepository, TCPServer *TCPServer, SpotChanT
return &FlexClient{
Port: "4992",
SpotChanToFlex: SpotChanToFlex,
MsgChan: TCPServer.MsgChan,
Repo: repo,
TCPServer: TCPServer,
HTTPServer: httpServer,
IsConnected: false,
Enabled: enabled,
ctx: ctx,
@@ -114,7 +112,6 @@ func (fc *FlexClient) resolveAddress() (string, error) {
if Cfg.Flex.Discover {
Log.Debug("Attempting FlexRadio discovery...")
// Timeout sur la découverte (10 secondes max)
discoveryDone := make(chan struct {
success bool
discovery *Discovery
@@ -197,32 +194,9 @@ func (fc *FlexClient) StartFlexClient() {
if !fc.Enabled {
Log.Info("FlexRadio integration disabled in config - skipping")
// Consommer les spots pour éviter les blocages
go func() {
for {
select {
case <-fc.ctx.Done():
return
case <-fc.SpotChanToFlex:
}
}
}()
return
}
// Goroutine pour envoyer les spots au Flex
go func() {
for {
select {
case <-fc.ctx.Done():
return
case spot := <-fc.SpotChanToFlex:
fc.SendSpottoFlex(spot)
}
}
}()
for {
select {
case <-fc.ctx.Done():
@@ -289,188 +263,7 @@ func (fc *FlexClient) Close() {
}
}
func (fc *FlexClient) SendSpottoFlex(spot TelnetSpot) {
freq := FreqMhztoHz(spot.Frequency)
flexSpot := FlexSpot{
CommandNumber: CommandNumber,
DX: spot.DX,
FrequencyMhz: freq,
FrequencyHz: spot.Frequency,
Band: spot.Band,
Mode: spot.Mode,
Source: "FlexDXCluster",
SpotterCallsign: spot.Spotter,
TimeStamp: time.Now().Unix(),
UTCTime: spot.Time,
LifeTime: Cfg.Flex.SpotLife,
OriginalComment: spot.Comment,
Comment: spot.Comment,
Color: "#ffeaeaea",
BackgroundColor: "#ff000000",
Priority: "5",
NewDXCC: spot.NewDXCC,
NewBand: spot.NewBand,
NewMode: spot.NewMode,
NewSlot: spot.NewSlot,
Worked: spot.CallsignWorked,
InWatchlist: false,
CountryName: spot.CountryName,
DXCC: spot.DXCC,
}
flexSpot.Comment = flexSpot.Comment + " [" + flexSpot.Mode + "] [" + flexSpot.SpotterCallsign + "] [" + flexSpot.UTCTime + "]"
if fc.HTTPServer != nil && fc.HTTPServer.Watchlist != nil {
if fc.HTTPServer.Watchlist.Matches(flexSpot.DX) {
flexSpot.InWatchlist = true
flexSpot.Comment = flexSpot.Comment + " [Watchlist]"
Log.Infof("🎯 Watchlist match: %s", flexSpot.DX)
}
}
// If new DXCC
if spot.NewDXCC {
flexSpot.Priority = "1"
flexSpot.Comment = flexSpot.Comment + " [New DXCC]"
if Cfg.General.SpotColorNewEntity != "" && Cfg.General.BackgroundColorNewEntity != "" {
flexSpot.Color = Cfg.General.SpotColorNewEntity
flexSpot.BackgroundColor = Cfg.General.BackgroundColorNewEntity
} else {
flexSpot.Color = "#ff3bf908"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.DX == Cfg.General.Callsign {
flexSpot.Priority = "1"
if Cfg.General.SpotColorMyCallsign != "" && Cfg.General.BackgroundColorMyCallsign != "" {
flexSpot.Color = Cfg.General.SpotColorMyCallsign
flexSpot.BackgroundColor = Cfg.General.BackgroundColorMyCallsign
} else {
flexSpot.Color = "#ffff0000"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.CallsignWorked {
flexSpot.Priority = "5"
flexSpot.Comment = flexSpot.Comment + " [Worked]"
if Cfg.General.SpotColorWorked != "" && Cfg.General.BackgroundColorWorked != "" {
flexSpot.Color = Cfg.General.SpotColorWorked
flexSpot.BackgroundColor = Cfg.General.BackgroundColorWorked
} else {
flexSpot.Color = "#ff000000"
flexSpot.BackgroundColor = "#ff00c0c0"
}
} else if spot.NewMode && spot.NewBand {
flexSpot.Priority = "1"
flexSpot.Comment = flexSpot.Comment + " [New Band & Mode]"
if Cfg.General.SpotColorNewBandMode != "" && Cfg.General.BackgroundColorNewBandMode != "" {
flexSpot.Color = Cfg.General.SpotColorNewBandMode
flexSpot.BackgroundColor = Cfg.General.BackgroundColorNewBandMode
} else {
flexSpot.Color = "#ffc603fc"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.NewMode && !spot.NewBand {
flexSpot.Priority = "2"
flexSpot.Comment = flexSpot.Comment + " [New Mode]"
if Cfg.General.SpotColorNewMode != "" && Cfg.General.BackgroundColorNewMode != "" {
flexSpot.Color = Cfg.General.SpotColorNewMode
flexSpot.BackgroundColor = Cfg.General.BackgroundColorNewMode
} else {
flexSpot.Color = "#fff9a908"
flexSpot.BackgroundColor = "#ff000000"
}
} else if spot.NewBand && !spot.NewMode {
flexSpot.Color = "#fff9f508"
flexSpot.Priority = "3"
flexSpot.BackgroundColor = "#ff000000"
flexSpot.Comment = flexSpot.Comment + " [New Band]"
} else if !spot.NewBand && !spot.NewMode && !spot.NewDXCC && !spot.CallsignWorked && spot.NewSlot {
flexSpot.Color = "#ff91d2ff"
flexSpot.Priority = "5"
flexSpot.BackgroundColor = "#ff000000"
flexSpot.Comment = flexSpot.Comment + " [New Slot]"
} else if !spot.NewBand && !spot.NewMode && !spot.NewDXCC && !spot.CallsignWorked {
flexSpot.Color = "#ffeaeaea"
flexSpot.Priority = "5"
flexSpot.BackgroundColor = "#ff000000"
} else {
flexSpot.Color = "#ffeaeaea"
flexSpot.Priority = "5"
flexSpot.BackgroundColor = "#ff000000"
}
// Send notification to Gotify
Gotify(flexSpot)
flexSpot.Comment = strings.ReplaceAll(flexSpot.Comment, " ", "\u00A0")
srcFlexSpot, err := fc.Repo.FindDXSameBand(flexSpot)
if err != nil {
Log.Debugf("Could not find the DX in the database: %v", err)
}
var stringSpot string
if srcFlexSpot.DX == "" {
fc.Repo.CreateSpot(flexSpot)
CommandNumber++
if fc.HTTPServer != nil {
fc.HTTPServer.broadcast <- WSMessage{
Type: "spots",
Data: fc.Repo.GetAllSpots("0"),
}
}
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot add rx_freq=%v callsign=%s mode=%s source=%s spotter_callsign=%s timestamp=%v lifetime_seconds=%s comment=%s color=%s background_color=%s priority=%s",
flexSpot.CommandNumber, flexSpot.FrequencyMhz,
flexSpot.DX, flexSpot.Mode, flexSpot.Source, flexSpot.SpotterCallsign,
flexSpot.TimeStamp, flexSpot.LifeTime, flexSpot.Comment, flexSpot.Color,
flexSpot.BackgroundColor, flexSpot.Priority)
CommandNumber++
fc.SendSpot(stringSpot)
}
} else if srcFlexSpot.DX != "" && srcFlexSpot.Band == flexSpot.Band {
fc.Repo.DeleteSpotByFlexSpotNumber(string(flexSpot.FlexSpotNumber))
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot remove %v", flexSpot.CommandNumber, srcFlexSpot.FlexSpotNumber)
fc.SendSpot(stringSpot)
CommandNumber++
}
fc.Repo.CreateSpot(flexSpot)
CommandNumber++
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot add rx_freq=%v callsign=%s mode=%s source=%s spotter_callsign=%s timestamp=%v lifetime_seconds=%s comment=%s color=%s background_color=%s priority=%s",
flexSpot.CommandNumber, flexSpot.FrequencyMhz,
flexSpot.DX, flexSpot.Mode, flexSpot.Source, flexSpot.SpotterCallsign,
flexSpot.TimeStamp, flexSpot.LifeTime, flexSpot.Comment, flexSpot.Color,
flexSpot.BackgroundColor, flexSpot.Priority)
CommandNumber++
fc.SendSpot(stringSpot)
}
} else if srcFlexSpot.DX != "" && srcFlexSpot.Band != flexSpot.Band {
fc.Repo.CreateSpot(flexSpot)
CommandNumber++
if fc.IsConnected {
stringSpot = fmt.Sprintf("C%v|spot add rx_freq=%v callsign=%s mode=%s source=%s spotter_callsign=%s timestamp=%v lifetime_seconds=%s comment=%s color=%s background_color=%s priority=%s",
flexSpot.CommandNumber, flexSpot.FrequencyMhz,
flexSpot.DX, flexSpot.Mode, flexSpot.Source, flexSpot.SpotterCallsign,
flexSpot.TimeStamp, flexSpot.LifeTime, flexSpot.Comment, flexSpot.Color,
flexSpot.BackgroundColor, flexSpot.Priority)
CommandNumber++
fc.SendSpot(stringSpot)
}
}
}
// ✅ SendSpot - Méthode simplifiée pour envoyer une commande au Flex
func (fc *FlexClient) SendSpot(stringSpot string) {
if fc.IsConnected {
fc.Write(stringSpot)

View File

@@ -79,15 +79,17 @@
filteredSpots = applyFilters(spots, spotFilters, watchlist);
}
}
$: if (typeof localStorage !== 'undefined') {
localStorage.setItem('soundEnabled', soundEnabled.toString());
}
// Détecter les nouveaux spots et jouer les sons appropriés
$: if (spots.length > 0 && soundEnabled) {
checkForNewSpots(spots, previousSpots, watchlist);
previousSpots = [...spots];
// ✅ Ne garder que les 100 derniers spots pour la comparaison
previousSpots = spots.slice(0, 100);
}
// ✅ SUPPRIMÉ - La watchlist est gérée côté serveur via WebSocket
// Les fonctions addToWatchlist et removeFromWatchlist ne sont plus nécessaires
function checkForNewSpots(currentSpots, prevSpots, wl) {
// Ne pas jouer de sons au chargement initial
@@ -318,6 +320,14 @@
case 'dxccProgress':
dxccProgress = message.data || { worked: 0, total: 340, percentage: 0 };
break;
case 'milestone': // ✅ AJOUTER
const milestoneData = message.data;
const toastType = milestoneData.type === 'qso' ? 'milestone' : 'band';
showToast(milestoneData.message, toastType);
if (soundEnabled) {
playSound('milestone');
}
break;
}
}
@@ -425,11 +435,22 @@
}
onMount(() => {
const savedSoundEnabled = localStorage.getItem('soundEnabled');
if (savedSoundEnabled !== null) {
soundEnabled = savedSoundEnabled === 'true';
}
connectWebSocket();
fetchSolarData();
const solarInterval = setInterval(fetchSolarData, 15 * 60 * 1000);
const cleanupInterval = setInterval(() => {
// Nettoyer previousSpots
if (previousSpots.length > 100) {
previousSpots = previousSpots.slice(0, 100);
}
}, 60000);
const handleSendSpot = (e) => {
sendCallsign(e.detail.callsign, e.detail.frequency, e.detail.mode);
};
@@ -444,7 +465,7 @@
});
</script>
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-4">
<div class="bg-gradient-to-br from-slate-900 via-slate-800 to-slate-900 text-white min-h-screen p-2">
<!-- Gestionnaire de sons -->
<SoundManager bind:enabled={soundEnabled} />
@@ -460,7 +481,9 @@
{stats}
{solarData}
{wsStatus}
on:shutdown={shutdownApp}
{soundEnabled}
on:shutdown={shutdownApp}
on:toggleSound={() => soundEnabled = !soundEnabled}
/>
<StatsCards
@@ -475,9 +498,9 @@
on:toggleFilter={(e) => toggleFilter(e.detail)}
/>
<div class="grid grid-cols-4 gap-3 overflow-hidden" style="height: calc(100vh - 360px); min-height: 500px;">
<div class="col-span-3 overflow-hidden">
<SpotsTable
<div class="grid grid-cols-[2.8fr_1.2fr] gap-3 overflow-hidden" style="height: calc(100vh - 280px); min-height: 500px;">
<div class="overflow-hidden">
<SpotsTable
spots={filteredSpots}
{watchlist}
myCallsign={stats.myCallsign}

View File

@@ -1,6 +1,7 @@
<script>
import { createEventDispatcher } from 'svelte';
export let soundEnabled = true;
export let stats;
export let solarData;
export let wsStatus;
@@ -50,7 +51,7 @@
FlexDXCluster
</h1>
<div class="flex items-center gap-3 text-xs text-slate-400">
<span>F4BPO<span>{stats.totalContacts}</span> Contacts</span>
<span>{stats.myCallsign || 'N/A'}<span>{stats.totalContacts}</span> Contacts</span>
<span class="text-slate-600">|</span>
<span class="flex items-center gap-1">
<span class="font-semibold text-amber-400">SFI:</span>
@@ -100,6 +101,25 @@
Flex
</span>
<!-- Bouton Son -->
<button
on:click={() => dispatch('toggleSound')}
class="px-3 py-1.5 rounded-lg transition-colors flex items-center gap-2 text-sm {soundEnabled ? 'bg-blue-600 hover:bg-blue-700 text-white' : 'bg-slate-700/50 hover:bg-slate-700 text-slate-300'}"
title={soundEnabled ? 'Mute sounds' : 'Enable sounds'}>
{#if soundEnabled}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15.536 8.464a5 5 0 010 7.072m2.828-9.9a9 9 0 010 12.728M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z"></path>
</svg>
<span>Sound On</span>
{:else}
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clip-rule="evenodd"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"></path>
</svg>
<span>Sound Off</span>
{/if}
</button>
<button
on:click={() => dispatch('shutdown')}
class="px-3 py-1.5 text-xs bg-red-600 hover:bg-red-700 rounded transition-colors flex items-center gap-1">

View File

@@ -26,7 +26,7 @@
});
function handlePlaySound(event) {
if (!enabled || isMuted) return;
if (!enabled) return; // ✅ Utiliser 'enabled' (qui vient de App.svelte) au lieu de 'isMuted'
const { type } = event.detail;
@@ -89,10 +89,20 @@
oscillator.start(startTime);
oscillator.stop(startTime + duration);
}
function playMilestoneSound() {
if (!audioContext) return;
const now = audioContext.currentTime;
// Mélodie festive : E5 -> G5 -> A5 -> C6
playBeep(now, 659.25, 0.15);
playBeep(now + 0.15, 783.99, 0.15);
playBeep(now + 0.3, 880.00, 0.15);
playBeep(now + 0.45, 1046.50, 0.2);
}
function toggleMute() {
isMuted = !isMuted;
localStorage.setItem('soundMuted', isMuted.toString());
enabled = !enabled; // ✅ Modifier 'enabled' au lieu de 'isMuted'
}
function updateVolume(newVolume) {
@@ -105,9 +115,9 @@
<div class="fixed bottom-4 right-4 flex items-center gap-2 bg-slate-800/90 backdrop-blur rounded-lg border border-slate-700/50 p-2 shadow-lg z-50">
<button
on:click={toggleMute}
class="p-2 rounded transition-colors {isMuted ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}"
title={isMuted ? 'Unmute sounds' : 'Mute sounds'}>
{#if isMuted}
class="p-2 rounded transition-colors {!enabled ? 'bg-red-600/20 text-red-400 hover:bg-red-600/30' : 'bg-slate-700/50 text-slate-300 hover:bg-slate-700'}"
title={!enabled ? 'Unmute sounds' : 'Mute sounds'}>
{#if !enabled}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5.586 15H4a1 1 0 01-1-1v-4a1 1 0 011-1h1.586l4.707-4.707C10.923 3.663 12 4.109 12 5v14c0 .891-1.077 1.337-1.707.707L5.586 15z" clip-rule="evenodd"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 14l2-2m0 0l2-2m-2 2l-2-2m2 2l2 2"></path>

View File

@@ -1,37 +1,41 @@
<script>
export let message;
export let type = 'info'; // 'success', 'error', 'warning', 'info'
export let type = 'info'; // 'success', 'error', 'warning', 'info', 'milestone', 'band'
const icons = {
success: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>`,
error: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>`,
warning: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"></path>`,
info: ''
info: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"></path>`,
milestone: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 3v4M3 5h4M6 17v4m-2-2h4m5-16l2.286 6.857L21 12l-5.714 2.143L13 21l-2.286-6.857L5 12l5.714-2.143L13 3z"></path>`,
band: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z"></path>`
};
const colors = {
success: 'bg-green-500',
error: 'bg-red-500',
warning: 'bg-orange-500',
info: 'bg-blue-500'
info: 'bg-blue-500',
milestone: 'bg-gradient-to-r from-purple-500 to-pink-500',
band: 'bg-gradient-to-r from-orange-500 to-amber-500'
};
</script>
<div class="fixed bottom-5 right-5 {colors[type]} text-white px-5 py-3 rounded-lg shadow-lg z-50 animate-in slide-in-from-bottom-5 duration-300">
<div class="flex items-center gap-2">
<div class="fixed bottom-5 right-5 {colors[type]} text-white px-5 py-3 rounded-lg shadow-lg z-50 animate-in slide-in-from-bottom-5 duration-300 min-w-[300px] backdrop-blur-sm">
<div class="flex items-center gap-3">
{#if icons[type]}
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<svg class="w-6 h-6 flex-shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
{@html icons[type]}
</svg>
{/if}
<span>{message}</span>
<span class="font-medium text-sm">{message}</span>
</div>
</div>
<style>
@keyframes slide-in-from-bottom {
from {
transform: translateY(400px);
transform: translateY(100px);
opacity: 0;
}
to {

View File

@@ -75,17 +75,24 @@
).length;
}
function getMatchingSpotsForCallsign(callsign) {
const spots = watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
function getMatchingSpotsForCallsign(callsign) {
const spots = watchlistSpots.filter(s => s.dx === callsign || s.dx.startsWith(callsign));
// ✅ Trier par bande d'abord, puis par heure
const bandOrder = { '160M': 0, '80M': 1, '60M': 2, '40M': 3, '30M': 4, '20M': 5, '17M': 6, '15M': 7, '12M': 8, '10M': 9, '6M': 10 };
return spots.sort((a, b) => {
// Trier par bande en premier
const bandA = bandOrder[a.band] ?? 99;
const bandB = bandOrder[b.band] ?? 99;
if (bandA !== bandB) return bandA - bandB;
// ✅ Trier les spots par heure décroissante (plus récent en premier)
return spots.sort((a, b) => {
// Comparer les heures UTC (format "HH:MM")
const timeA = a.utcTime || "00:00";
const timeB = b.utcTime || "00:00";
return timeB.localeCompare(timeA);
});
}
// Si même bande, trier par heure (plus récent en premier)
const timeA = a.utcTime || "00:00";
const timeB = b.utcTime || "00:00";
return timeB.localeCompare(timeA);
});
}
async function addToWatchlist() {
const callsign = newCallsign.trim().toUpperCase();

View File

@@ -22,21 +22,23 @@ import (
var frontendFiles embed.FS
type HTTPServer struct {
Router *mux.Router
FlexRepo *FlexDXClusterRepository
ContactRepo *Log4OMContactsRepository
TCPServer *TCPServer
TCPClient *TCPClient
FlexClient *FlexClient
Port string
Log *log.Logger
statsCache Stats
statsMutex sync.RWMutex
lastUpdate time.Time
wsClients map[*websocket.Conn]bool
wsMutex sync.RWMutex
broadcast chan WSMessage
Watchlist *Watchlist
Router *mux.Router
FlexRepo *FlexDXClusterRepository
ContactRepo *Log4OMContactsRepository
TCPServer *TCPServer
TCPClient *TCPClient
FlexClient *FlexClient
Port string
Log *log.Logger
lastQSOCount int
lastBandOpening map[string]time.Time
statsCache Stats
statsMutex sync.RWMutex
lastUpdate time.Time
wsClients map[*websocket.Conn]bool
wsMutex sync.RWMutex
broadcast chan WSMessage
Watchlist *Watchlist
}
type Stats struct {
@@ -99,17 +101,19 @@ func NewHTTPServer(flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContact
tcpServer *TCPServer, tcpClient *TCPClient, flexClient *FlexClient, port string) *HTTPServer {
server := &HTTPServer{
Router: mux.NewRouter(),
FlexRepo: flexRepo,
ContactRepo: contactRepo,
TCPServer: tcpServer,
TCPClient: tcpClient,
FlexClient: flexClient,
Port: port,
Log: Log,
wsClients: make(map[*websocket.Conn]bool),
broadcast: make(chan WSMessage, 256),
Watchlist: NewWatchlist("watchlist.json"),
Router: mux.NewRouter(),
FlexRepo: flexRepo,
ContactRepo: contactRepo,
TCPServer: tcpServer,
TCPClient: tcpClient,
FlexClient: flexClient,
Port: port,
Log: Log,
wsClients: make(map[*websocket.Conn]bool),
broadcast: make(chan WSMessage, 256),
Watchlist: NewWatchlist("watchlist.json"),
lastQSOCount: 0,
lastBandOpening: make(map[string]time.Time),
}
server.setupRoutes()
@@ -239,7 +243,7 @@ func (s *HTTPServer) sendInitialData(conn *websocket.Conn) {
conn.WriteJSON(WSMessage{Type: "watchlist", Data: watchlist})
// Send initial log data
qsos := s.ContactRepo.GetRecentQSOs("8")
qsos := s.ContactRepo.GetRecentQSOs("13")
conn.WriteJSON(WSMessage{Type: "log", Data: qsos})
logStats := s.ContactRepo.GetQSOStats()
@@ -276,8 +280,11 @@ func (s *HTTPServer) handleBroadcasts() {
func (s *HTTPServer) broadcastUpdates() {
statsTicker := time.NewTicker(1 * time.Second)
logTicker := time.NewTicker(10 * time.Second)
cleanupTicker := time.NewTicker(5 * time.Minute) // ✅ AJOUTER
defer statsTicker.Stop()
defer logTicker.Stop()
defer cleanupTicker.Stop() // ✅ AJOUTER
for {
select {
@@ -295,7 +302,8 @@ func (s *HTTPServer) broadcastUpdates() {
s.broadcast <- WSMessage{Type: "stats", Data: stats}
// Broadcast spots
spots := s.FlexRepo.GetAllSpots("0")
spots := s.FlexRepo.GetAllSpots("300")
s.checkBandOpening(spots)
s.broadcast <- WSMessage{Type: "spots", Data: spots}
// Broadcast spotters
@@ -312,10 +320,11 @@ func (s *HTTPServer) broadcastUpdates() {
}
// Broadcast log data every 10 seconds
qsos := s.ContactRepo.GetRecentQSOs("8")
qsos := s.ContactRepo.GetRecentQSOs("13")
s.broadcast <- WSMessage{Type: "log", Data: qsos}
stats := s.ContactRepo.GetQSOStats()
s.checkQSOMilestones(stats.Today)
s.broadcast <- WSMessage{Type: "logStats", Data: stats}
dxccCount := s.ContactRepo.GetDXCCCount()
@@ -324,11 +333,72 @@ func (s *HTTPServer) broadcastUpdates() {
"total": 340,
"percentage": float64(dxccCount) / 340.0 * 100.0,
}
s.broadcast <- WSMessage{Type: "dxccProgress", Data: dxccData}
}
}
}
func (s *HTTPServer) checkQSOMilestones(todayCount int) {
s.statsMutex.Lock()
defer s.statsMutex.Unlock()
if todayCount == s.lastQSOCount {
return
}
milestones := []int{5, 10, 25, 50, 100, 200, 500}
for _, milestone := range milestones {
if todayCount >= milestone && s.lastQSOCount < milestone {
s.broadcast <- WSMessage{
Type: "milestone",
Data: map[string]interface{}{
"type": "qso",
"count": milestone,
"message": fmt.Sprintf("🎉 %d QSOs today!", milestone),
},
}
}
}
s.lastQSOCount = todayCount
}
func (s *HTTPServer) checkBandOpening(spots []FlexSpot) {
s.statsMutex.Lock()
defer s.statsMutex.Unlock()
bandCounts := make(map[string]int)
for _, spot := range spots {
bandCounts[spot.Band]++
}
// ✅ Seulement surveiller 6M, 10M et 12M
monitoredBands := []string{"6M", "10M", "12M"}
now := time.Now()
for _, band := range monitoredBands {
count := bandCounts[band]
if count >= 20 { // Si 20+ spots sur une bande
lastSeen, exists := s.lastBandOpening[band]
// Notifier si première fois ou si pas vu depuis 2 heures
if !exists || now.Sub(lastSeen) > 2*time.Hour {
s.lastBandOpening[band] = now
s.broadcast <- WSMessage{
Type: "milestone",
Data: map[string]interface{}{
"type": "band",
"band": band,
"count": count,
"message": fmt.Sprintf("📡 %s opening detected! (%d spots)", band, count),
},
}
}
}
}
}
func (s *HTTPServer) getRecentQSOs(w http.ResponseWriter, r *http.Request) {
limitStr := r.URL.Query().Get("limit")
if limitStr == "" {

12
main.go
View File

@@ -68,18 +68,24 @@ func main() {
defer cRepo.db.Close()
contacts := cRepo.CountEntries()
log.Infof("Log4OM Database Contains %v Contacts", contacts)
defer cRepo.db.Close()
// ✅ Créer le canal pour le traitement centralisé des spots
SpotChanToHTTPServer := make(chan TelnetSpot, 100)
// Initialize servers and clients
TCPServer := NewTCPServer(cfg.TelnetServer.Host, cfg.TelnetServer.Port)
TCPClient := NewTCPClient(TCPServer, Countries, cRepo)
FlexClient := NewFlexClient(*fRepo, TCPServer, TCPClient.SpotChanToFlex, nil)
TCPClient := NewTCPClient(TCPServer, Countries, cRepo, SpotChanToHTTPServer)
FlexClient := NewFlexClient(*fRepo, TCPServer, nil, nil)
// Initialize HTTP Server for Dashboard
HTTPServer := NewHTTPServer(fRepo, cRepo, TCPServer, TCPClient, FlexClient, "8080")
FlexClient.HTTPServer = HTTPServer
spotProcessor := NewSpotProcessor(fRepo, FlexClient, HTTPServer, SpotChanToHTTPServer)
go spotProcessor.Start()
// Start all services
go FlexClient.StartFlexClient()
go TCPClient.StartClient()

27
spot.go
View File

@@ -28,8 +28,6 @@ type TelnetSpot struct {
CallsignWorked bool
}
// var spotNumber = 1
func ProcessTelnetSpot(re *regexp.Regexp, spotRaw string, SpotChanToFlex chan TelnetSpot, SpotChanToHTTPServer chan TelnetSpot, Countries Countries, contactRepo *Log4OMContactsRepository) {
match := re.FindStringSubmatch(spotRaw)
@@ -106,9 +104,16 @@ func ProcessTelnetSpot(re *regexp.Regexp, spotRaw string, SpotChanToFlex chan Te
spot.CallsignWorked = true
}
// Send spots to FlexRadio
SpotChanToFlex <- spot
// Envoyer TOUJOURS le spot vers le processeur principal (base de données + HTTP)
// Ce canal est maintenant géré par une goroutine dans main.go
select {
case SpotChanToHTTPServer <- spot:
// Spot envoyé avec succès
default:
Log.Warn("SpotChanToHTTPServer is full, spot may be lost")
}
// Logging des spots
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)
@@ -143,12 +148,7 @@ func ProcessTelnetSpot(re *regexp.Regexp, spotRaw string, SpotChanToFlex chan Te
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() {
@@ -381,6 +381,15 @@ func (spot *TelnetSpot) GuessMode() {
spot.Mode = "FM"
}
}
} else {
spot.Mode = strings.ToUpper(spot.Mode)
if spot.Mode == "SSB" {
if spot.Band == "10M" || spot.Band == "12M" || spot.Band == "6M" || spot.Band == "15M" || spot.Band == "17M" || spot.Band == "20M" {
spot.Mode = "USB"
} else {
spot.Mode = "LSB"
}
}
}
if spot.Mode == "" {

201
spotprocessor.go Normal file
View File

@@ -0,0 +1,201 @@
package main
import (
"fmt"
"strings"
"time"
)
type SpotProcessor struct {
FlexRepo *FlexDXClusterRepository
FlexClient *FlexClient
HTTPServer *HTTPServer
SpotChan chan TelnetSpot
}
func NewSpotProcessor(flexRepo *FlexDXClusterRepository, flexClient *FlexClient, httpServer *HTTPServer, spotChan chan TelnetSpot) *SpotProcessor {
return &SpotProcessor{
FlexRepo: flexRepo,
FlexClient: flexClient,
HTTPServer: httpServer,
SpotChan: spotChan,
}
}
func (sp *SpotProcessor) Start() {
Log.Info("Starting Spot Processor...")
for spot := range sp.SpotChan {
sp.processSpot(spot)
}
}
func (sp *SpotProcessor) processSpot(spot TelnetSpot) {
freq := FreqMhztoHz(spot.Frequency)
flexSpot := FlexSpot{
CommandNumber: CommandNumber,
DX: spot.DX,
FrequencyMhz: freq,
FrequencyHz: spot.Frequency,
Band: spot.Band,
Mode: spot.Mode,
Source: "FlexDXCluster",
SpotterCallsign: spot.Spotter,
TimeStamp: time.Now().Unix(),
UTCTime: spot.Time,
LifeTime: Cfg.Flex.SpotLife,
OriginalComment: spot.Comment,
Comment: spot.Comment,
Color: "#ffeaeaea",
BackgroundColor: "#ff000000",
Priority: "5",
NewDXCC: spot.NewDXCC,
NewBand: spot.NewBand,
NewMode: spot.NewMode,
NewSlot: spot.NewSlot,
Worked: spot.CallsignWorked,
InWatchlist: false,
CountryName: spot.CountryName,
DXCC: spot.DXCC,
}
flexSpot.Comment = flexSpot.Comment + " [" + flexSpot.Mode + "] [" + flexSpot.SpotterCallsign + "] [" + flexSpot.UTCTime + "]"
if sp.HTTPServer != nil && sp.HTTPServer.Watchlist != nil {
if sp.HTTPServer.Watchlist.Matches(flexSpot.DX) {
flexSpot.InWatchlist = true
flexSpot.Comment = flexSpot.Comment + " [Watchlist]"
Log.Infof("🎯 Watchlist match: %s", flexSpot.DX)
}
}
sp.applySpotColors(&flexSpot, spot)
Gotify(flexSpot)
flexSpot.Comment = strings.ReplaceAll(flexSpot.Comment, " ", "\u00A0")
srcFlexSpot, err := sp.FlexRepo.FindDXSameBand(flexSpot)
if err != nil {
Log.Debugf("Could not find the DX in the database: %v", err)
}
// Vérifier si le spot trouvé est valide (a un ID)
if srcFlexSpot != nil && srcFlexSpot.ID == 0 {
srcFlexSpot = nil
}
sp.handleSpotStorage(flexSpot, srcFlexSpot)
if sp.FlexClient != nil && sp.FlexClient.Enabled && sp.FlexClient.IsConnected {
sp.sendToFlexRadio(flexSpot, srcFlexSpot)
}
}
func (sp *SpotProcessor) applySpotColors(flexSpot *FlexSpot, spot TelnetSpot) {
if spot.NewDXCC {
flexSpot.Priority = "1"
flexSpot.Comment = flexSpot.Comment + " [New DXCC]"
if Cfg.General.SpotColorNewEntity != "" {
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 != "" {
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 != "" {
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 != "" {
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 != "" {
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]"
}
}
func (sp *SpotProcessor) handleSpotStorage(flexSpot FlexSpot, srcFlexSpot *FlexSpot) {
if srcFlexSpot == nil {
sp.FlexRepo.CreateSpot(flexSpot)
CommandNumber++
if sp.HTTPServer != nil {
sp.HTTPServer.broadcast <- WSMessage{Type: "spots", Data: sp.FlexRepo.GetAllSpots("0")}
}
} else if srcFlexSpot.Band == flexSpot.Band {
sp.FlexRepo.DeleteSpotByFlexSpotNumber(fmt.Sprintf("%d", srcFlexSpot.FlexSpotNumber))
sp.FlexRepo.CreateSpot(flexSpot)
CommandNumber++
} else {
sp.FlexRepo.CreateSpot(flexSpot)
CommandNumber++
}
}
func (sp *SpotProcessor) sendToFlexRadio(flexSpot FlexSpot, srcFlexSpot *FlexSpot) {
var stringSpot string
if srcFlexSpot == nil {
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++
sp.FlexClient.SendSpot(stringSpot)
} else if srcFlexSpot.Band == flexSpot.Band {
stringSpot = fmt.Sprintf("C%v|spot remove %v", flexSpot.CommandNumber, srcFlexSpot.FlexSpotNumber)
sp.FlexClient.SendSpot(stringSpot)
CommandNumber++
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++
sp.FlexClient.SendSpot(stringSpot)
} else {
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++
sp.FlexClient.SendSpot(stringSpot)
}
}

View File

@@ -1 +1 @@
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB"]
["H44MS","5X2I","PY0FB","PY0FBS","XT2AW","ZL7IO","YJ0CA","FW5K","J38","E6AD","E51MWA","PJ6Y","5J0EA","5K0UA","VP2M","5X1XA","C5R","C5LT","EL2BG","4X6TT","V85NPV","YI1MB","C21TS","XV9","TJ1GD"]