package main import ( "embed" "encoding/json" "encoding/xml" "fmt" "io" "io/fs" "net/http" "os" "strings" "sync" "time" "github.com/google/uuid" "github.com/gorilla/mux" "github.com/gorilla/websocket" log "github.com/sirupsen/logrus" ) //go:embed frontend/dist/* var frontendFiles embed.FS var httpServerInstance *HTTPServer type HTTPServer struct { 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 { TotalSpots int `json:"totalSpots"` NewDXCC int `json:"newDXCC"` ConnectedClients int `json:"connectedClients"` TotalContacts int `json:"totalContacts"` ClusterStatus string `json:"clusterStatus"` FlexStatus string `json:"flexStatus"` MyCallsign string `json:"myCallsign"` Mode string `json:"mode"` Filters Filters `json:"filters"` SpotsReceived int64 `json:"spotsReceived"` SpotsProcessed int64 `json:"spotsProcessed"` SpotsRejected int64 `json:"spotsRejected"` SpotSuccessRate float64 `json:"spotSuccessRate"` } type Filters struct { Skimmer bool `json:"skimmer"` FT8 bool `json:"ft8"` FT4 bool `json:"ft4"` Beacon bool `json:"beacon"` } type APIResponse struct { Success bool `json:"success"` Data interface{} `json:"data,omitempty"` Error string `json:"error,omitempty"` Message string `json:"message,omitempty"` } type WSMessage struct { Type string `json:"type"` Data interface{} `json:"data"` } type SendCallsignRequest struct { Callsign string `json:"callsign"` } type WatchlistSpot struct { DX string `json:"dx"` FrequencyMhz string `json:"frequencyMhz"` Band string `json:"band"` Mode string `json:"mode"` SpotterCallsign string `json:"spotterCallsign"` UTCTime string `json:"utcTime"` CountryName string `json:"countryName"` NewDXCC bool `json:"newDXCC"` NewBand bool `json:"newBand"` NewMode bool `json:"newMode"` Worked bool `json:"worked"` WorkedBandMode bool `json:"workedBandMode"` } type RemoteControlRequestFreq struct { XMLName xml.Name `xml:"RemoteControlRequest"` MessageId string `xml:"MessageId"` RemoteControlMessage string `xml:"RemoteControlMessage"` Frequency string `xml:"Frequency"` } type RemoteControlRequestMode struct { XMLName xml.Name `xml:"RemoteControlRequest"` MessageId string `xml:"MessageId"` RemoteControlMessage string `xml:"RemoteControlMessage"` Mode string `xml:"Mode"` } var upgrader = websocket.Upgrader{ CheckOrigin: func(r *http.Request) bool { return true // Allow all origins in development }, } func NewHTTPServer(flexRepo *FlexDXClusterRepository, contactRepo *Log4OMContactsRepository, tcpServer *TCPServer, tcpClient *TCPClient, flexClient *FlexClient, port string) *HTTPServer { server := &HTTPServer{ Router: mux.NewRouter(), FlexRepo: flexRepo, ContactRepo: contactRepo, TCPServer: tcpServer, TCPClient: tcpClient, FlexClient: flexClient, Port: port, Log: Log, wsClients: make(map[*websocket.Conn]bool), broadcast: make(chan WSMessage, 256), Watchlist: NewWatchlist("watchlist.json"), lastQSOCount: 0, lastBandOpening: make(map[string]time.Time), } httpServerInstance = server server.setupRoutes() go server.handleBroadcasts() go server.broadcastUpdates() return server } func (s *HTTPServer) setupRoutes() { // Enable CORS s.Router.Use(s.corsMiddleware) // API Routes api := s.Router.PathPrefix("/api").Subrouter() api.HandleFunc("/stats", s.getStats).Methods("GET", "OPTIONS") api.HandleFunc("/spots", s.getSpots).Methods("GET", "OPTIONS") api.HandleFunc("/spots/{id}", s.getSpotByID).Methods("GET", "OPTIONS") api.HandleFunc("/contacts", s.getContacts).Methods("GET", "OPTIONS") api.HandleFunc("/filters", s.updateFilters).Methods("POST", "OPTIONS") api.HandleFunc("/shutdown", s.shutdownApp).Methods("POST", "OPTIONS") api.HandleFunc("/send-callsign", s.handleSendCallsign).Methods("POST", "OPTIONS") api.HandleFunc("/watchlist", s.getWatchlist).Methods("GET", "OPTIONS") api.HandleFunc("/solar", s.HandleSolarData).Methods("GET", "OPTIONS") api.HandleFunc("/log/recent", s.getRecentQSOs).Methods("GET", "OPTIONS") api.HandleFunc("/log/stats", s.getLogStats).Methods("GET", "OPTIONS") api.HandleFunc("/log/dxcc-progress", s.getDXCCProgress).Methods("GET", "OPTIONS") api.HandleFunc("/watchlist/spots", s.getWatchlistSpotsWithStatus).Methods("GET", "OPTIONS") api.HandleFunc("/watchlist/add", s.addToWatchlist).Methods("POST", "OPTIONS") api.HandleFunc("/watchlist/remove", s.removeFromWatchlist).Methods("DELETE", "OPTIONS") api.HandleFunc("/watchlist/update-sound", s.updateWatchlistSound).Methods("POST", "OPTIONS") api.HandleFunc("/stats/spots", s.getSpotProcessingStats).Methods("GET", "OPTIONS") api.HandleFunc("/logs", s.getLogs).Methods("GET", "OPTIONS") // WebSocket endpoint api.HandleFunc("/ws", s.handleWebSocket).Methods("GET") s.setupStaticFiles() } func (s *HTTPServer) setupStaticFiles() { // Obtenir le sous-système de fichiers depuis dist/ distFS, err := fs.Sub(frontendFiles, "frontend/dist") if err != nil { s.Log.Fatal("Cannot load frontend files:", err) } spaHandler := http.FileServer(http.FS(distFS)) s.Router.PathPrefix("/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { path := r.URL.Path // Vérifier si le fichier existe if path != "/" { file, err := distFS.Open(strings.TrimPrefix(path, "/")) if err == nil { file.Close() spaHandler.ServeHTTP(w, r) return } } // Si pas trouvé ou racine, servir index.html r.URL.Path = "/" spaHandler.ServeHTTP(w, r) })) } func (s *HTTPServer) handleWebSocket(w http.ResponseWriter, r *http.Request) { conn, err := upgrader.Upgrade(w, r, nil) if err != nil { s.Log.Errorf("WebSocket upgrade failed: %v", err) return } s.wsMutex.Lock() s.wsClients[conn] = true clientCount := len(s.wsClients) s.wsMutex.Unlock() s.Log.Infof("New WebSocket client connected (total: %d)", clientCount) // Send initial data s.sendInitialData(conn) // Keep connection alive and handle client messages go s.handleWebSocketClient(conn) } func (s *HTTPServer) handleWebSocketClient(conn *websocket.Conn) { defer func() { s.wsMutex.Lock() delete(s.wsClients, conn) clientCount := len(s.wsClients) s.wsMutex.Unlock() conn.Close() s.Log.Infof("WebSocket client disconnected (remaining: %d)", clientCount) }() // Read messages from client (for ping/pong) for { _, _, err := conn.ReadMessage() if err != nil { if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { s.Log.Errorf("WebSocket error: %v", err) } break } } } func (s *HTTPServer) sendInitialData(conn *websocket.Conn) { // Send initial stats stats := s.calculateStats() conn.WriteJSON(WSMessage{Type: "stats", Data: stats}) // Send initial spots spots := s.FlexRepo.GetAllSpots("0") conn.WriteJSON(WSMessage{Type: "spots", Data: spots}) // Send initial watchlist watchlist := s.Watchlist.GetAll() conn.WriteJSON(WSMessage{Type: "watchlist", Data: watchlist}) // Send initial log data qsos := s.ContactRepo.GetRecentQSOs("19") conn.WriteJSON(WSMessage{Type: "log", Data: qsos}) logStats := s.ContactRepo.GetQSOStats() conn.WriteJSON(WSMessage{Type: "logStats", Data: logStats}) dxccCount := s.ContactRepo.GetDXCCCount() dxccData := map[string]interface{}{ "worked": dxccCount, "total": 340, "percentage": float64(dxccCount) / 340.0 * 100.0, } conn.WriteJSON(WSMessage{Type: "dxccProgress", Data: dxccData}) if logBuffer != nil { logs := logBuffer.GetAll() conn.WriteJSON(WSMessage{Type: "appLogs", Data: logs}) } } func (s *HTTPServer) handleBroadcasts() { for msg := range s.broadcast { s.wsMutex.RLock() for client := range s.wsClients { err := client.WriteJSON(msg) if err != nil { s.Log.Errorf("WebSocket write error: %v", err) client.Close() s.wsMutex.RUnlock() s.wsMutex.Lock() delete(s.wsClients, client) s.wsMutex.Unlock() s.wsMutex.RLock() } } s.wsMutex.RUnlock() } } func (s *HTTPServer) broadcastUpdates() { statsTicker := time.NewTicker(1 * time.Second) logTicker := time.NewTicker(5 * time.Second) cleanupTicker := time.NewTicker(5 * time.Minute) watchlistSaveTicker := time.NewTicker(20 * time.Second) defer statsTicker.Stop() defer logTicker.Stop() defer cleanupTicker.Stop() defer watchlistSaveTicker.Stop() for { select { case <-statsTicker.C: s.wsMutex.RLock() clientCount := len(s.wsClients) s.wsMutex.RUnlock() if clientCount == 0 { continue } // Broadcast stats stats := s.calculateStats() s.broadcast <- WSMessage{Type: "stats", Data: stats} // Broadcast spots spots := s.FlexRepo.GetAllSpots("0") s.checkBandOpening(spots) s.broadcast <- WSMessage{Type: "spots", Data: spots} case <-logTicker.C: s.wsMutex.RLock() clientCount := len(s.wsClients) s.wsMutex.RUnlock() if clientCount == 0 { continue } // Broadcast log data every 10 seconds qsos := s.ContactRepo.GetRecentQSOs("19") 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() dxccData := map[string]interface{}{ "worked": dxccCount, "total": 340, "percentage": float64(dxccCount) / 340.0 * 100.0, } s.broadcast <- WSMessage{Type: "dxccProgress", Data: dxccData} case <-watchlistSaveTicker.C: // Sauvegarder la watchlist périodiquement if s.Watchlist != nil { s.Watchlist.save() } } } } func (s *HTTPServer) getLogs(w http.ResponseWriter, r *http.Request) { if logBuffer == nil { s.sendJSON(w, APIResponse{Success: true, Data: []LogEntry{}}) return } logs := logBuffer.GetAll() s.sendJSON(w, APIResponse{Success: true, Data: logs}) } func (s *HTTPServer) getSpotProcessingStats(w http.ResponseWriter, r *http.Request) { received, processed, rejected := GetSpotStats() successRate := GetSpotSuccessRate() stats := map[string]interface{}{ "received": received, "processed": processed, "rejected": rejected, "successRate": successRate, } s.sendJSON(w, APIResponse{Success: true, Data: stats}) } 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 == "" { limitStr = "50" } qsos := s.ContactRepo.GetRecentQSOs(limitStr) s.sendJSON(w, APIResponse{Success: true, Data: qsos}) } func (s *HTTPServer) getLogStats(w http.ResponseWriter, r *http.Request) { stats := s.ContactRepo.GetQSOStats() s.sendJSON(w, APIResponse{Success: true, Data: stats}) } func (s *HTTPServer) getDXCCProgress(w http.ResponseWriter, r *http.Request) { workedCount := s.ContactRepo.GetDXCCCount() data := map[string]interface{}{ "worked": workedCount, "total": 340, "percentage": float64(workedCount) / 340.0 * 100.0, } s.sendJSON(w, APIResponse{Success: true, Data: data}) } func (s *HTTPServer) calculateStats() Stats { allSpots := s.FlexRepo.GetAllSpots("0") contacts := s.ContactRepo.CountEntries() newDXCCCount := 0 for _, spot := range allSpots { if spot.NewDXCC { newDXCCCount++ } } clusterStatus := "disconnected" if s.TCPClient != nil && s.TCPClient.LoggedIn { clusterStatus = "connected" } flexStatus := "disconnected" if s.FlexClient != nil && s.FlexClient.IsConnected { flexStatus = "connected" } // Récupérer les stats de traitement des spots received, processed, rejected := GetSpotStats() successRate := GetSpotSuccessRate() return Stats{ TotalSpots: len(allSpots), NewDXCC: newDXCCCount, ConnectedClients: len(s.TCPServer.Clients), TotalContacts: contacts, ClusterStatus: clusterStatus, FlexStatus: flexStatus, MyCallsign: Cfg.General.Callsign, Filters: Filters{ Skimmer: Cfg.Cluster.Skimmer, FT8: Cfg.Cluster.FT8, FT4: Cfg.Cluster.FT4, Beacon: Cfg.Cluster.Beacon, }, SpotsReceived: received, SpotsProcessed: processed, SpotsRejected: rejected, SpotSuccessRate: successRate, } } func (s *HTTPServer) corsMiddleware(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Access-Control-Allow-Origin", "*") w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS") w.Header().Set("Access-Control-Allow-Headers", "Content-Type, Authorization") if r.Method == "OPTIONS" { w.WriteHeader(http.StatusOK) return } next.ServeHTTP(w, r) }) } func (s *HTTPServer) getStats(w http.ResponseWriter, r *http.Request) { stats := s.calculateStats() s.sendJSON(w, APIResponse{Success: true, Data: stats}) } func (s *HTTPServer) getSpots(w http.ResponseWriter, r *http.Request) { limitStr := r.URL.Query().Get("limit") if limitStr == "" { limitStr = "50" } spots := s.FlexRepo.GetAllSpots(limitStr) s.sendJSON(w, APIResponse{Success: true, Data: spots}) } func (s *HTTPServer) getSpotByID(w http.ResponseWriter, r *http.Request) { vars := mux.Vars(r) id := vars["id"] spot, err := s.FlexRepo.FindSpotByFlexSpotNumber(id) if err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Spot not found"}) return } s.sendJSON(w, APIResponse{Success: true, Data: spot}) } func (s *HTTPServer) getContacts(w http.ResponseWriter, r *http.Request) { count := s.ContactRepo.CountEntries() data := map[string]interface{}{"totalContacts": count} s.sendJSON(w, APIResponse{Success: true, Data: data}) } type FilterRequest struct { Skimmer *bool `json:"skimmer,omitempty"` FT8 *bool `json:"ft8,omitempty"` FT4 *bool `json:"ft4,omitempty"` Beacon *bool `json:"beacon,omitempty"` } func (s *HTTPServer) updateFilters(w http.ResponseWriter, r *http.Request) { var req FilterRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"}) return } commands := []string{} if req.Skimmer != nil { if *req.Skimmer { commands = append(commands, "set/skimmer") Cfg.Cluster.Skimmer = true } else { commands = append(commands, "set/noskimmer") Cfg.Cluster.Skimmer = false } } if req.FT8 != nil { if *req.FT8 { commands = append(commands, "set/ft8") Cfg.Cluster.FT8 = true } else { commands = append(commands, "set/noft8") Cfg.Cluster.FT8 = false } } if req.FT4 != nil { if *req.FT4 { commands = append(commands, "set/ft4") Cfg.Cluster.FT4 = true } else { commands = append(commands, "set/noft4") Cfg.Cluster.FT4 = false } } if req.Beacon != nil { if *req.Beacon { commands = append(commands, "set/beacon") Cfg.Cluster.Beacon = true } else { commands = append(commands, "set/nobeacon") Cfg.Cluster.Beacon = false } } for _, cmd := range commands { s.TCPClient.CmdChan <- cmd } s.sendJSON(w, APIResponse{Success: true, Data: map[string]string{"message": "Filters updated successfully"}}) } func (s *HTTPServer) getWatchlist(w http.ResponseWriter, r *http.Request) { callsigns := s.Watchlist.GetAll() s.sendJSON(w, APIResponse{Success: true, Data: callsigns}) } func (s *HTTPServer) addToWatchlist(w http.ResponseWriter, r *http.Request) { var req struct { Callsign string `json:"callsign"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"}) return } if req.Callsign == "" { s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"}) return } if err := s.Watchlist.Add(req.Callsign); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: err.Error()}) return } s.Log.Infof("Added %s to watchlist", req.Callsign) // Broadcast updated watchlist to all clients s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()} s.sendJSON(w, APIResponse{Success: true, Message: "Callsign added to watchlist"}) } func (s *HTTPServer) removeFromWatchlist(w http.ResponseWriter, r *http.Request) { var req struct { Callsign string `json:"callsign"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"}) return } if req.Callsign == "" { s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"}) return } if err := s.Watchlist.Remove(req.Callsign); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: err.Error()}) return } s.Log.Debugf("Removed %s from watchlist", req.Callsign) // Broadcast updated watchlist to all clients s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()} s.sendJSON(w, APIResponse{Success: true, Message: "Callsign removed from watchlist"}) } func (s *HTTPServer) updateWatchlistNotes(w http.ResponseWriter, r *http.Request) { var req struct { Callsign string `json:"callsign"` Notes string `json:"notes"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"}) return } if req.Callsign == "" { s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"}) return } s.Log.Debugf("Updated notes for %s", req.Callsign) // Broadcast updated watchlist to all clients s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()} s.sendJSON(w, APIResponse{Success: true, Message: "Notes updated"}) } func (s *HTTPServer) updateWatchlistSound(w http.ResponseWriter, r *http.Request) { var req struct { Callsign string `json:"callsign"` PlaySound bool `json:"playSound"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"}) return } if req.Callsign == "" { s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"}) return } if err := s.Watchlist.UpdateSound(req.Callsign, req.PlaySound); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: err.Error()}) return } s.Log.Debugf("Updated sound setting for %s to %v", req.Callsign, req.PlaySound) // Broadcast updated watchlist to all clients s.broadcast <- WSMessage{Type: "watchlist", Data: s.Watchlist.GetAll()} s.sendJSON(w, APIResponse{Success: true, Message: "Sound setting updated"}) } func (s *HTTPServer) getWatchlistSpotsWithStatus(w http.ResponseWriter, r *http.Request) { // Récupérer tous les spots allSpots := s.FlexRepo.GetAllSpots("0") // Récupérer la watchlist (maintenant ce sont des WatchlistEntry) watchlistEntries := s.Watchlist.GetAll() // Extraire juste les callsigns pour la comparaison watchlistCallsigns := make([]string, len(watchlistEntries)) for i, entry := range watchlistEntries { watchlistCallsigns[i] = entry.Callsign } // Filtrer les spots de la watchlist var relevantSpots []FlexSpot for _, spot := range allSpots { isInWatchlist := false for _, pattern := range watchlistCallsigns { if spot.DX == pattern || strings.HasPrefix(spot.DX, pattern) { isInWatchlist = true break } } if isInWatchlist { relevantSpots = append(relevantSpots, spot) } } type BandModeKey struct { Band string Mode string } spotsByBandMode := make(map[BandModeKey][]FlexSpot) for _, spot := range relevantSpots { key := BandModeKey{Band: spot.Band, Mode: spot.Mode} spotsByBandMode[key] = append(spotsByBandMode[key], spot) } var watchlistSpots []WatchlistSpot for key, spots := range spotsByBandMode { // Extraire les callsigns uniques callsignSet := make(map[string]bool) for _, spot := range spots { callsignSet[spot.DX] = true } callsigns := make([]string, 0, len(callsignSet)) for callsign := range callsignSet { callsigns = append(callsigns, callsign) } workedMap := s.ContactRepo.GetWorkedCallsignsBandMode(callsigns, key.Band, key.Mode) // Construire les résultats for _, spot := range spots { watchlistSpots = append(watchlistSpots, WatchlistSpot{ DX: spot.DX, FrequencyMhz: spot.FrequencyMhz, Band: spot.Band, Mode: spot.Mode, SpotterCallsign: spot.SpotterCallsign, UTCTime: spot.UTCTime, CountryName: spot.CountryName, NewDXCC: spot.NewDXCC, NewBand: spot.NewBand, NewMode: spot.NewMode, Worked: spot.Worked, WorkedBandMode: workedMap[spot.DX], }) } } s.sendJSON(w, APIResponse{Success: true, Data: watchlistSpots}) } func (s *HTTPServer) HandleSolarData(w http.ResponseWriter, r *http.Request) { // Récupérer les données depuis hamqsl.com resp, err := http.Get("https://www.hamqsl.com/solarxml.php") if err != nil { s.Log.Errorf("Error fetching solar data: %v", err) s.sendJSON(w, APIResponse{Success: false, Error: "Failed to fetch solar data"}) return } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { s.Log.Errorf("Error reading solar data: %v", err) s.sendJSON(w, APIResponse{Success: false, Error: "Failed to read solar data"}) return } var solarXML SolarXML err = xml.Unmarshal(body, &solarXML) if err != nil { s.Log.Errorf("Error parsing solar XML: %v", err) s.sendJSON(w, APIResponse{Success: false, Error: "Failed to parse solar data"}) return } response := map[string]interface{}{ "sfi": solarXML.Data.SolarFlux, "sunspots": solarXML.Data.Sunspots, "aIndex": solarXML.Data.AIndex, "kIndex": solarXML.Data.KIndex, "updated": solarXML.Data.Updated, } s.sendJSON(w, APIResponse{Success: true, Data: response}) } func (s *HTTPServer) handleSendCallsign(w http.ResponseWriter, r *http.Request) { var req struct { Callsign string `json:"callsign"` Frequency string `json:"frequency"` Mode string `json:"mode"` } if err := json.NewDecoder(r.Body).Decode(&req); err != nil { s.sendJSON(w, APIResponse{Success: false, Error: "Invalid request"}) return } if req.Callsign == "" { s.sendJSON(w, APIResponse{Success: false, Error: "Callsign is required"}) return } SendUDPMessage([]byte("" + req.Callsign)) s.Log.Infof("Sent callsign %s to Log4OM via UDP (127.0.0.1:2241)", req.Callsign) if Cfg.General.SendFreqModeToLog { freqLog4OM := strings.Replace(req.Frequency, ".", "", 1) xmlRequestFreq := RemoteControlRequestFreq{ MessageId: uuid.New().String(), // Generate a new unique ID RemoteControlMessage: "SetTxFrequency", // Note: Typo matches your required format Frequency: freqLog4OM, } xmlRequestMode := RemoteControlRequestMode{ MessageId: uuid.New().String(), // Generate a new unique ID RemoteControlMessage: "SetMode", // Note: Typo matches your required format Mode: req.Mode, } xmlBytesFreq, err := xml.MarshalIndent(xmlRequestFreq, "", " ") if err != nil { s.Log.Errorf("Failed to marshal XML: %v", err) } else { SendUDPMessage([]byte(xmlBytesFreq)) } xmlBytesMode, err := xml.MarshalIndent(xmlRequestMode, "", " ") if err != nil { s.Log.Errorf("Failed to marshal XML: %v", err) } else { SendUDPMessage([]byte(xmlBytesMode)) } } if req.Frequency != "" && s.FlexClient != nil && s.FlexClient.IsConnected { tuneCmd := fmt.Sprintf("C%v|slice tune 0 %s", CommandNumber, req.Frequency) s.FlexClient.Write(tuneCmd) CommandNumber++ time.Sleep(time.Millisecond * 500) modeCmd := fmt.Sprintf("C%v|slice s 0 mode=%s", CommandNumber, req.Mode) s.FlexClient.Write(modeCmd) CommandNumber++ s.Log.Infof("Sent TUNE command to Flex: %s", tuneCmd) } s.sendJSON(w, APIResponse{ Success: true, Message: "Callsign sent to Log4OM and radio tuned", Data: map[string]string{"callsign": req.Callsign, "frequency": req.Frequency}, }) } func (s *HTTPServer) sendJSON(w http.ResponseWriter, data interface{}) { w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(data) } // ✅ Fonction de shutdown propre func (s *HTTPServer) shutdownApp(w http.ResponseWriter, r *http.Request) { s.Log.Info("Shutdown request received from dashboard") s.sendJSON(w, APIResponse{Success: true, Data: map[string]string{"message": "Shutting down FlexDXCluster"}}) go func() { time.Sleep(500 * time.Millisecond) // ✅ Utiliser le shutdown centralisé GracefulShutdown(s.TCPClient, s.TCPServer, s.FlexClient, s.FlexRepo, s.ContactRepo) os.Exit(0) }() } func (s *HTTPServer) Start() { s.Log.Infof("HTTP Server starting on port %s", s.Port) s.Log.Infof("Dashboard available at http://localhost:%s", s.Port) if err := http.ListenAndServe(":"+s.Port, s.Router); err != nil { s.Log.Fatalf("Failed to start HTTP server: %v", err) } }