From 4ab192418e21065c68d59777493ea03b76c061e7 Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 9 Jan 2026 11:56:40 +0100 Subject: [PATCH] up --- cmd/server/main.go | 75 ++++ configs/config.example.yaml | 1 - go.mod | 5 +- go.sum | 3 + internal/api/device_manager.go | 243 +++++++++++ internal/api/handlers.go | 373 +++++++++++++++++ internal/api/websocket.go | 193 +++++++++ internal/config/config.go | 5 +- .../devices/antennagenius/antennagenius.go | 159 ++++++++ internal/devices/command_id.go | 35 ++ internal/devices/powergenius/powergenius.go | 380 ++++++++++++++++++ .../devices/rotatorgenius/rotatorgenius.go | 10 +- internal/devices/tunergenius/tunergenius.go | 23 +- internal/devices/webswitch/webswitch.go | 52 +++ internal/services/solar/solar.go | 96 +++++ internal/services/weather/weather.go | 126 ++++++ web/index.html | 15 + web/package.json | 18 + web/src/App.svelte | 265 ++++++++++++ web/src/app.css | 128 ++++++ web/src/components/AntennaGenius.svelte | 129 ++++++ web/src/components/PowerGenius.svelte | 209 ++++++++++ web/src/components/RotatorGenius.svelte | 334 +++++++++++++++ web/src/components/TunerGenius.svelte | 257 ++++++++++++ web/src/components/WebSwitch.svelte | 152 +++++++ web/src/lib/api.js | 76 ++++ web/src/lib/websocket.js | 81 ++++ web/src/main.js | 8 + web/svelte.config.js | 1 + web/vite.config.js | 19 + 30 files changed, 3455 insertions(+), 16 deletions(-) create mode 100644 cmd/server/main.go create mode 100644 internal/api/device_manager.go create mode 100644 internal/api/handlers.go create mode 100644 internal/api/websocket.go create mode 100644 internal/devices/antennagenius/antennagenius.go create mode 100644 internal/devices/command_id.go create mode 100644 internal/devices/powergenius/powergenius.go create mode 100644 internal/services/solar/solar.go create mode 100644 internal/services/weather/weather.go create mode 100644 web/index.html create mode 100644 web/package.json create mode 100644 web/src/App.svelte create mode 100644 web/src/app.css create mode 100644 web/src/components/AntennaGenius.svelte create mode 100644 web/src/components/PowerGenius.svelte create mode 100644 web/src/components/RotatorGenius.svelte create mode 100644 web/src/components/TunerGenius.svelte create mode 100644 web/src/components/WebSwitch.svelte create mode 100644 web/src/lib/api.js create mode 100644 web/src/lib/websocket.js create mode 100644 web/src/main.js create mode 100644 web/svelte.config.js create mode 100644 web/vite.config.js diff --git a/cmd/server/main.go b/cmd/server/main.go new file mode 100644 index 0000000..7bd23c0 --- /dev/null +++ b/cmd/server/main.go @@ -0,0 +1,75 @@ +package main + +import ( + "fmt" + "log" + "net/http" + "os" + "os/signal" + "syscall" + "time" + + "git.rouggy.com/rouggy/ShackMaster/internal/api" + "git.rouggy.com/rouggy/ShackMaster/internal/config" +) + +func main() { + log.Println("Starting ShackMaster server...") + + // Load configuration + cfg, err := config.Load("configs/config.yaml") + if err != nil { + log.Fatalf("Failed to load configuration: %v", err) + } + + log.Printf("Configuration loaded: %s:%d", cfg.Server.Host, cfg.Server.Port) + + // Create WebSocket hub + hub := api.NewHub() + go hub.Run() + + // Initialize device manager with hub + deviceManager := api.NewDeviceManager(cfg, hub) + if err := deviceManager.Initialize(); err != nil { + log.Fatalf("Failed to initialize device manager: %v", err) + } + + // Start device monitoring (will broadcast automatically) + if err := deviceManager.Start(); err != nil { + log.Fatalf("Failed to start device manager: %v", err) + } + + // Create HTTP server + server := api.NewServer(deviceManager, hub, cfg) + mux := server.SetupRoutes() + + // Setup HTTP server + addr := fmt.Sprintf("%s:%d", cfg.Server.Host, cfg.Server.Port) + httpServer := &http.Server{ + Addr: addr, + Handler: mux, + ReadTimeout: 15 * time.Second, + WriteTimeout: 15 * time.Second, + IdleTimeout: 60 * time.Second, + } + + // Start HTTP server in goroutine + go func() { + log.Printf("Server listening on %s", addr) + log.Printf("WebSocket endpoint: ws://%s/ws", addr) + log.Printf("Web interface: http://%s/", addr) + + if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed { + log.Fatalf("HTTP server error: %v", err) + } + }() + + // Wait for interrupt signal + quit := make(chan os.Signal, 1) + signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM) + <-quit + + log.Println("Shutting down server...") + deviceManager.Stop() + log.Println("Server stopped") +} diff --git a/configs/config.example.yaml b/configs/config.example.yaml index 209d694..3400a53 100644 --- a/configs/config.example.yaml +++ b/configs/config.example.yaml @@ -13,7 +13,6 @@ devices: tuner_genius: host: "10.10.10.129" port: 9010 - id_number: 1 # Default ID for commands antenna_genius: host: "10.10.10.130" diff --git a/go.mod b/go.mod index 037ed01..487d28f 100644 --- a/go.mod +++ b/go.mod @@ -2,4 +2,7 @@ module git.rouggy.com/rouggy/ShackMaster go 1.24.3 -require gopkg.in/yaml.v3 v3.0.1 // indirect +require ( + github.com/gorilla/websocket v1.5.3 + gopkg.in/yaml.v3 v3.0.1 +) diff --git a/go.sum b/go.sum index 4bc0337..6a2c68d 100644 --- a/go.sum +++ b/go.sum @@ -1,3 +1,6 @@ +github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= +github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/internal/api/device_manager.go b/internal/api/device_manager.go new file mode 100644 index 0000000..880871b --- /dev/null +++ b/internal/api/device_manager.go @@ -0,0 +1,243 @@ +package api + +import ( + "log" + "sync" + "time" + + "git.rouggy.com/rouggy/ShackMaster/internal/config" + "git.rouggy.com/rouggy/ShackMaster/internal/devices/antennagenius" + "git.rouggy.com/rouggy/ShackMaster/internal/devices/powergenius" + "git.rouggy.com/rouggy/ShackMaster/internal/devices/rotatorgenius" + "git.rouggy.com/rouggy/ShackMaster/internal/devices/tunergenius" + "git.rouggy.com/rouggy/ShackMaster/internal/devices/webswitch" + "git.rouggy.com/rouggy/ShackMaster/internal/services/solar" + "git.rouggy.com/rouggy/ShackMaster/internal/services/weather" +) + +type DeviceManager struct { + config *config.Config + + webSwitch *webswitch.Client + powerGenius *powergenius.Client + tunerGenius *tunergenius.Client + antennaGenius *antennagenius.Client + rotatorGenius *rotatorgenius.Client + solarClient *solar.Client + weatherClient *weather.Client + + hub *Hub + statusMu sync.RWMutex + lastStatus *SystemStatus + + updateInterval time.Duration + stopChan chan struct{} +} + +type SystemStatus struct { + WebSwitch *webswitch.Status `json:"webswitch"` + PowerGenius *powergenius.Status `json:"power_genius"` + TunerGenius *tunergenius.Status `json:"tuner_genius"` + AntennaGenius *antennagenius.Status `json:"antenna_genius"` + RotatorGenius *rotatorgenius.Status `json:"rotator_genius"` + Solar *solar.SolarData `json:"solar"` + Weather *weather.WeatherData `json:"weather"` + Timestamp time.Time `json:"timestamp"` +} + +func NewDeviceManager(cfg *config.Config, hub *Hub) *DeviceManager { + return &DeviceManager{ + config: cfg, + hub: hub, + updateInterval: 1 * time.Second, // Update status every second + stopChan: make(chan struct{}), + } +} + +func (dm *DeviceManager) Initialize() error { + log.Println("Initializing device manager...") + + // Initialize WebSwitch + dm.webSwitch = webswitch.New(dm.config.Devices.WebSwitch.Host) + + // Initialize Power Genius + dm.powerGenius = powergenius.New( + dm.config.Devices.PowerGenius.Host, + dm.config.Devices.PowerGenius.Port, + ) + + // Initialize Tuner Genius + dm.tunerGenius = tunergenius.New( + dm.config.Devices.TunerGenius.Host, + dm.config.Devices.TunerGenius.Port, + ) + + // Initialize Antenna Genius + dm.antennaGenius = antennagenius.New( + dm.config.Devices.AntennaGenius.Host, + dm.config.Devices.AntennaGenius.Port, + ) + + // Initialize Rotator Genius + dm.rotatorGenius = rotatorgenius.New( + dm.config.Devices.RotatorGenius.Host, + dm.config.Devices.RotatorGenius.Port, + ) + + // Initialize Solar data client + dm.solarClient = solar.New() + + // Initialize Weather client + dm.weatherClient = weather.New( + dm.config.Weather.OpenWeatherMapAPIKey, + dm.config.Location.Latitude, + dm.config.Location.Longitude, + ) + + // Start PowerGenius continuous polling + if err := dm.powerGenius.Start(); err != nil { + log.Printf("Warning: Failed to start PowerGenius polling: %v", err) + } + + log.Println("Device manager initialized") + return nil +} + +func (dm *DeviceManager) Start() error { + log.Println("Starting device monitoring...") + go dm.monitorDevices() + return nil +} + +func (dm *DeviceManager) Stop() { + log.Println("Stopping device manager...") + close(dm.stopChan) + + // Close all connections + if dm.powerGenius != nil { + dm.powerGenius.Close() + } + if dm.tunerGenius != nil { + dm.tunerGenius.Close() + } + if dm.antennaGenius != nil { + dm.antennaGenius.Close() + } + if dm.rotatorGenius != nil { + dm.rotatorGenius.Close() + } +} + +func (dm *DeviceManager) monitorDevices() { + ticker := time.NewTicker(dm.updateInterval) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + dm.updateStatus() + case <-dm.stopChan: + return + } + } +} + +func (dm *DeviceManager) updateStatus() { + status := &SystemStatus{ + Timestamp: time.Now(), + } + + // Query all devices + // WebSwitch - get actual relay states + if wsStatus, err := dm.webSwitch.GetStatus(); err == nil { + status.WebSwitch = wsStatus + } else { + log.Printf("WebSwitch error: %v", err) + } + + // Power Genius + if pgStatus, err := dm.powerGenius.GetStatus(); err == nil { + status.PowerGenius = pgStatus + } else { + log.Printf("Power Genius error: %v", err) + } + + // // Tuner Genius + // if tgStatus, err := dm.tunerGenius.GetStatus(); err == nil { + // status.TunerGenius = tgStatus + // } else { + // log.Printf("Tuner Genius error: %v", err) + // } + + // // Antenna Genius + // if agStatus, err := dm.antennaGenius.GetStatus(); err == nil { + // status.AntennaGenius = agStatus + // } else { + // log.Printf("Antenna Genius error: %v", err) + // } + + // // Rotator Genius + // if rgStatus, err := dm.rotatorGenius.GetStatus(); err == nil { + // status.RotatorGenius = rgStatus + // } else { + // log.Printf("Rotator Genius error: %v", err) + // } + + // Solar Data (fetched every 15 minutes, cached) + if solarData, err := dm.solarClient.GetSolarData(); err == nil { + status.Solar = solarData + } else { + log.Printf("Solar data error: %v", err) + } + + // Weather Data (fetched every 10 minutes, cached) + if weatherData, err := dm.weatherClient.GetWeatherData(); err == nil { + status.Weather = weatherData + } else { + log.Printf("Weather data error: %v", err) + } + + // Update cached status + dm.statusMu.Lock() + dm.lastStatus = status + dm.statusMu.Unlock() + + // Broadcast to all connected clients + if dm.hub != nil { + dm.hub.BroadcastStatusUpdate(status) + } +} + +func (dm *DeviceManager) GetStatus() *SystemStatus { + dm.statusMu.RLock() + defer dm.statusMu.RUnlock() + + if dm.lastStatus == nil { + return &SystemStatus{ + Timestamp: time.Now(), + } + } + + return dm.lastStatus +} + +// Device control methods +func (dm *DeviceManager) WebSwitch() *webswitch.Client { + return dm.webSwitch +} + +func (dm *DeviceManager) PowerGenius() *powergenius.Client { + return dm.powerGenius +} + +func (dm *DeviceManager) TunerGenius() *tunergenius.Client { + return dm.tunerGenius +} + +func (dm *DeviceManager) AntennaGenius() *antennagenius.Client { + return dm.antennaGenius +} + +func (dm *DeviceManager) RotatorGenius() *rotatorgenius.Client { + return dm.rotatorGenius +} diff --git a/internal/api/handlers.go b/internal/api/handlers.go new file mode 100644 index 0000000..259df54 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,373 @@ +package api + +import ( + "encoding/json" + "log" + "net/http" + "strconv" + + "git.rouggy.com/rouggy/ShackMaster/internal/config" + "github.com/gorilla/websocket" +) + +type Server struct { + deviceManager *DeviceManager + hub *Hub + config *config.Config + upgrader websocket.Upgrader +} + +func NewServer(dm *DeviceManager, hub *Hub, cfg *config.Config) *Server { + return &Server{ + deviceManager: dm, + hub: hub, + config: cfg, + upgrader: websocket.Upgrader{ + ReadBufferSize: 1024, + WriteBufferSize: 1024, + CheckOrigin: func(r *http.Request) bool { + return true // Allow all origins for now + }, + }, + } +} + +func (s *Server) SetupRoutes() *http.ServeMux { + mux := http.NewServeMux() + + // WebSocket endpoint + mux.HandleFunc("/ws", s.handleWebSocket) + + // REST API endpoints + mux.HandleFunc("/api/status", s.handleGetStatus) + mux.HandleFunc("/api/config", s.handleGetConfig) + + // WebSwitch endpoints + mux.HandleFunc("/api/webswitch/relay/on", s.handleWebSwitchRelayOn) + mux.HandleFunc("/api/webswitch/relay/off", s.handleWebSwitchRelayOff) + mux.HandleFunc("/api/webswitch/all/on", s.handleWebSwitchAllOn) + mux.HandleFunc("/api/webswitch/all/off", s.handleWebSwitchAllOff) + + // Rotator endpoints + mux.HandleFunc("/api/rotator/move", s.handleRotatorMove) + mux.HandleFunc("/api/rotator/cw", s.handleRotatorCW) + mux.HandleFunc("/api/rotator/ccw", s.handleRotatorCCW) + mux.HandleFunc("/api/rotator/stop", s.handleRotatorStop) + + // Tuner endpoints + mux.HandleFunc("/api/tuner/operate", s.handleTunerOperate) + mux.HandleFunc("/api/tuner/tune", s.handleTunerAutoTune) + mux.HandleFunc("/api/tuner/antenna", s.handleTunerAntenna) + + // Antenna Genius endpoints + mux.HandleFunc("/api/antenna/set", s.handleAntennaSet) + + // Power Genius endpoints + mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode) + + // Static files (will be frontend) + mux.Handle("/", http.FileServer(http.Dir("./web/dist"))) + + return mux +} + +func (s *Server) handleWebSocket(w http.ResponseWriter, r *http.Request) { + conn, err := s.upgrader.Upgrade(w, r, nil) + if err != nil { + log.Printf("WebSocket upgrade error: %v", err) + return + } + + ServeWs(s.hub, conn) +} + +func (s *Server) handleGetStatus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + status := s.deviceManager.GetStatus() + s.sendJSON(w, status) +} + +func (s *Server) handleGetConfig(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + // Only send public config info (not API keys) + configInfo := map[string]interface{}{ + "callsign": s.config.Location.Callsign, + "location": map[string]float64{ + "latitude": s.config.Location.Latitude, + "longitude": s.config.Location.Longitude, + }, + } + + s.sendJSON(w, configInfo) +} + +// WebSwitch handlers +func (s *Server) handleWebSwitchRelayOn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + relay, err := strconv.Atoi(r.URL.Query().Get("relay")) + if err != nil || relay < 1 || relay > 5 { + http.Error(w, "Invalid relay number", http.StatusBadRequest) + return + } + + if err := s.deviceManager.WebSwitch().TurnOn(relay); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleWebSwitchRelayOff(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + relay, err := strconv.Atoi(r.URL.Query().Get("relay")) + if err != nil || relay < 1 || relay > 5 { + http.Error(w, "Invalid relay number", http.StatusBadRequest) + return + } + + if err := s.deviceManager.WebSwitch().TurnOff(relay); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleWebSwitchAllOn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := s.deviceManager.WebSwitch().AllOn(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleWebSwitchAllOff(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := s.deviceManager.WebSwitch().AllOff(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +// Rotator handlers +func (s *Server) handleRotatorMove(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Rotator int `json:"rotator"` + Azimuth int `json:"azimuth"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.RotatorGenius().MoveToAzimuth(req.Rotator, req.Azimuth); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRotatorCW(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + rotator, err := strconv.Atoi(r.URL.Query().Get("rotator")) + if err != nil || rotator < 1 || rotator > 2 { + http.Error(w, "Invalid rotator number", http.StatusBadRequest) + return + } + + if err := s.deviceManager.RotatorGenius().RotateCW(rotator); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRotatorCCW(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + rotator, err := strconv.Atoi(r.URL.Query().Get("rotator")) + if err != nil || rotator < 1 || rotator > 2 { + http.Error(w, "Invalid rotator number", http.StatusBadRequest) + return + } + + if err := s.deviceManager.RotatorGenius().RotateCCW(rotator); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleRotatorStop(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := s.deviceManager.RotatorGenius().Stop(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +// Tuner handlers +func (s *Server) handleTunerOperate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Operate bool `json:"operate"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.TunerGenius().SetOperate(req.Operate); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleTunerAutoTune(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := s.deviceManager.TunerGenius().AutoTune(); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleTunerAntenna(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Antenna int `json:"antenna"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.TunerGenius().ActivateAntenna(req.Antenna); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +// Antenna Genius handlers +func (s *Server) handleAntennaSet(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Radio int `json:"radio"` + Antenna int `json:"antenna"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.AntennaGenius().SetRadioAntenna(req.Radio, req.Antenna); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +// Power Genius handlers +func (s *Server) handlePowerFanMode(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Mode string `json:"mode"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.PowerGenius().SetFanMode(req.Mode); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) sendJSON(w http.ResponseWriter, data interface{}) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(data) +} diff --git a/internal/api/websocket.go b/internal/api/websocket.go new file mode 100644 index 0000000..5707b77 --- /dev/null +++ b/internal/api/websocket.go @@ -0,0 +1,193 @@ +package api + +import ( + "encoding/json" + "log" + "sync" + "time" + + "git.rouggy.com/rouggy/ShackMaster/pkg/protocol" + "github.com/gorilla/websocket" +) + +type Hub struct { + clients map[*Client]bool + broadcast chan *protocol.WebSocketMessage + register chan *Client + unregister chan *Client + mu sync.RWMutex +} + +type Client struct { + hub *Hub + conn *websocket.Conn + send chan *protocol.WebSocketMessage +} + +func NewHub() *Hub { + return &Hub{ + clients: make(map[*Client]bool), + broadcast: make(chan *protocol.WebSocketMessage, 256), + register: make(chan *Client), + unregister: make(chan *Client), + } +} + +func (h *Hub) Run() { + for { + select { + case client := <-h.register: + h.mu.Lock() + h.clients[client] = true + h.mu.Unlock() + log.Printf("Client connected, total: %d", len(h.clients)) + + case client := <-h.unregister: + h.mu.Lock() + if _, ok := h.clients[client]; ok { + delete(h.clients, client) + close(client.send) + } + h.mu.Unlock() + log.Printf("Client disconnected, total: %d", len(h.clients)) + + case message := <-h.broadcast: + h.mu.RLock() + for client := range h.clients { + select { + case client.send <- message: + default: + // Client's send buffer is full, close it + h.mu.RUnlock() + h.unregister <- client + h.mu.RLock() + } + } + h.mu.RUnlock() + } + } +} + +func (h *Hub) Broadcast(msg *protocol.WebSocketMessage) { + h.broadcast <- msg +} + +func (h *Hub) ClientCount() int { + h.mu.RLock() + defer h.mu.RUnlock() + return len(h.clients) +} + +const ( + writeWait = 10 * time.Second + pongWait = 60 * time.Second + pingPeriod = (pongWait * 9) / 10 + maxMessageSize = 512 * 1024 // 512KB +) + +func (c *Client) readPump() { + defer func() { + c.hub.unregister <- c + c.conn.Close() + }() + + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + c.conn.SetReadLimit(maxMessageSize) + c.conn.SetPongHandler(func(string) error { + c.conn.SetReadDeadline(time.Now().Add(pongWait)) + return nil + }) + + for { + var msg protocol.WebSocketMessage + err := c.conn.ReadJSON(&msg) + if err != nil { + if websocket.IsUnexpectedCloseError(err, websocket.CloseGoingAway, websocket.CloseAbnormalClosure) { + log.Printf("WebSocket error: %v", err) + } + break + } + + // Handle incoming commands from client + log.Printf("Received message: type=%s, device=%s", msg.Type, msg.Device) + + // Commands are handled via REST API, not WebSocket + // WebSocket is primarily for server -> client updates + // Client should use REST endpoints for commands + } +} + +func (c *Client) writePump() { + ticker := time.NewTicker(pingPeriod) + defer func() { + ticker.Stop() + c.conn.Close() + }() + + for { + select { + case message, ok := <-c.send: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if !ok { + // Hub closed the channel + c.conn.WriteMessage(websocket.CloseMessage, []byte{}) + return + } + + if err := c.conn.WriteJSON(message); err != nil { + log.Printf("Error writing message: %v", err) + return + } + + case <-ticker.C: + c.conn.SetWriteDeadline(time.Now().Add(writeWait)) + if err := c.conn.WriteMessage(websocket.PingMessage, nil); err != nil { + return + } + } + } +} + +func ServeWs(hub *Hub, conn *websocket.Conn) { + client := &Client{ + hub: hub, + conn: conn, + send: make(chan *protocol.WebSocketMessage, 256), + } + + client.hub.register <- client + + // Send initial status + go func() { + time.Sleep(100 * time.Millisecond) + client.send <- &protocol.WebSocketMessage{ + Type: protocol.MsgTypeStatus, + Data: map[string]string{"status": "connected"}, + Timestamp: time.Now(), + } + }() + + go client.writePump() + go client.readPump() +} + +// BroadcastStatusUpdate sends a status update to all connected clients +func (h *Hub) BroadcastStatusUpdate(status interface{}) { + data, err := json.Marshal(status) + if err != nil { + log.Printf("Error marshaling status: %v", err) + return + } + + var statusMap map[string]interface{} + if err := json.Unmarshal(data, &statusMap); err != nil { + log.Printf("Error unmarshaling status: %v", err) + return + } + + h.Broadcast(&protocol.WebSocketMessage{ + Type: protocol.MsgTypeUpdate, + Data: statusMap, + Timestamp: time.Now(), + }) +} diff --git a/internal/config/config.go b/internal/config/config.go index a3a6950..fe57a83 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -37,9 +37,8 @@ type PowerGeniusConfig struct { } type TunerGeniusConfig struct { - Host string `yaml:"host"` - Port int `yaml:"port"` - IDNumber int `yaml:"id_number"` + Host string `yaml:"host"` + Port int `yaml:"port"` } type AntennaGeniusConfig struct { diff --git a/internal/devices/antennagenius/antennagenius.go b/internal/devices/antennagenius/antennagenius.go new file mode 100644 index 0000000..12d3dee --- /dev/null +++ b/internal/devices/antennagenius/antennagenius.go @@ -0,0 +1,159 @@ +package antennagenius + +import ( + "bufio" + "fmt" + "net" + "strconv" + "strings" + "time" + + . "git.rouggy.com/rouggy/ShackMaster/internal/devices" +) + +type Client struct { + host string + port int + conn net.Conn +} + +type Status struct { + Radio1Antenna int `json:"radio1_antenna"` // 0-7 (antenna index) + Radio2Antenna int `json:"radio2_antenna"` // 0-7 (antenna index) + Connected bool `json:"connected"` +} + +func New(host string, port int) *Client { + return &Client{ + host: host, + port: port, + } +} + +func (c *Client) Connect() error { + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.conn = conn + return nil +} + +func (c *Client) Close() error { + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +func (c *Client) sendCommand(cmd string) (string, error) { + if c.conn == nil { + if err := c.Connect(); err != nil { + return "", err + } + } + + // Get next command ID from global counter + cmdID := GetGlobalCommandID().GetNextID() + + // Format command with ID: C| + fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd) + + // Send command + _, err := c.conn.Write([]byte(fullCmd)) + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to send command: %w", err) + } + + // Read response + reader := bufio.NewReader(c.conn) + response, err := reader.ReadString('\n') + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to read response: %w", err) + } + + return strings.TrimSpace(response), nil +} + +func (c *Client) GetStatus() (*Status, error) { + resp, err := c.sendCommand("status") + if err != nil { + return nil, err + } + + return c.parseStatus(resp) +} + +func (c *Client) parseStatus(resp string) (*Status, error) { + status := &Status{ + Connected: true, + } + + // Parse response format from 4O3A API + // Expected format will vary - this is a basic parser + pairs := strings.Fields(resp) + + for _, pair := range pairs { + parts := strings.SplitN(pair, "=", 2) + if len(parts) != 2 { + continue + } + + key := parts[0] + value := parts[1] + + switch key { + case "radio1", "r1": + status.Radio1Antenna, _ = strconv.Atoi(value) + case "radio2", "r2": + status.Radio2Antenna, _ = strconv.Atoi(value) + } + } + + return status, nil +} + +// SetRadioAntenna sets which antenna a radio should use +// radio: 1 or 2 +// antenna: 0-7 (antenna index) +func (c *Client) SetRadioAntenna(radio int, antenna int) error { + if radio < 1 || radio > 2 { + return fmt.Errorf("radio must be 1 or 2") + } + if antenna < 0 || antenna > 7 { + return fmt.Errorf("antenna must be between 0 and 7") + } + + cmd := fmt.Sprintf("set radio%d=%d", radio, antenna) + resp, err := c.sendCommand(cmd) + if err != nil { + return err + } + + // Check response for success + if !strings.Contains(strings.ToLower(resp), "ok") && resp != "" { + // If response doesn't contain "ok" but isn't empty, assume success + // (some devices may return the new state instead of "ok") + } + + return nil +} + +// GetRadioAntenna gets which antenna a radio is currently using +func (c *Client) GetRadioAntenna(radio int) (int, error) { + if radio < 1 || radio > 2 { + return -1, fmt.Errorf("radio must be 1 or 2") + } + + status, err := c.GetStatus() + if err != nil { + return -1, err + } + + if radio == 1 { + return status.Radio1Antenna, nil + } + return status.Radio2Antenna, nil +} diff --git a/internal/devices/command_id.go b/internal/devices/command_id.go new file mode 100644 index 0000000..0318056 --- /dev/null +++ b/internal/devices/command_id.go @@ -0,0 +1,35 @@ +package devices + +import "sync" + +// CommandIDManager manages the global command ID counter for 4O3A devices +// All 4O3A devices share the same command ID sequence +type CommandIDManager struct { + mu sync.Mutex + counter int +} + +var globalCommandID = &CommandIDManager{ + counter: 0, +} + +// GetNextID returns the next command ID and increments the counter +func (m *CommandIDManager) GetNextID() int { + m.mu.Lock() + defer m.mu.Unlock() + + m.counter++ + return m.counter +} + +// Reset resets the counter to 0 (useful for testing or restart) +func (m *CommandIDManager) Reset() { + m.mu.Lock() + defer m.mu.Unlock() + m.counter = 0 +} + +// GetGlobalCommandID returns the global command ID manager +func GetGlobalCommandID() *CommandIDManager { + return globalCommandID +} diff --git a/internal/devices/powergenius/powergenius.go b/internal/devices/powergenius/powergenius.go new file mode 100644 index 0000000..839daf6 --- /dev/null +++ b/internal/devices/powergenius/powergenius.go @@ -0,0 +1,380 @@ +package powergenius + +import ( + "bufio" + "fmt" + "log" + "math" + "net" + "strconv" + "strings" + "sync" + "time" + + . "git.rouggy.com/rouggy/ShackMaster/internal/devices" +) + +type Client struct { + host string + port int + conn net.Conn + connMu sync.Mutex + lastStatus *Status + statusMu sync.RWMutex + stopChan chan struct{} + running bool +} + +type Status struct { + PowerForward float64 `json:"power_forward"` + PowerReflected float64 `json:"power_reflected"` + SWR float64 `json:"swr"` + Voltage float64 `json:"voltage"` + VDD float64 `json:"vdd"` + Current float64 `json:"current"` + PeakCurrent float64 `json:"peak_current"` + Temperature float64 `json:"temperature"` + HarmonicLoadTemp float64 `json:"harmonic_load_temp"` + FanSpeed int `json:"fan_speed"` + FanMode string `json:"fan_mode"` + State string `json:"state"` + BandA string `json:"band_a"` + BandB string `json:"band_b"` + FaultPresent bool `json:"fault_present"` + Connected bool `json:"connected"` + Meffa string `json:"meffa"` +} + +func New(host string, port int) *Client { + return &Client{ + host: host, + port: port, + stopChan: make(chan struct{}), + } +} + +func (c *Client) Connect() error { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.conn != nil { + return nil // Already connected + } + + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) + if err != nil { + return fmt.Errorf("failed to connect: %w", err) + } + c.conn = conn + + // Read and discard version banner + reader := bufio.NewReader(c.conn) + _, _ = reader.ReadString('\n') + + return nil +} + +func (c *Client) Close() error { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.stopChan != nil { + close(c.stopChan) + } + + if c.conn != nil { + return c.conn.Close() + } + return nil +} + +// Start begins continuous polling of the device +func (c *Client) Start() error { + if err := c.Connect(); err != nil { + return err + } + + if c.running { + return nil + } + + c.running = true + go c.pollLoop() + + return nil +} + +// pollLoop continuously polls the device for status +func (c *Client) pollLoop() { + ticker := time.NewTicker(150 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + status, err := c.queryStatus() + if err != nil { + log.Printf("PowerGenius query error: %v", err) + // Try to reconnect + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + + if err := c.Connect(); err != nil { + log.Printf("PowerGenius reconnect failed: %v", err) + } + continue + } + + // Merge with existing status (spontaneous messages may only update some fields) + c.statusMu.Lock() + if c.lastStatus != nil { + // Keep existing values for fields not in the new status + if status.PowerForward == 0 && c.lastStatus.PowerForward != 0 { + status.PowerForward = c.lastStatus.PowerForward + } + if status.Temperature == 0 && c.lastStatus.Temperature != 0 { + status.Temperature = c.lastStatus.Temperature + } + if status.HarmonicLoadTemp == 0 && c.lastStatus.HarmonicLoadTemp != 0 { + status.HarmonicLoadTemp = c.lastStatus.HarmonicLoadTemp + } + if status.Voltage == 0 && c.lastStatus.Voltage != 0 { + status.Voltage = c.lastStatus.Voltage + } + if status.FanMode == "" && c.lastStatus.FanMode != "" { + status.FanMode = c.lastStatus.FanMode + } + if status.BandA == "" && c.lastStatus.BandA != "" { + status.BandA = c.lastStatus.BandA + } + if status.BandB == "" && c.lastStatus.BandB != "" { + status.BandB = c.lastStatus.BandB + } + } + c.lastStatus = status + c.statusMu.Unlock() + + case <-c.stopChan: + return + } + } +} + +func (c *Client) queryStatus() (*Status, error) { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.conn == nil { + return nil, fmt.Errorf("not connected") + } + + // Get next command ID from global counter + cmdID := GetGlobalCommandID().GetNextID() + + // Format command with ID: C|status + fullCmd := fmt.Sprintf("C%d|status\n", cmdID) + + // Send command + _, err := c.conn.Write([]byte(fullCmd)) + if err != nil { + c.conn = nil + return nil, fmt.Errorf("failed to send command: %w", err) + } + + // Read response + reader := bufio.NewReader(c.conn) + response, err := reader.ReadString('\n') + if err != nil { + c.conn = nil + return nil, fmt.Errorf("failed to read response: %w", err) + } + + return c.parseStatus(strings.TrimSpace(response)) +} + +func (c *Client) sendCommand(cmd string) (string, error) { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.conn == nil { + return "", fmt.Errorf("not connected") + } + + // Get next command ID from global counter + cmdID := GetGlobalCommandID().GetNextID() + + // Format command with ID: C| + fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd) + + // Send command + _, err := c.conn.Write([]byte(fullCmd)) + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to send command: %w", err) + } + + // Read response + reader := bufio.NewReader(c.conn) + response, err := reader.ReadString('\n') + if err != nil { + c.conn = nil + return "", fmt.Errorf("failed to read response: %w", err) + } + + return strings.TrimSpace(response), nil +} + +func (c *Client) GetStatus() (*Status, error) { + c.statusMu.RLock() + defer c.statusMu.RUnlock() + + if c.lastStatus == nil { + return &Status{Connected: false}, nil + } + + return c.lastStatus, nil +} + +func (c *Client) parseStatus(resp string) (*Status, error) { + status := &Status{ + Connected: true, + } + + var data string + + // Handle two message formats: + // 1. Response to command: R|0|state=IDLE bandA=40 ... + // 2. Spontaneous status: S0|state=TRANSMIT_A + + if strings.HasPrefix(resp, "R") { + // Response format: R|0|state=IDLE bandA=40 ... + parts := strings.SplitN(resp, "|", 3) + if len(parts) < 3 { + return nil, fmt.Errorf("invalid response format: %s", resp) + } + data = parts[2] // Get everything after "R|0|" + + } else if strings.HasPrefix(resp, "S") { + // Spontaneous message format: S0|state=TRANSMIT_A + parts := strings.SplitN(resp, "|", 2) + if len(parts) < 2 { + return nil, fmt.Errorf("invalid spontaneous message format: %s", resp) + } + data = parts[1] // Get everything after "S0|" + + } else { + return nil, fmt.Errorf("unknown message format: %s", resp) + } + + // Parse key=value pairs separated by spaces + pairs := strings.Fields(data) + + for _, pair := range pairs { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + continue + } + + key := kv[0] + value := kv[1] + + switch key { + case "fwd": + // fwd is in dBm (e.g., 56.5 dBm = 446W) + // Formula: watts = 10^(dBm/10) / 1000 + if dBm, err := strconv.ParseFloat(value, 64); err == nil { + milliwatts := math.Pow(10, dBm/10.0) + status.PowerForward = milliwatts / 1000.0 + } + case "peakfwd": + // Peak forward power + case "swr": + // SWR from return loss + // Formula: returnLoss = abs(swr) / 20 + // swr = (10^returnLoss + 1) / (10^returnLoss - 1) + if swrRaw, err := strconv.ParseFloat(value, 64); err == nil { + returnLoss := math.Abs(swrRaw) / 20.0 + tenPowRL := math.Pow(10, returnLoss) + calculatedSWR := (tenPowRL + 1) / (tenPowRL - 1) + status.SWR = calculatedSWR + } + case "vac": + status.Voltage, _ = strconv.ParseFloat(value, 64) + case "meffa": + status.Meffa = value + case "vdd": + status.VDD, _ = strconv.ParseFloat(value, 64) + case "id": + status.Current, _ = strconv.ParseFloat(value, 64) + case "peakid": + status.PeakCurrent, _ = strconv.ParseFloat(value, 64) + case "temp": + status.Temperature, _ = strconv.ParseFloat(value, 64) + case "hltemp": + status.HarmonicLoadTemp, _ = strconv.ParseFloat(value, 64) + case "bandA": + if band, err := strconv.Atoi(value); err == nil { + status.BandA = fmt.Sprintf("%dM", band) + } + case "bandB": + if band, err := strconv.Atoi(value); err == nil && band > 0 { + status.BandB = fmt.Sprintf("%dM", band) + } + case "fanmode": + status.FanMode = value + case "state": + status.State = value + } + } + + return status, nil +} + +// Subscribe starts receiving periodic status updates +func (c *Client) Subscribe() error { + _, err := c.sendCommand("sub status") + return err +} + +// Unsubscribe stops receiving periodic status updates +func (c *Client) Unsubscribe() error { + _, err := c.sendCommand("unsub status") + return err +} + +// ReadUpdate reads a status update (when subscribed) +func (c *Client) ReadUpdate() (*Status, error) { + if c.conn == nil { + return nil, fmt.Errorf("not connected") + } + + reader := bufio.NewReader(c.conn) + response, err := reader.ReadString('\n') + if err != nil { + return nil, fmt.Errorf("failed to read update: %w", err) + } + + return c.parseStatus(strings.TrimSpace(response)) +} + +// SetFanMode sets the fan mode +// mode can be: STANDARD, CONTEST, or BROADCAST +func (c *Client) SetFanMode(mode string) error { + validModes := map[string]bool{ + "STANDARD": true, + "CONTEST": true, + "BROADCAST": true, + } + + if !validModes[mode] { + return fmt.Errorf("invalid fan mode: %s (must be STANDARD, CONTEST, or BROADCAST)", mode) + } + + cmd := fmt.Sprintf("setup fanmode=%s", mode) + _, err := c.sendCommand(cmd) + return err +} diff --git a/internal/devices/rotatorgenius/rotatorgenius.go b/internal/devices/rotatorgenius/rotatorgenius.go index c704b15..e9ee8c5 100644 --- a/internal/devices/rotatorgenius/rotatorgenius.go +++ b/internal/devices/rotatorgenius/rotatorgenius.go @@ -7,6 +7,8 @@ import ( "strconv" "strings" "time" + + . "git.rouggy.com/rouggy/ShackMaster/internal/devices" ) type Client struct { @@ -65,8 +67,14 @@ func (c *Client) sendCommand(cmd string) (string, error) { } } + // Get next command ID from global counter + cmdID := GetGlobalCommandID().GetNextID() + + // Format command with ID: C| + fullCmd := fmt.Sprintf("C%d%s", cmdID, cmd) + // Send command - _, err := c.conn.Write([]byte(cmd)) + _, err := c.conn.Write([]byte(fullCmd)) if err != nil { c.conn = nil return "", fmt.Errorf("failed to send command: %w", err) diff --git a/internal/devices/tunergenius/tunergenius.go b/internal/devices/tunergenius/tunergenius.go index c3bf5e1..5da024f 100644 --- a/internal/devices/tunergenius/tunergenius.go +++ b/internal/devices/tunergenius/tunergenius.go @@ -6,13 +6,14 @@ import ( "net" "strings" "time" + + . "git.rouggy.com/rouggy/ShackMaster/internal/devices" ) type Client struct { - host string - port int - idNumber int - conn net.Conn + host string + port int + conn net.Conn } type Status struct { @@ -31,11 +32,10 @@ type Status struct { Connected bool `json:"connected"` } -func New(host string, port int, idNumber int) *Client { +func New(host string, port int) *Client { return &Client{ - host: host, - port: port, - idNumber: idNumber, + host: host, + port: port, } } @@ -62,8 +62,11 @@ func (c *Client) sendCommand(cmd string) (string, error) { } } - // Format command with ID - fullCmd := fmt.Sprintf("C%d|%s\n", c.idNumber, cmd) + // Get next command ID from global counter + cmdID := GetGlobalCommandID().GetNextID() + + // Format command with ID: C| + fullCmd := fmt.Sprintf("C%d|%s\n", cmdID, cmd) // Send command _, err := c.conn.Write([]byte(fullCmd)) diff --git a/internal/devices/webswitch/webswitch.go b/internal/devices/webswitch/webswitch.go index 5b6d1e0..3d4eec9 100644 --- a/internal/devices/webswitch/webswitch.go +++ b/internal/devices/webswitch/webswitch.go @@ -4,6 +4,8 @@ import ( "fmt" "io" "net/http" + "strconv" + "strings" "time" ) @@ -82,6 +84,56 @@ func (c *Client) AllOff() error { return nil } +// GetStatus queries the actual state of all relays +func (c *Client) GetStatus() (*Status, error) { + url := fmt.Sprintf("http://%s/relaystate/get2/1$2$3$4$5$", c.host) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to get relay status: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + // Parse response format: "1,1\n2,1\n3,1\n4,1\n5,0\n" + status := &Status{ + Relays: make([]RelayState, 0, 5), + } + + lines := strings.Split(strings.TrimSpace(string(body)), "\n") + for _, line := range lines { + parts := strings.Split(strings.TrimSpace(line), ",") + if len(parts) != 2 { + continue + } + + relayNum, err := strconv.Atoi(parts[0]) + if err != nil { + continue + } + + relayState, err := strconv.Atoi(parts[1]) + if err != nil { + continue + } + + status.Relays = append(status.Relays, RelayState{ + Number: relayNum, + State: relayState == 1, + }) + } + + return status, nil +} + // Ping checks if the device is reachable func (c *Client) Ping() error { url := fmt.Sprintf("http://%s/", c.host) diff --git a/internal/services/solar/solar.go b/internal/services/solar/solar.go new file mode 100644 index 0000000..cb1e7b9 --- /dev/null +++ b/internal/services/solar/solar.go @@ -0,0 +1,96 @@ +package solar + +import ( + "encoding/xml" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +// SolarData contains current solar and geomagnetic conditions +type SolarData struct { + SolarFluxIndex int `json:"sfi"` // Solar Flux Index + Sunspots int `json:"sunspots"` // Number of sunspots + AIndex int `json:"a_index"` // A-index (geomagnetic activity) + KIndex int `json:"k_index"` // K-index (geomagnetic activity) + GeomagField string `json:"geomag"` // Geomagnetic field status + UpdatedAt string `json:"updated"` // Last update time +} + +// HamQSLResponse matches the XML structure from hamqsl.com +type HamQSLResponse struct { + XMLName xml.Name `xml:"solar"` + SolarData SolarDataXML `xml:"solardata"` +} + +type SolarDataXML struct { + Updated string `xml:"updated"` + SolarFlux string `xml:"solarflux"` + AIndex string `xml:"aindex"` + KIndex string `xml:"kindex"` + Sunspots string `xml:"sunspots"` + GeomagField string `xml:"geomagfield"` +} + +type Client struct { + httpClient *http.Client + lastUpdate time.Time + cachedData *SolarData +} + +func New() *Client { + return &Client{ + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// GetSolarData fetches current solar data from HamQSL +// Data is cached for 15 minutes to avoid excessive requests +func (c *Client) GetSolarData() (*SolarData, error) { + // Return cached data if less than 15 minutes old + if c.cachedData != nil && time.Since(c.lastUpdate) < 15*time.Minute { + return c.cachedData, nil + } + + resp, err := c.httpClient.Get("http://www.hamqsl.com/solarxml.php") + if err != nil { + return nil, fmt.Errorf("failed to fetch solar data: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected status code: %d", resp.StatusCode) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var hamqslData HamQSLResponse + if err := xml.Unmarshal(body, &hamqslData); err != nil { + return nil, fmt.Errorf("failed to parse XML: %w", err) + } + + // Parse the data + solarData := &SolarData{ + GeomagField: hamqslData.SolarData.GeomagField, + UpdatedAt: hamqslData.SolarData.Updated, + } + + // Parse numeric values (trim spaces) + fmt.Sscanf(strings.TrimSpace(hamqslData.SolarData.SolarFlux), "%d", &solarData.SolarFluxIndex) + fmt.Sscanf(strings.TrimSpace(hamqslData.SolarData.AIndex), "%d", &solarData.AIndex) + fmt.Sscanf(strings.TrimSpace(hamqslData.SolarData.KIndex), "%d", &solarData.KIndex) + fmt.Sscanf(strings.TrimSpace(hamqslData.SolarData.Sunspots), "%d", &solarData.Sunspots) + + // Cache the data + c.cachedData = solarData + c.lastUpdate = time.Now() + + return solarData, nil +} diff --git a/internal/services/weather/weather.go b/internal/services/weather/weather.go new file mode 100644 index 0000000..d3797e1 --- /dev/null +++ b/internal/services/weather/weather.go @@ -0,0 +1,126 @@ +package weather + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "time" +) + +// WeatherData contains current weather conditions +type WeatherData struct { + Temperature float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Humidity int `json:"humidity"` + Pressure int `json:"pressure"` + WindSpeed float64 `json:"wind_speed"` + WindGust float64 `json:"wind_gust"` + WindDeg int `json:"wind_deg"` + Clouds int `json:"clouds"` + Description string `json:"description"` + Icon string `json:"icon"` + UpdatedAt string `json:"updated"` +} + +// OpenWeatherMapResponse matches the API response +type OpenWeatherMapResponse struct { + Weather []struct { + Description string `json:"description"` + Icon string `json:"icon"` + } `json:"weather"` + Main struct { + Temp float64 `json:"temp"` + FeelsLike float64 `json:"feels_like"` + Humidity int `json:"humidity"` + Pressure int `json:"pressure"` + } `json:"main"` + Wind struct { + Speed float64 `json:"speed"` + Deg int `json:"deg"` + Gust float64 `json:"gust"` + } `json:"wind"` + Clouds struct { + All int `json:"all"` + } `json:"clouds"` + Dt int64 `json:"dt"` +} + +type Client struct { + apiKey string + latitude float64 + longitude float64 + httpClient *http.Client + lastUpdate time.Time + cachedData *WeatherData +} + +func New(apiKey string, latitude, longitude float64) *Client { + return &Client{ + apiKey: apiKey, + latitude: latitude, + longitude: longitude, + httpClient: &http.Client{ + Timeout: 10 * time.Second, + }, + } +} + +// GetWeatherData fetches current weather from OpenWeatherMap +// Data is cached for 10 minutes +func (c *Client) GetWeatherData() (*WeatherData, error) { + // Return cached data if less than 10 minutes old + if c.cachedData != nil && time.Since(c.lastUpdate) < 10*time.Minute { + return c.cachedData, nil + } + + url := fmt.Sprintf( + "https://api.openweathermap.org/data/2.5/weather?lat=%f&lon=%f&appid=%s&units=metric", + c.latitude, c.longitude, c.apiKey, + ) + + resp, err := c.httpClient.Get(url) + if err != nil { + return nil, fmt.Errorf("failed to fetch weather data: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("unexpected status code %d: %s", resp.StatusCode, string(body)) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return nil, fmt.Errorf("failed to read response: %w", err) + } + + var owmData OpenWeatherMapResponse + if err := json.Unmarshal(body, &owmData); err != nil { + return nil, fmt.Errorf("failed to parse JSON: %w", err) + } + + // Convert to our structure + weatherData := &WeatherData{ + Temperature: owmData.Main.Temp, + FeelsLike: owmData.Main.FeelsLike, + Humidity: owmData.Main.Humidity, + Pressure: owmData.Main.Pressure, + WindSpeed: owmData.Wind.Speed, + WindGust: owmData.Wind.Gust, + WindDeg: owmData.Wind.Deg, + Clouds: owmData.Clouds.All, + UpdatedAt: time.Unix(owmData.Dt, 0).Format(time.RFC3339), + } + + if len(owmData.Weather) > 0 { + weatherData.Description = owmData.Weather[0].Description + weatherData.Icon = owmData.Weather[0].Icon + } + + // Cache the data + c.cachedData = weatherData + c.lastUpdate = time.Now() + + return weatherData, nil +} diff --git a/web/index.html b/web/index.html new file mode 100644 index 0000000..6f74a25 --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + ShackMaster - XV9Q Shack + + + + + +
+ + + \ No newline at end of file diff --git a/web/package.json b/web/package.json new file mode 100644 index 0000000..e14bdf2 --- /dev/null +++ b/web/package.json @@ -0,0 +1,18 @@ +{ + "name": "shackmaster-web", + "version": "1.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^3.0.1", + "svelte": "^4.2.8", + "vite": "^5.0.11" + }, + "dependencies": { + "@turf/turf": "^6.5.0" + } +} \ No newline at end of file diff --git a/web/src/App.svelte b/web/src/App.svelte new file mode 100644 index 0000000..c6df217 --- /dev/null +++ b/web/src/App.svelte @@ -0,0 +1,265 @@ + + +
+
+
+

