From ac99f291a7ee82572628f5801b70d3c8611a3b9a Mon Sep 17 00:00:00 2001 From: rouggy Date: Fri, 9 Jan 2026 11:56:40 +0100 Subject: [PATCH] rot finished --- cmd/server/main.go | 75 +++ configs/config.example.yaml | 1 - go.mod | 5 +- go.sum | 3 + internal/api/device_manager.go | 267 ++++++++++ internal/api/handlers.go | 399 ++++++++++++++ internal/api/websocket.go | 193 +++++++ internal/config/config.go | 5 +- .../devices/antennagenius/antennagenius.go | 437 ++++++++++++++++ internal/devices/command_id.go | 35 ++ internal/devices/powergenius/powergenius.go | 413 +++++++++++++++ .../devices/rotatorgenius/rotatorgenius.go | 353 +++++++------ internal/devices/tunergenius/tunergenius.go | 413 +++++++++++---- internal/devices/webswitch/webswitch.go | 104 +++- 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 | 438 ++++++++++++++++ web/src/components/AntennaGenius.svelte | 310 +++++++++++ web/src/components/PowerGenius.svelte | 492 ++++++++++++++++++ web/src/components/RotatorGenius.svelte | 427 +++++++++++++++ web/src/components/TunerGenius.svelte | 477 +++++++++++++++++ web/src/components/WebSwitch.svelte | 305 +++++++++++ web/src/lib/api.js | 92 ++++ web/src/lib/websocket.js | 82 +++ web/src/main.js | 8 + web/svelte.config.js | 1 + web/vite.config.js | 19 + 30 files changed, 5581 insertions(+), 293 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..fd53fa0 --- /dev/null +++ b/internal/api/device_manager.go @@ -0,0 +1,267 @@ +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 + log.Printf("Initializing RotatorGenius: host=%s port=%d", dm.config.Devices.RotatorGenius.Host, dm.config.Devices.RotatorGenius.Port) + 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 device polling in background (non-blocking) + go func() { + if err := dm.powerGenius.Start(); err != nil { + log.Printf("Warning: Failed to start PowerGenius polling: %v", err) + } + }() + + go func() { + if err := dm.tunerGenius.Start(); err != nil { + log.Printf("Warning: Failed to start TunerGenius polling: %v", err) + } + }() + + go func() { + if err := dm.antennaGenius.Start(); err != nil { + log.Printf("Warning: Failed to start AntennaGenius polling: %v", err) + } + }() + + log.Println("About to launch RotatorGenius goroutine...") + go func() { + log.Println("Starting RotatorGenius polling goroutine...") + if err := dm.rotatorGenius.Start(); err != nil { + log.Printf("Warning: Failed to start RotatorGenius polling: %v", err) + } + }() + log.Println("RotatorGenius goroutine launched") + + 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..479cd22 --- /dev/null +++ b/internal/api/handlers.go @@ -0,0 +1,399 @@ +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/heading", s.handleRotatorHeading) + 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/bypass", s.handleTunerBypass) + mux.HandleFunc("/api/tuner/autotune", s.handleTunerAutoTune) + + // Antenna Genius endpoints + mux.HandleFunc("/api/antenna/select", s.handleAntennaSelect) + mux.HandleFunc("/api/antenna/reboot", s.handleAntennaReboot) + + // Power Genius endpoints + mux.HandleFunc("/api/power/fanmode", s.handlePowerFanMode) + mux.HandleFunc("/api/power/operate", s.handlePowerOperate) + + // 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) handleRotatorHeading(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Heading int `json:"heading"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.RotatorGenius().SetHeading(req.Heading); 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 + } + + if err := s.deviceManager.RotatorGenius().RotateCW(); 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 + } + + if err := s.deviceManager.RotatorGenius().RotateCCW(); 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 { + Value int `json:"value"` + } + + 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.Value); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleTunerBypass(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Value int `json:"value"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.TunerGenius().SetBypass(req.Value); 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"}) +} + +// Antenna Genius handlers +func (s *Server) handleAntennaSelect(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Port int `json:"port"` + 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().SetAntenna(req.Port, req.Antenna); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } + + s.sendJSON(w, map[string]string{"status": "ok"}) +} + +func (s *Server) handleAntennaReboot(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + if err := s.deviceManager.AntennaGenius().Reboot(); 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) handlePowerOperate(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Value int `json:"value"` + } + + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, "Invalid request body", http.StatusBadRequest) + return + } + + if err := s.deviceManager.PowerGenius().SetOperate(req.Value); 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..2fc21c1 --- /dev/null +++ b/internal/devices/antennagenius/antennagenius.go @@ -0,0 +1,437 @@ +package antennagenius + +import ( + "bufio" + "fmt" + "log" + "net" + "strconv" + "strings" + "sync" + "time" +) + +type Client struct { + host string + port int + conn net.Conn + reader *bufio.Reader + connMu sync.Mutex + lastStatus *Status + statusMu sync.RWMutex + antennas []Antenna + antennasMu sync.RWMutex + stopChan chan struct{} + running bool +} + +type Status struct { + PortA *PortStatus `json:"port_a"` + PortB *PortStatus `json:"port_b"` + Antennas []Antenna `json:"antennas"` + Connected bool `json:"connected"` +} + +type PortStatus struct { + Auto bool `json:"auto"` + Source string `json:"source"` + Band int `json:"band"` + Frequency float64 `json:"frequency"` + Nickname string `json:"nickname"` + RxAnt int `json:"rx_ant"` + TxAnt int `json:"tx_ant"` + InBand int `json:"in_band"` + TX bool `json:"tx"` + Inhibit int `json:"inhibit"` +} + +type Antenna struct { + Number int `json:"number"` + Name string `json:"name"` + TX string `json:"tx"` + RX string `json:"rx"` + InBand string `json:"in_band"` + Hotkey int `json:"hotkey"` +} + +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 + } + + 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 + c.reader = bufio.NewReader(c.conn) + + // Read and discard banner + _, _ = c.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 +} + +func (c *Client) Start() error { + if c.running { + return nil + } + + _ = c.Connect() + + c.running = true + go c.pollLoop() + + return nil +} + +func (c *Client) pollLoop() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + initialized := false + + for { + select { + case <-ticker.C: + c.connMu.Lock() + if c.conn == nil { + c.connMu.Unlock() + + c.statusMu.Lock() + c.lastStatus = &Status{Connected: false} + c.statusMu.Unlock() + + if err := c.Connect(); err != nil { + continue + } + initialized = false + c.connMu.Lock() + } + c.connMu.Unlock() + + // Initialize: get antenna list and subscribe + if !initialized { + if err := c.initialize(); err != nil { + log.Printf("AntennaGenius init error: %v", err) + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.reader = nil + } + c.connMu.Unlock() + continue + } + initialized = true + } + + // Read spontaneous messages from subscription + c.connMu.Lock() + if c.conn != nil && c.reader != nil { + c.conn.SetReadDeadline(time.Now().Add(150 * time.Millisecond)) + + for { + line, err := c.reader.ReadString('\n') + if err != nil { + break + } + + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "S") { + c.parsePortStatus(line) + } + } + } + c.connMu.Unlock() + + case <-c.stopChan: + return + } + } +} + +func (c *Client) initialize() error { + // Get antenna list + antennas, err := c.getAntennaList() + if err != nil { + return fmt.Errorf("failed to get antenna list: %w", err) + } + + c.antennasMu.Lock() + c.antennas = antennas + c.antennasMu.Unlock() + + // Subscribe to port updates + if err := c.subscribeToPortUpdates(); err != nil { + return fmt.Errorf("failed to subscribe: %w", err) + } + + // Initialize status + c.statusMu.Lock() + c.lastStatus = &Status{ + PortA: &PortStatus{}, + PortB: &PortStatus{}, + Antennas: antennas, + Connected: true, + } + c.statusMu.Unlock() + + return nil +} + +func (c *Client) sendCommand(cmd string) (string, error) { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.conn == nil || c.reader == nil { + return "", fmt.Errorf("not connected") + } + + // AntennaGenius only accepts C1| for all commands + fullCmd := fmt.Sprintf("C1|%s\n", cmd) + + _, err := c.conn.Write([]byte(fullCmd)) + if err != nil { + c.conn = nil + c.reader = nil + return "", fmt.Errorf("failed to send command: %w", err) + } + + // Read all response lines using shared reader + var response strings.Builder + + // Set a read timeout to avoid blocking forever + c.conn.SetReadDeadline(time.Now().Add(2 * time.Second)) + defer c.conn.SetReadDeadline(time.Time{}) + + for { + line, err := c.reader.ReadString('\n') + if err != nil { + if response.Len() > 0 { + // We got some data, return it + break + } + c.conn = nil + c.reader = nil + return "", fmt.Errorf("failed to read response: %w", err) + } + + response.WriteString(line) + + // Parse spontaneous status updates + trimmedLine := strings.TrimSpace(line) + if strings.HasPrefix(trimmedLine, "S0|") { + c.connMu.Unlock() + c.parsePortStatus(trimmedLine) + c.connMu.Lock() + } + + // Check if this is the last line (empty line or timeout) + if trimmedLine == "" { + break + } + } + + return response.String(), nil +} + +func (c *Client) getAntennaList() ([]Antenna, error) { + resp, err := c.sendCommand("antenna list") + if err != nil { + return nil, err + } + + var antennas []Antenna + + // Response format: R|0|antenna name= tx= rx= inband= hotkey= + lines := strings.Split(resp, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if !strings.Contains(line, "antenna ") { + continue + } + + antenna := c.parseAntennaLine(line) + + // Skip unconfigured antennas (name = Antenna X with space) + if strings.HasPrefix(antenna.Name, "Antenna ") { + continue + } + + antennas = append(antennas, antenna) + } + + return antennas, nil +} + +func (c *Client) parseAntennaLine(line string) Antenna { + antenna := Antenna{} + + // Extract antenna number + if idx := strings.Index(line, "antenna "); idx != -1 { + rest := line[idx+8:] + parts := strings.Fields(rest) + if len(parts) > 0 { + antenna.Number, _ = strconv.Atoi(parts[0]) + } + } + + // Parse key=value pairs + pairs := strings.Fields(line) + for _, pair := range pairs { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + continue + } + + key := kv[0] + value := kv[1] + + switch key { + case "name": + // Replace underscores with spaces + antenna.Name = strings.ReplaceAll(value, "_", " ") + case "tx": + antenna.TX = value + case "rx": + antenna.RX = value + case "inband": + antenna.InBand = value + case "hotkey": + antenna.Hotkey, _ = strconv.Atoi(value) + } + } + + return antenna +} + +func (c *Client) subscribeToPortUpdates() error { + resp, err := c.sendCommand("sub port all") + if err != nil { + return err + } + + // Parse initial port status from subscription response + // The response may contain S0|port messages with current status + lines := strings.Split(resp, "\n") + for _, line := range lines { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "S0|port") { + c.parsePortStatus(line) + } + } + + return nil +} + +func (c *Client) parsePortStatus(line string) { + // Format: S0|port auto=<0|1> source= band= freq= nickname= rxant= txant= inband= tx=<0|1> inhibit= + + var portID int + portStatus := &PortStatus{} + + // Extract port ID + if idx := strings.Index(line, "port "); idx != -1 { + rest := line[idx+5:] + parts := strings.Fields(rest) + if len(parts) > 0 { + portID, _ = strconv.Atoi(parts[0]) + } + } + + // Parse key=value pairs + pairs := strings.Fields(line) + for _, pair := range pairs { + kv := strings.SplitN(pair, "=", 2) + if len(kv) != 2 { + continue + } + + key := kv[0] + value := kv[1] + + switch key { + case "auto": + portStatus.Auto = value == "1" + case "source": + portStatus.Source = value + case "band": + portStatus.Band, _ = strconv.Atoi(value) + case "freq": + portStatus.Frequency, _ = strconv.ParseFloat(value, 64) + case "nickname": + portStatus.Nickname = value + case "rxant": + portStatus.RxAnt, _ = strconv.Atoi(value) + case "txant": + portStatus.TxAnt, _ = strconv.Atoi(value) + case "inband": + portStatus.InBand, _ = strconv.Atoi(value) + case "tx": + portStatus.TX = value == "1" + case "inhibit": + portStatus.Inhibit, _ = strconv.Atoi(value) + } + } + + // Update status + c.statusMu.Lock() + if c.lastStatus != nil { + if portID == 1 { + c.lastStatus.PortA = portStatus + } else if portID == 2 { + c.lastStatus.PortB = portStatus + } + } + c.statusMu.Unlock() +} + +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 +} + +// SetAntenna sets the antenna for a specific port +func (c *Client) SetAntenna(port, antenna int) error { + cmd := fmt.Sprintf("port set %d rxant=%d", port, antenna) + _, err := c.sendCommand(cmd) + return err +} + +// Reboot reboots the device +func (c *Client) Reboot() error { + _, err := c.sendCommand("reboot") + return err +} 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..fbaa72c --- /dev/null +++ b/internal/devices/powergenius/powergenius.go @@ -0,0 +1,413 @@ +package powergenius + +import ( + "bufio" + "fmt" + "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"` +} + +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 c.running { + return nil + } + + // Try to connect, but don't fail if it doesn't work + // The poll loop will keep trying + _ = c.Connect() + + 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: + // Try to reconnect if not connected + c.connMu.Lock() + if c.conn == nil { + c.connMu.Unlock() + + // Mark as disconnected and reset all values + c.statusMu.Lock() + c.lastStatus = &Status{ + Connected: false, + } + c.statusMu.Unlock() + + if err := c.Connect(); err != nil { + // Silent fail, will retry on next tick + continue + } + c.connMu.Lock() + } + c.connMu.Unlock() + + status, err := c.queryStatus() + if err != nil { + // Connection lost, close and retry next tick + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + + // Mark as disconnected and reset all values + c.statusMu.Lock() + c.lastStatus = &Status{ + Connected: false, + } + c.statusMu.Unlock() + continue + } + + // Mark as connected + status.Connected = true + + // 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 "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 +} + +// SetOperate sets the operate mode +// value can be: 0 (STANDBY) or 1 (OPERATE) +func (c *Client) SetOperate(value int) error { + if value != 0 && value != 1 { + return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value) + } + + cmd := fmt.Sprintf("operate=%d", value) + _, err := c.sendCommand(cmd) + return err +} diff --git a/internal/devices/rotatorgenius/rotatorgenius.go b/internal/devices/rotatorgenius/rotatorgenius.go index c704b15..ee98fc0 100644 --- a/internal/devices/rotatorgenius/rotatorgenius.go +++ b/internal/devices/rotatorgenius/rotatorgenius.go @@ -6,227 +6,244 @@ import ( "net" "strconv" "strings" + "sync" "time" ) type Client struct { - host string - port int - conn net.Conn + host string + port int + conn net.Conn + reader *bufio.Reader + connMu sync.Mutex + lastStatus *Status + statusMu sync.RWMutex + stopChan chan struct{} + running bool } type Status struct { - Rotator1 RotatorData `json:"rotator1"` - Rotator2 RotatorData `json:"rotator2"` - Panic bool `json:"panic"` -} - -type RotatorData struct { - CurrentAzimuth int `json:"current_azimuth"` - LimitCW int `json:"limit_cw"` - LimitCCW int `json:"limit_ccw"` - Configuration string `json:"configuration"` // "A" for azimuth, "E" for elevation - Moving int `json:"moving"` // 0=stopped, 1=CW, 2=CCW - Offset int `json:"offset"` - TargetAzimuth int `json:"target_azimuth"` - StartAzimuth int `json:"start_azimuth"` - OutsideLimit bool `json:"outside_limit"` - Name string `json:"name"` - Connected bool `json:"connected"` + Heading int `json:"heading"` + Connected bool `json:"connected"` } func New(host string, port int) *Client { return &Client{ - host: host, - port: port, + 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 + } + + fmt.Printf("RotatorGenius: Attempting to connect to %s:%d\n", c.host, c.port) + conn, err := net.DialTimeout("tcp", fmt.Sprintf("%s:%d", c.host, c.port), 5*time.Second) if err != nil { + fmt.Printf("RotatorGenius: Connection failed: %v\n", err) return fmt.Errorf("failed to connect: %w", err) } c.conn = conn + c.reader = bufio.NewReader(c.conn) + + fmt.Println("RotatorGenius: Connected successfully") + 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 } -func (c *Client) sendCommand(cmd string) (string, error) { - if c.conn == nil { - if err := c.Connect(); err != nil { - return "", err - } +func (c *Client) Start() error { + fmt.Println("RotatorGenius Start() called") + + if c.running { + fmt.Println("RotatorGenius already running, skipping") + return nil + } + + fmt.Println("RotatorGenius attempting initial connection...") + _ = c.Connect() + + c.running = true + fmt.Println("RotatorGenius launching pollLoop...") + go c.pollLoop() + + fmt.Println("RotatorGenius Start() completed") + return nil +} + +func (c *Client) pollLoop() { + ticker := time.NewTicker(500 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + c.connMu.Lock() + if c.conn == nil { + c.connMu.Unlock() + + c.statusMu.Lock() + c.lastStatus = &Status{Connected: false} + c.statusMu.Unlock() + + if err := c.Connect(); err != nil { + continue + } + c.connMu.Lock() + } + c.connMu.Unlock() + + status, err := c.queryStatus() + if err != nil { + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + c.reader = nil + } + c.connMu.Unlock() + + c.statusMu.Lock() + c.lastStatus = &Status{Connected: false} + c.statusMu.Unlock() + continue + } + + status.Connected = true + + c.statusMu.Lock() + c.lastStatus = status + c.statusMu.Unlock() + + case <-c.stopChan: + return + } + } +} + +func (c *Client) sendCommand(cmd string) error { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.conn == nil || c.reader == nil { + return fmt.Errorf("not connected") } - // Send command _, err := c.conn.Write([]byte(cmd)) if err != nil { c.conn = nil - return "", fmt.Errorf("failed to send command: %w", err) + c.reader = nil + return fmt.Errorf("failed to send command: %w", err) } - // Read response - reader := bufio.NewReader(c.conn) - response, err := reader.ReadString('\n') + return nil +} + +func (c *Client) queryStatus() (*Status, error) { + c.connMu.Lock() + defer c.connMu.Unlock() + + if c.conn == nil || c.reader == nil { + return nil, fmt.Errorf("not connected") + } + + // Send |h command + _, err := c.conn.Write([]byte("|h")) if err != nil { c.conn = nil - return "", fmt.Errorf("failed to read response: %w", err) + c.reader = nil + return nil, fmt.Errorf("failed to send query: %w", err) } - return strings.TrimSpace(response), nil + // Read response - RotatorGenius doesn't send newline, read fixed amount + c.conn.SetReadDeadline(time.Now().Add(500 * time.Millisecond)) + defer c.conn.SetReadDeadline(time.Time{}) + + buf := make([]byte, 100) + n, err := c.reader.Read(buf) + if err != nil || n == 0 { + c.conn = nil + c.reader = nil + return nil, fmt.Errorf("failed to read response: %w", err) + } + + response := string(buf[:n]) + + return c.parseStatus(response), nil +} + +func (c *Client) parseStatus(response string) *Status { + status := &Status{} + + // Response format: |h2... + // Example: |h2\x00183 8 10A0... + // After |h2 there's a null byte, then 3 digits for heading + + if !strings.HasPrefix(response, "|h2") { + return status + } + + // Skip |h2 (3 chars) and null byte (1 char), then read 3 digits + if len(response) >= 7 { + // Position 3 is the null byte, position 4-6 are the heading + headingStr := response[4:7] + heading, err := strconv.Atoi(strings.TrimSpace(headingStr)) + if err == nil { + status.Heading = heading + } + } + + return status } func (c *Client) GetStatus() (*Status, error) { - resp, err := c.sendCommand("|h") - if err != nil { - return nil, err + c.statusMu.RLock() + defer c.statusMu.RUnlock() + + if c.lastStatus == nil { + return &Status{Connected: false}, nil } - return parseStatusResponse(resp) + return c.lastStatus, nil } -func parseStatusResponse(resp string) (*Status, error) { - if len(resp) < 80 { - return nil, fmt.Errorf("response too short: %d bytes", len(resp)) - } - - status := &Status{} - - // Parse panic flag - status.Panic = resp[3] != 0x00 - - // Parse Rotator 1 (positions 4-38) - status.Rotator1 = parseRotatorData(resp[4:38]) - - // Parse Rotator 2 (positions 38-72) - if len(resp) >= 72 { - status.Rotator2 = parseRotatorData(resp[38:72]) - } - - return status, nil +// SetHeading rotates to a specific azimuth +func (c *Client) SetHeading(azimuth int) error { + cmd := fmt.Sprintf("|A1%d", azimuth) + return c.sendCommand(cmd) } -func parseRotatorData(data string) RotatorData { - rd := RotatorData{} - - // Current azimuth (3 bytes) - if azStr := strings.TrimSpace(data[0:3]); azStr != "999" { - rd.CurrentAzimuth, _ = strconv.Atoi(azStr) - rd.Connected = true - } else { - rd.CurrentAzimuth = 999 - rd.Connected = false - } - - // Limits - rd.LimitCW, _ = strconv.Atoi(strings.TrimSpace(data[3:6])) - rd.LimitCCW, _ = strconv.Atoi(strings.TrimSpace(data[6:9])) - - // Configuration - rd.Configuration = string(data[9]) - - // Moving state - rd.Moving, _ = strconv.Atoi(string(data[10])) - - // Offset - rd.Offset, _ = strconv.Atoi(strings.TrimSpace(data[11:15])) - - // Target azimuth - if targetStr := strings.TrimSpace(data[15:18]); targetStr != "999" { - rd.TargetAzimuth, _ = strconv.Atoi(targetStr) - } else { - rd.TargetAzimuth = 999 - } - - // Start azimuth - if startStr := strings.TrimSpace(data[18:21]); startStr != "999" { - rd.StartAzimuth, _ = strconv.Atoi(startStr) - } else { - rd.StartAzimuth = 999 - } - - // Limit flag - rd.OutsideLimit = data[21] == '1' - - // Name - rd.Name = strings.TrimSpace(data[22:34]) - - return rd +// RotateCW rotates clockwise +func (c *Client) RotateCW() error { + return c.sendCommand("|P1") } -func (c *Client) MoveToAzimuth(rotator int, azimuth int) error { - if rotator < 1 || rotator > 2 { - return fmt.Errorf("rotator must be 1 or 2") - } - if azimuth < 0 || azimuth > 360 { - return fmt.Errorf("azimuth must be between 0 and 360") - } - - cmd := fmt.Sprintf("|A%d%03d", rotator, azimuth) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - if !strings.HasSuffix(resp, "K") { - return fmt.Errorf("command failed: %s", resp) - } - - return nil -} - -func (c *Client) RotateCW(rotator int) error { - if rotator < 1 || rotator > 2 { - return fmt.Errorf("rotator must be 1 or 2") - } - - cmd := fmt.Sprintf("|P%d", rotator) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - if !strings.HasSuffix(resp, "K") { - return fmt.Errorf("command failed: %s", resp) - } - - return nil -} - -func (c *Client) RotateCCW(rotator int) error { - if rotator < 1 || rotator > 2 { - return fmt.Errorf("rotator must be 1 or 2") - } - - cmd := fmt.Sprintf("|M%d", rotator) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - if !strings.HasSuffix(resp, "K") { - return fmt.Errorf("command failed: %s", resp) - } - - return nil +// RotateCCW rotates counter-clockwise +func (c *Client) RotateCCW() error { + return c.sendCommand("|M1") } +// Stop stops rotation func (c *Client) Stop() error { - resp, err := c.sendCommand("|S") - if err != nil { - return err - } - - if !strings.HasSuffix(resp, "K") { - return fmt.Errorf("command failed: %s", resp) - } - - return nil + return c.sendCommand("|S") } diff --git a/internal/devices/tunergenius/tunergenius.go b/internal/devices/tunergenius/tunergenius.go index c3bf5e1..bb2b111 100644 --- a/internal/devices/tunergenius/tunergenius.go +++ b/internal/devices/tunergenius/tunergenius.go @@ -3,67 +3,217 @@ package tunergenius import ( "bufio" "fmt" + "math" "net" + "strconv" "strings" + "sync" "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 + connMu sync.Mutex + lastStatus *Status + statusMu sync.RWMutex + stopChan chan struct{} + running bool } type Status struct { - Operate bool `json:"operate"` // true = OPERATE, false = STANDBY - Bypass bool `json:"bypass"` // Bypass mode - ActiveAntenna int `json:"active_antenna"` // 0=ANT1, 1=ANT2, 2=ANT3 - TuningStatus string `json:"tuning_status"` - FrequencyA float64 `json:"frequency_a"` - FrequencyB float64 `json:"frequency_b"` - C1 int `json:"c1"` - L int `json:"l"` - C2 int `json:"c2"` - SWR float64 `json:"swr"` - Power float64 `json:"power"` - Temperature float64 `json:"temperature"` - Connected bool `json:"connected"` + PowerForward float64 `json:"power_forward"` + PowerPeak float64 `json:"power_peak"` + PowerMax float64 `json:"power_max"` + SWR float64 `json:"swr"` + PTTA int `json:"ptt_a"` + BandA int `json:"band_a"` + FreqA float64 `json:"frequency_a"` + BypassA bool `json:"bypass_a"` + AntA int `json:"antenna_a"` + PTTB int `json:"ptt_b"` + BandB int `json:"band_b"` + FreqB float64 `json:"frequency_b"` + BypassB bool `json:"bypass_b"` + AntB int `json:"antenna_b"` + State int `json:"state"` + Active int `json:"active"` + Tuning int `json:"tuning"` + Bypass bool `json:"bypass"` + RelayC1 int `json:"c1"` + RelayL int `json:"l"` + RelayC2 int `json:"c2"` + TuningStatus string `json:"tuning_status"` + 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, + 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 } -func (c *Client) sendCommand(cmd string) (string, error) { - if c.conn == nil { - if err := c.Connect(); err != nil { - return "", err - } +// Start begins continuous polling of the device +func (c *Client) Start() error { + if c.running { + return nil } - // Format command with ID - fullCmd := fmt.Sprintf("C%d|%s\n", c.idNumber, cmd) + // Try to connect, but don't fail if it doesn't work + // The poll loop will keep trying + _ = c.Connect() + + c.running = true + go c.pollLoop() + + return nil +} + +// pollLoop continuously polls the device for status +func (c *Client) pollLoop() { + ticker := time.NewTicker(100 * time.Millisecond) + defer ticker.Stop() + + for { + select { + case <-ticker.C: + // Try to reconnect if not connected + c.connMu.Lock() + if c.conn == nil { + c.connMu.Unlock() + + // Mark as disconnected and reset all values + c.statusMu.Lock() + c.lastStatus = &Status{ + Connected: false, + } + c.statusMu.Unlock() + + if err := c.Connect(); err != nil { + // Silent fail, will retry on next tick + continue + } + c.connMu.Lock() + } + c.connMu.Unlock() + + status, err := c.queryStatus() + if err != nil { + // Connection lost, close and retry next tick + c.connMu.Lock() + if c.conn != nil { + c.conn.Close() + c.conn = nil + } + c.connMu.Unlock() + + // Mark as disconnected and reset all values + c.statusMu.Lock() + c.lastStatus = &Status{ + Connected: false, + } + c.statusMu.Unlock() + continue + } + + // Mark as connected + status.Connected = true + + c.statusMu.Lock() + 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 get + fullCmd := fmt.Sprintf("C%d|status get\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)) @@ -84,119 +234,156 @@ func (c *Client) sendCommand(cmd string) (string, error) { } func (c *Client) GetStatus() (*Status, error) { - resp, err := c.sendCommand("status") - if err != nil { - return nil, err + c.statusMu.RLock() + defer c.statusMu.RUnlock() + + if c.lastStatus == nil { + return &Status{Connected: false}, nil } - // Parse the response - format will depend on actual device response - // This is a placeholder that should be updated based on real response format + return c.lastStatus, nil +} + +func (c *Client) parseStatus(resp string) (*Status, error) { status := &Status{ Connected: true, } - // TODO: Parse actual status response from device - // The response format needs to be determined from real device testing - // For now, we just check if we got a response - _ = resp // Temporary: will be used when we parse the actual response format + // Response format: S|status fwd=21.19 peak=21.55 ... + // Extract the data part after "S|status " + idx := strings.Index(resp, "|status ") + if idx == -1 { + return nil, fmt.Errorf("invalid response format: %s", resp) + } + + data := resp[idx+8:] // Skip "|status " + + // 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., 42.62 dBm) + // 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 "peak": + // peak power in dBm + if dBm, err := strconv.ParseFloat(value, 64); err == nil { + milliwatts := math.Pow(10, dBm/10.0) + status.PowerPeak = milliwatts / 1000.0 + } + case "max": + if dBm, err := strconv.ParseFloat(value, 64); err == nil { + milliwatts := math.Pow(10, dBm/10.0) + status.PowerMax = milliwatts / 1000.0 + } + 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 "pttA": + status.PTTA, _ = strconv.Atoi(value) + case "bandA": + status.BandA, _ = strconv.Atoi(value) + case "freqA": + status.FreqA, _ = strconv.ParseFloat(value, 64) + case "bypassA": + status.BypassA = value == "1" + case "antA": + status.AntA, _ = strconv.Atoi(value) + case "pttB": + status.PTTB, _ = strconv.Atoi(value) + case "bandB": + status.BandB, _ = strconv.Atoi(value) + case "freqB": + status.FreqB, _ = strconv.ParseFloat(value, 64) + case "bypassB": + status.BypassB = value == "1" + case "antB": + status.AntB, _ = strconv.Atoi(value) + case "state": + status.State, _ = strconv.Atoi(value) + case "active": + status.Active, _ = strconv.Atoi(value) + case "tuning": + status.Tuning, _ = strconv.Atoi(value) + if status.Tuning == 1 { + status.TuningStatus = "TUNING" + } else { + status.TuningStatus = "READY" + } + case "bypass": + status.Bypass = value == "1" + case "relayC1": + status.RelayC1, _ = strconv.Atoi(value) + case "relayL": + status.RelayL, _ = strconv.Atoi(value) + case "relayC2": + status.RelayC2, _ = strconv.Atoi(value) + } + } return status, nil } -func (c *Client) SetOperate(operate bool) error { - var state int - if operate { - state = 1 +// SetOperate switches between STANDBY (0) and OPERATE (1) +func (c *Client) SetOperate(value int) error { + if value != 0 && value != 1 { + return fmt.Errorf("invalid operate value: %d (must be 0 or 1)", value) } - cmd := fmt.Sprintf("operate set=%d", state) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - // Check if command was successful - if resp == "" { - return fmt.Errorf("empty response from device") - } - - return nil + cmd := fmt.Sprintf("operate set=%d", value) + _, err := c.sendCommand(cmd) + return err } -func (c *Client) SetBypass(bypass bool) error { - var state int - if bypass { - state = 1 +// SetBypass sets BYPASS mode +func (c *Client) SetBypass(value int) error { + if value != 0 && value != 1 { + return fmt.Errorf("invalid bypass value: %d (must be 0 or 1)", value) } - cmd := fmt.Sprintf("bypass set=%d", state) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - // Check if command was successful - if resp == "" { - return fmt.Errorf("empty response from device") - } - - return nil -} - -func (c *Client) ActivateAntenna(antenna int) error { - if antenna < 0 || antenna > 2 { - return fmt.Errorf("antenna must be 0 (ANT1), 1 (ANT2), or 2 (ANT3)") - } - - cmd := fmt.Sprintf("activate ant=%d", antenna) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - // Check if command was successful - if resp == "" { - return fmt.Errorf("empty response from device") - } - - return nil + cmd := fmt.Sprintf("bypass set=%d", value) + _, err := c.sendCommand(cmd) + return err } +// AutoTune starts a tuning cycle func (c *Client) AutoTune() error { - resp, err := c.sendCommand("autotune") - if err != nil { - return err - } - - // Check if command was successful - if resp == "" { - return fmt.Errorf("empty response from device") - } - - return nil + _, err := c.sendCommand("autotune") + return err } -// TuneRelay adjusts tuning parameters manually +// TuneRelay adjusts one tuning parameter by one step // relay: 0=C1, 1=L, 2=C2 -// move: -1 to decrease, 1 to increase -func (c *Client) TuneRelay(relay int, move int) error { +// move: -1 (decrease) or 1 (increase) +func (c *Client) TuneRelay(relay, move int) error { if relay < 0 || relay > 2 { - return fmt.Errorf("relay must be 0 (C1), 1 (L), or 2 (C2)") + return fmt.Errorf("invalid relay: %d (must be 0, 1, or 2)", relay) } if move != -1 && move != 1 { - return fmt.Errorf("move must be -1 or 1") + return fmt.Errorf("invalid move: %d (must be -1 or 1)", move) } cmd := fmt.Sprintf("tune relay=%d move=%d", relay, move) - resp, err := c.sendCommand(cmd) - if err != nil { - return err - } - - // Check if command was successful - if resp == "" { - return fmt.Errorf("empty response from device") - } - - return nil + _, err := c.sendCommand(cmd) + return err } diff --git a/internal/devices/webswitch/webswitch.go b/internal/devices/webswitch/webswitch.go index 5b6d1e0..dcce155 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" ) @@ -13,7 +15,8 @@ type Client struct { } type Status struct { - Relays []RelayState `json:"relays"` + Relays []RelayState `json:"relays"` + Connected bool `json:"connected"` } type RelayState struct { @@ -65,23 +68,110 @@ func (c *Client) TurnOff(relay int) error { } func (c *Client) AllOn() error { - for i := 1; i <= 5; i++ { - if err := c.TurnOn(i); err != nil { - return fmt.Errorf("failed to turn on relay %d: %w", i, err) + // Sequence for ALL ON: + // 1. Turn on relays 1, 2, 3, 5 immediately + // 2. Wait 5 seconds + // 3. Turn on relay 4 (Flex Radio Start) + + // Turn on relays 1, 2, 3, 5 + for _, relay := range []int{1, 2, 3, 5} { + if err := c.TurnOn(relay); err != nil { + return fmt.Errorf("failed to turn on relay %d: %w", relay, err) } } + + // Wait 5 seconds for power supply to stabilize + time.Sleep(5 * time.Second) + + // Turn on relay 4 (Flex Radio) + if err := c.TurnOn(4); err != nil { + return fmt.Errorf("failed to turn on relay 4: %w", err) + } + return nil } func (c *Client) AllOff() error { - for i := 1; i <= 5; i++ { - if err := c.TurnOff(i); err != nil { - return fmt.Errorf("failed to turn off relay %d: %w", i, err) + // Sequence for ALL OFF: + // 1. Turn off relay 4 (Flex Radio) immediately + // 2. Turn off relays 2, 3, 5 immediately + // 3. Wait 35 seconds for Flex Radio to shut down + // 4. Turn off relay 1 (Power Supply) + + // Turn off relay 4 (Flex Radio) + if err := c.TurnOff(4); err != nil { + return fmt.Errorf("failed to turn off relay 4: %w", err) + } + + // Turn off relays 2, 3, 5 + for _, relay := range []int{2, 3, 5} { + if err := c.TurnOff(relay); err != nil { + return fmt.Errorf("failed to turn off relay %d: %w", relay, err) } } + + // Wait 35 seconds for Flex Radio to shut down properly + time.Sleep(35 * time.Second) + + // Turn off relay 1 (Power Supply) + if err := c.TurnOff(1); err != nil { + return fmt.Errorf("failed to turn off relay 1: %w", err) + } + 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), + Connected: true, + } + + 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..d89e88a --- /dev/null +++ b/web/index.html @@ -0,0 +1,15 @@ + + + + + + ShackMaster - F4BPO 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..c797f93 --- /dev/null +++ b/web/src/app.css @@ -0,0 +1,438 @@ +:root { + /* Modern dark theme inspired by FlexDXCluster */ + --bg-primary: #0a1628; + --bg-secondary: #1a2332; + --bg-tertiary: #243447; + --bg-hover: #2a3f5f; + + --text-primary: #e0e6ed; + --text-secondary: #a0aec0; + --text-muted: #718096; + + --accent-cyan: #4fc3f7; + --accent-blue: #2196f3; + --accent-green: #4caf50; + --accent-orange: #ff9800; + --accent-red: #f44336; + --accent-purple: #9c27b0; + --accent-yellow: #ffc107; + + --border-color: #2d3748; + --border-light: #374151; + + --card-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + --card-radius: 6px; + + --header-height: 56px; + --spacing-xs: 4px; + --spacing-sm: 8px; + --spacing-md: 12px; + --spacing-lg: 16px; + --spacing-xl: 20px; +} + +* { + box-sizing: border-box; + margin: 0; + padding: 0; +} + +body { + font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', + 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + background: var(--bg-primary); + color: var(--text-primary); + font-size: 13px; + line-height: 1.4; + overflow-x: hidden; +} + +.app { + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* ==================== HEADER ==================== */ +header { + height: var(--header-height); + background: var(--bg-secondary); + border-bottom: 1px solid var(--border-color); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 var(--spacing-lg); + flex-shrink: 0; +} + +.header-left { + display: flex; + align-items: center; + gap: var(--spacing-lg); +} + +.header-left h1 { + font-size: 16px; + font-weight: 600; + color: var(--accent-cyan); + letter-spacing: 0.5px; +} + +.connection-status { + display: flex; + align-items: center; + gap: var(--spacing-sm); + font-size: 12px; + color: var(--text-secondary); +} + +.status-indicator { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-red); + transition: background 0.3s; +} + +.status-indicator.status-online { + background: var(--accent-green); + box-shadow: 0 0 8px var(--accent-green); +} + +.header-center { + display: flex; + gap: var(--spacing-xl); +} + +.solar-info { + display: flex; + gap: var(--spacing-md); + font-size: 12px; +} + +.solar-item { + color: var(--text-secondary); +} + +.solar-item .value { + color: var(--accent-cyan); + font-weight: 600; + margin-left: var(--spacing-xs); +} + +.header-right { + display: flex; + align-items: center; + gap: var(--spacing-lg); +} + +.weather-info { + display: flex; + gap: var(--spacing-md); + font-size: 12px; + color: var(--text-secondary); +} + +.clock { + display: flex; + flex-direction: column; + align-items: flex-end; + font-size: 11px; +} + +.clock .time { + font-size: 14px; + font-weight: 600; + color: var(--text-primary); +} + +.clock .date { + color: var(--text-secondary); +} + +/* ==================== MAIN CONTENT ==================== */ +main { + flex: 1; + overflow-y: auto; + padding: var(--spacing-md); +} + +.dashboard-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: var(--spacing-md); + max-width: 1800px; + margin: 0 auto; +} + +/* ==================== CARDS ==================== */ +.card { + background: var(--bg-secondary); + border: 1px solid var(--border-color); + border-radius: var(--card-radius); + padding: var(--spacing-md); + box-shadow: var(--card-shadow); + transition: border-color 0.2s; +} + +.card:hover { + border-color: var(--border-light); +} + +.card h2 { + font-size: 14px; + font-weight: 600; + color: var(--accent-cyan); + margin-bottom: var(--spacing-md); + display: flex; + align-items: center; + gap: var(--spacing-sm); + letter-spacing: 0.5px; +} + +.card h2::before { + content: ''; + width: 3px; + height: 14px; + background: var(--accent-cyan); + border-radius: 2px; +} + +/* Status indicators */ +.status-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: var(--accent-green); +} + +.status-dot.disconnected { + background: var(--accent-red); +} + +/* ==================== LABELS & VALUES ==================== */ +.label { + font-size: 11px; + color: var(--text-muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: var(--spacing-xs); +} + +.value { + font-size: 18px; + font-weight: 300; + color: var(--text-primary); +} + +/* ==================== BUTTONS ==================== */ +button, .button { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: var(--spacing-sm) var(--spacing-md); + font-size: 12px; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +button:hover, .button:hover { + background: var(--bg-hover); + border-color: var(--border-light); +} + +button:active, .button:active { + transform: scale(0.98); +} + +button.primary { + background: var(--accent-cyan); + border-color: var(--accent-cyan); + color: #000; +} + +button.primary:hover { + background: #29b6f6; + border-color: #29b6f6; +} + +button.success { + background: var(--accent-green); + border-color: var(--accent-green); + color: white; +} + +button.danger { + background: var(--accent-red); + border-color: var(--accent-red); + color: white; +} + +button:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +/* ==================== SELECT ==================== */ +select { + background: var(--bg-tertiary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + padding: var(--spacing-sm); + font-size: 12px; + cursor: pointer; + outline: none; + transition: all 0.2s; +} + +select:hover { + border-color: var(--border-light); +} + +select:focus { + border-color: var(--accent-cyan); +} + +/* ==================== BADGES ==================== */ +.badge { + display: inline-block; + padding: 4px 10px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge.green { + background: rgba(76, 175, 80, 0.2); + color: var(--accent-green); +} + +.badge.red { + background: rgba(244, 67, 54, 0.2); + color: var(--accent-red); +} + +.badge.orange { + background: rgba(255, 152, 0, 0.2); + color: var(--accent-orange); +} + +.badge.cyan { + background: rgba(79, 195, 247, 0.2); + color: var(--accent-cyan); +} + +.badge.purple { + background: rgba(156, 39, 176, 0.2); + color: var(--accent-purple); +} + +/* ==================== PROGRESS BARS ==================== */ +.bar { + width: 100%; + height: 6px; + background: var(--bg-tertiary); + border-radius: 3px; + overflow: hidden; + margin: var(--spacing-xs) 0; +} + +.bar-fill { + height: 100%; + background: linear-gradient(90deg, var(--accent-green), var(--accent-orange), var(--accent-red)); + transition: width 0.3s ease; + border-radius: 3px; +} + +.scale { + display: flex; + justify-content: space-between; + font-size: 10px; + color: var(--text-muted); + margin-top: var(--spacing-xs); +} + +/* ==================== METRICS ==================== */ +.metrics { + display: flex; + flex-direction: column; + gap: var(--spacing-md); +} + +.metric { + display: flex; + flex-direction: column; + gap: var(--spacing-xs); +} + +.metric-row { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: var(--spacing-md); +} + +.metric.small { + min-width: 0; +} + +.metric-value { + display: flex; + justify-content: space-between; + align-items: baseline; + flex-wrap: wrap; +} + +/* ==================== SCROLLBAR ==================== */ +::-webkit-scrollbar { + width: 8px; + height: 8px; +} + +::-webkit-scrollbar-track { + background: var(--bg-primary); +} + +::-webkit-scrollbar-thumb { + background: var(--bg-tertiary); + border-radius: 4px; +} + +::-webkit-scrollbar-thumb:hover { + background: var(--bg-hover); +} + +/* ==================== RESPONSIVE ==================== */ +@media (max-width: 1400px) { + .dashboard-grid { + grid-template-columns: repeat(auto-fit, minmax(250px, 1fr)); + } +} + +@media (max-width: 768px) { + header { + flex-direction: column; + height: auto; + padding: var(--spacing-sm); + gap: var(--spacing-sm); + } + + .dashboard-grid { + grid-template-columns: 1fr; + } + + .header-center { + order: 3; + width: 100%; + } +} \ 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..d9c7c2d --- /dev/null +++ b/web/src/components/AntennaGenius.svelte @@ -0,0 +1,310 @@ + + +
+
+