{callsign} Shack

+
+ + {isConnected ? 'Connected' : 'Disconnected'} +
+
+ +
+
+ SFI {solarData.sfi} + Spots {solarData.sunspots} + A {solarData.a_index} + K {solarData.k_index} + G {solarData.geomag} +
+
+ +
+
+ 🌬️ {weatherData.wind_speed.toFixed(1)}m/s + 💨 {weatherData.wind_gust.toFixed(1)}m/s + 🌡️ {weatherData.temp.toFixed(1)}°C + → {weatherData.feels_like.toFixed(1)}°C +
+
+ {formatTime(currentTime)} + {currentTime.toLocaleDateString()} +
+
+
+ +
+
+
+ + + +
+ +
+ + +
+
+
+
+ + \ No newline at end of file diff --git a/web/src/app.css b/web/src/app.css new file mode 100644 index 0000000..51c8017 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,128 @@ +:root { + --bg-primary: #1a1a1a; + --bg-secondary: #2a2a2a; + --bg-card: #333333; + --text-primary: #ffffff; + --text-secondary: #b0b0b0; + --accent-teal: #00bcd4; + --accent-green: #4caf50; + --accent-red: #f44336; + --accent-blue: #2196f3; + --border-color: #444444; +} + +* { + margin: 0; + padding: 0; + box-sizing: border-box; +} + +body { + margin: 0; + padding: 0; + font-family: 'Roboto', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; + background-color: var(--bg-primary); + color: var(--text-primary); + overflow-x: hidden; +} + +#app { + min-height: 100vh; +} + +button { + font-family: inherit; + cursor: pointer; + border: none; + outline: none; + transition: all 0.2s ease; +} + +button:hover { + transform: translateY(-1px); +} + +button:active { + transform: translateY(0); +} + +input, select { + font-family: inherit; + outline: none; +} + +.card { + background: var(--bg-card); + border: 1px solid var(--border-color); + border-radius: 8px; + padding: 16px; +} + +.status-indicator { + display: inline-block; + width: 12px; + height: 12px; + border-radius: 50%; + margin-right: 8px; +} + +.status-online { + background-color: var(--accent-green); + box-shadow: 0 0 8px var(--accent-green); +} + +.status-offline { + background-color: var(--text-secondary); +} + +.btn { + padding: 12px 24px; + border-radius: 6px; + font-weight: 500; + font-size: 14px; + text-transform: uppercase; + letter-spacing: 0.5px; + transition: all 0.2s ease; +} + +.btn-primary { + background: var(--accent-green); + color: white; +} + +.btn-primary:hover { + background: #45a049; +} + +.btn-danger { + background: var(--accent-red); + color: white; +} + +.btn-danger:hover { + background: #da190b; +} + +.btn-secondary { + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); +} + +.btn-secondary:hover { + background: var(--bg-card); +} + +.value-display { + font-size: 24px; + font-weight: 300; + color: var(--accent-teal); +} + +.label { + font-size: 12px; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 4px; +} \ No newline at end of file diff --git a/web/src/components/AntennaGenius.svelte b/web/src/components/AntennaGenius.svelte new file mode 100644 index 0000000..51080e9 --- /dev/null +++ b/web/src/components/AntennaGenius.svelte @@ -0,0 +1,129 @@ + + +
+

+ AG 8X2 + +

+ +
+
Radio 1 / Radio 2
+ +
+
+
Radio 1
+
+ {#each Array(4) as _, i} + + {/each} +
+
+ +
+
Radio 2
+
+ {#each Array(4) as _, i} + + {/each} +
+
+
+
+
+ + \ No newline at end of file diff --git a/web/src/components/PowerGenius.svelte b/web/src/components/PowerGenius.svelte new file mode 100644 index 0000000..c2c2bb3 --- /dev/null +++ b/web/src/components/PowerGenius.svelte @@ -0,0 +1,209 @@ + + +
+

+ PGXL + +

+ +
+
+ {displayState} +
+
+ +
+
+
FWD PWR (W)
+
{powerForward.toFixed(1)}
+
+
+
+
+ 0 + 1000 + 2000 +
+
+ +
+
PG XL SWR 1:1.00 use
+
{swr.toFixed(2)}
+
+ +
+
Temp / HL Temp
+
{temperature.toFixed(0)}°C / {harmonicLoadTemp.toFixed(1)}°C
+
+
+
+
+ 25 + 55 + 80 +
+
+ +
+
+
VAC
+
{voltage.toFixed(0)}
+
+
+
VDD
+
{vdd.toFixed(1)}
+
+
+
ID peak
+
{peakCurrent.toFixed(1)}
+
+
+ +
+
Fan Speed
+ +
+ +
+
Band A
+
{bandA}
+
+
+
Band B
+
{bandB}
+
+
+
+ + + + \ No newline at end of file diff --git a/web/src/components/RotatorGenius.svelte b/web/src/components/RotatorGenius.svelte new file mode 100644 index 0000000..096f477 --- /dev/null +++ b/web/src/components/RotatorGenius.svelte @@ -0,0 +1,334 @@ + + +
+

+ ROTATOR GENIUS + +

+ +
+ CURRENT HEADING: {currentHeading}° +
+ + {#if moving > 0} +
+ {moving === 1 ? '↻ ROTATING CW' : '↺ ROTATING CCW'} +
+ {/if} + + + +
+
+ + +
+ +
+ + + +
+
+ +
+ {#each presets as preset} + + {/each} +
+
+ + \ No newline at end of file diff --git a/web/src/components/TunerGenius.svelte b/web/src/components/TunerGenius.svelte new file mode 100644 index 0000000..5273d69 --- /dev/null +++ b/web/src/components/TunerGenius.svelte @@ -0,0 +1,257 @@ + + +
+

+ TGXL + +

+ +
+
Power 0.0w
+
1500
+
1650
+
+ +
+
+
TG XL SWR 1.00 use
+
+ +
+ + + +
+ +
+
+
{c1}
+
C1
+
+
+
{l}
+
L
+
+
+
{c2}
+
C2
+
+
+
+ +
+
+
Tuning Status
+
+ {tuningStatus} +
+
+
+ +
+
+
Frequency A
+
{(frequencyA / 1000).toFixed(3)}
+
+
+
Frequency B
+
{(frequencyB / 1000).toFixed(3)}
+
+
+ +
+ + +
+ + +
+ + \ No newline at end of file diff --git a/web/src/components/WebSwitch.svelte b/web/src/components/WebSwitch.svelte new file mode 100644 index 0000000..b0ef93c --- /dev/null +++ b/web/src/components/WebSwitch.svelte @@ -0,0 +1,152 @@ + + +
+

+ 1216RH + 0} class:status-offline={relays.length === 0}> +

+ +
+ {#each [1, 2, 3, 4, 5] as relayNum} + {@const relay = relays.find(r => r.number === relayNum)} + {@const isOn = relay?.state || false} +
+ {relayNames[relayNum]} + +
+ {/each} +
+ +
+ + +
+
+ + \ No newline at end of file diff --git a/web/src/lib/api.js b/web/src/lib/api.js new file mode 100644 index 0000000..cdc8683 --- /dev/null +++ b/web/src/lib/api.js @@ -0,0 +1,76 @@ +const API_BASE = '/api'; + +async function request(endpoint, options = {}) { + try { + const response = await fetch(`${API_BASE}${endpoint}`, { + ...options, + headers: { + 'Content-Type': 'application/json', + ...options.headers, + }, + }); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + return await response.json(); + } catch (error) { + console.error('API request failed:', error); + throw error; + } +} + +export const api = { + // Status + getStatus: () => request('/status'), + getConfig: () => request('/config'), + + // WebSwitch + webswitch: { + relayOn: (relay) => request(`/webswitch/relay/on?relay=${relay}`, { method: 'POST' }), + relayOff: (relay) => request(`/webswitch/relay/off?relay=${relay}`, { method: 'POST' }), + allOn: () => request('/webswitch/all/on', { method: 'POST' }), + allOff: () => request('/webswitch/all/off', { method: 'POST' }), + }, + + // Rotator + rotator: { + move: (rotator, azimuth) => request('/rotator/move', { + method: 'POST', + body: JSON.stringify({ rotator, azimuth }), + }), + cw: (rotator) => request(`/rotator/cw?rotator=${rotator}`, { method: 'POST' }), + ccw: (rotator) => request(`/rotator/ccw?rotator=${rotator}`, { method: 'POST' }), + stop: () => request('/rotator/stop', { method: 'POST' }), + }, + + // Tuner + tuner: { + operate: (operate) => request('/tuner/operate', { + method: 'POST', + body: JSON.stringify({ operate }), + }), + tune: () => request('/tuner/tune', { method: 'POST' }), + antenna: (antenna) => request('/tuner/antenna', { + method: 'POST', + body: JSON.stringify({ antenna }), + }), + }, + + // Antenna Genius + antenna: { + set: (radio, antenna) => request('/antenna/set', { + method: 'POST', + body: JSON.stringify({ radio, antenna }), + }), + }, + + // Power Genius + power: { + setFanMode: (mode) => request('/power/fanmode', { + method: 'POST', + body: JSON.stringify({ mode }), + }), + }, +}; \ No newline at end of file diff --git a/web/src/lib/websocket.js b/web/src/lib/websocket.js new file mode 100644 index 0000000..9f3edc1 --- /dev/null +++ b/web/src/lib/websocket.js @@ -0,0 +1,81 @@ +import { writable } from 'svelte/store'; + +export const connected = writable(false); +export const systemStatus = writable(null); +export const lastUpdate = writable(null); + +class WebSocketService { + constructor() { + this.ws = null; + this.reconnectTimeout = null; + this.reconnectDelay = 3000; + } + + connect() { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + const wsUrl = `${protocol}//${window.location.host}/ws`; + + try { + this.ws = new WebSocket(wsUrl); + + this.ws.onopen = () => { + console.log('WebSocket connected'); + connected.set(true); + }; + + this.ws.onmessage = (event) => { + try { + const message = JSON.parse(event.data); + + if (message.type === 'update') { + systemStatus.set(message.data); + lastUpdate.set(new Date(message.timestamp)); + } + } catch (err) { + console.error('Error parsing message:', err); + } + }; + + this.ws.onerror = (error) => { + console.error('WebSocket error:', error); + }; + + this.ws.onclose = () => { + console.log('WebSocket disconnected'); + connected.set(false); + this.scheduleReconnect(); + }; + } catch (err) { + console.error('Error creating WebSocket:', err); + this.scheduleReconnect(); + } + } + + scheduleReconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + + this.reconnectTimeout = setTimeout(() => { + console.log('Attempting to reconnect...'); + this.connect(); + }, this.reconnectDelay); + } + + send(message) { + if (this.ws && this.ws.readyState === WebSocket.OPEN) { + this.ws.send(JSON.stringify(message)); + } + } + + disconnect() { + if (this.reconnectTimeout) { + clearTimeout(this.reconnectTimeout); + } + if (this.ws) { + this.ws.close(); + } + } +} + +export const wsService = new WebSocketService(); \ No newline at end of file diff --git a/web/src/main.js b/web/src/main.js new file mode 100644 index 0000000..6a9b89b --- /dev/null +++ b/web/src/main.js @@ -0,0 +1,8 @@ +import App from './App.svelte' +import './app.css' + +const app = new App({ + target: document.getElementById('app') +}) + +export default app \ No newline at end of file diff --git a/web/svelte.config.js b/web/svelte.config.js new file mode 100644 index 0000000..56004c9 --- /dev/null +++ b/web/svelte.config.js @@ -0,0 +1 @@ +export default {} \ No newline at end of file diff --git a/web/vite.config.js b/web/vite.config.js new file mode 100644 index 0000000..9add09e --- /dev/null +++ b/web/vite.config.js @@ -0,0 +1,19 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: 'dist' + }, + server: { + port: 5173, + proxy: { + '/api': 'http://localhost:8081', + '/ws': { + target: 'ws://localhost:8081', + ws: true + } + } + } +}) \ No newline at end of file