Antenna Genius

+ +
+ +
+ +
+
+
{portA.source || 'FLEX'}
+
+
+
{portB.source || 'FLEX'}
+
+
+ + +
+
+
{bandAName}
+
+
+
{bandBName}
+
+
+ + +
+ {#each antennas as antenna} + {@const isPortATx = portA.tx && portA.tx_ant === antenna.number} + {@const isPortBTx = portB.tx && portB.tx_ant === antenna.number} + {@const isPortARx = !portA.tx && (portA.rx_ant === antenna.number || portA.tx_ant === antenna.number)} + {@const isPortBRx = !portB.tx && (portB.rx_ant === antenna.number || portB.tx_ant === antenna.number)} + {@const isTx = isPortATx || isPortBTx} + {@const isActive = isPortARx || isPortBRx} + +
+
{antenna.name}
+
+ + +
+
+ {/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..518f045 --- /dev/null +++ b/web/src/components/PowerGenius.svelte @@ -0,0 +1,492 @@ + + +
+
+

Power Genius XL

+
+ + +
+
+ +
+ +
+
+
{powerForward.toFixed(0)}W
+
Forward Power
+
+
+
+
+
+
+ 0 + 1000 + 2000 +
+
+
+ + +
+
+
{swr.toFixed(2)}
+
SWR
+
+
+ {#if swr < 1.5} + Excellent + {:else if swr < 2.0} + Good + {:else if swr < 3.0} + Caution + {:else} + High! + {/if} +
+
+ + +
+
+
{temperature.toFixed(0)}°
+
PA Temp
+
+
+
+
+
+
{harmonicLoadTemp.toFixed(0)}°
+
HL Temp
+
+
+
+
+
+ + +
+
+
VAC
+
{voltage.toFixed(0)}
+
+
+
VDD
+
{vdd.toFixed(1)}
+
+
+
ID Peak
+
{peakCurrent.toFixed(1)}
+
+
+ + +
+
+ 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..7e407bd --- /dev/null +++ b/web/src/components/RotatorGenius.svelte @@ -0,0 +1,427 @@ + + +
+
+

Rotator Genius

+ +
+ +
+ +
+
CURRENT HEADING
+
{heading}°
+
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + N + E + S + W + + + {#each [30, 60, 120, 150, 210, 240, 300, 330] as angle} + {@const x = 250 + 215 * Math.sin(angle * Math.PI / 180)} + {@const y = 250 - 215 * Math.cos(angle * Math.PI / 180)} + {angle}° + {/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..5dd3f1f --- /dev/null +++ b/web/src/components/TunerGenius.svelte @@ -0,0 +1,477 @@ + + +
+
+

Tuner Genius XL

+
+ {tuningStatus} + +
+
+ +
+ +
+
+
{powerForward.toFixed(0)}W
+
Forward Power
+
+
+
+
+
+
+ 0 + 1000 + 2000 +
+
+
+ + +
+
+
{swr.toFixed(2)}
+
SWR
+
+
+ {#if swr < 1.5} + Excellent + {:else if swr < 2.0} + Good + {:else if swr < 3.0} + Caution + {:else} + High! + {/if} +
+
+ + +
+
+
{relayC1}
+
C1
+
+
+
{relayL}
+
L
+
+
+
{relayC2}
+
C2
+
+
+ + +
+
+
Freq A
+
{(frequencyA / 1000).toFixed(3)}MHz
+
+
+
Freq B
+
{(frequencyB / 1000).toFixed(3)}MHz
+
+
+ + +
+ + +
+ + +
+
+ + \ 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..65e1506 --- /dev/null +++ b/web/src/components/WebSwitch.svelte @@ -0,0 +1,305 @@ + + +
+
+

WebSwitch

+ +
+ +
+
+ {#each [1, 2, 3, 4, 5] as relayNum} + {@const relay = relays.find(r => r.number === relayNum)} + {@const isOn = relay?.state || false} +
+
+
+
{relayNames[relayNum]}
+
{isOn ? 'ON' : 'OFF'}
+
+
+ +
+ {/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..3bdcde9 --- /dev/null +++ b/web/src/lib/api.js @@ -0,0 +1,92 @@ +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: { + setOperate: (value) => request('/tuner/operate', { + method: 'POST', + body: JSON.stringify({ value }), + }), + setBypass: (value) => request('/tuner/bypass', { + method: 'POST', + body: JSON.stringify({ value }), + }), + autoTune: () => request('/tuner/autotune', { method: 'POST' }), + }, + + // Antenna Genius + antenna: { + selectAntenna: (port, antenna) => request('/antenna/select', { + method: 'POST', + body: JSON.stringify({ port, antenna }), + }), + reboot: () => request('/antenna/reboot', { method: 'POST' }), + }, + + // Power Genius + power: { + setFanMode: (mode) => request('/power/fanmode', { + method: 'POST', + body: JSON.stringify({ mode }), + }), + setOperate: (value) => request('/power/operate', { + method: 'POST', + body: JSON.stringify({ value }), + }), + }, + + // Rotator Genius + rotator: { + setHeading: (heading) => request('/rotator/heading', { + method: 'POST', + body: JSON.stringify({ heading }), + }), + rotateCW: () => request('/rotator/cw', { method: 'POST' }), + rotateCCW: () => request('/rotator/ccw', { method: 'POST' }), + stop: () => request('/rotator/stop', { method: 'POST' }), + }, +}; \ 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..ea195e3 --- /dev/null +++ b/web/src/lib/websocket.js @@ -0,0 +1,82 @@ +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') { + console.log('System status updated:', message.data); + 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