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/flexradio" "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/ultrabeam" "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 ultrabeam *ultrabeam.Client flexRadio *flexradio.Client solarClient *solar.Client weatherClient *weather.Client hub *Hub statusMu sync.RWMutex lastStatus *SystemStatus updateInterval time.Duration stopChan chan struct{} // Auto frequency tracking freqThreshold int // Threshold for triggering update (Hz) autoTrackEnabled bool ultrabeamDirection int // User-selected direction (0=normal, 1=180, 2=bi-dir) ultrabeamDirectionSet bool // True if user has explicitly set a direction lastFreqUpdateTime time.Time // Last time we sent frequency update freqUpdateCooldown time.Duration // Minimum time between updates // Cached Ultrabeam state for FlexRadio interlock (avoid mutex contention) ultrabeamMotorsMoving int ultrabeamStateMu sync.RWMutex } 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"` Ultrabeam *ultrabeam.Status `json:"ultrabeam"` FlexRadio *flexradio.Status `json:"flexradio,omitempty"` 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: 200 * time.Millisecond, // Update status every second stopChan: make(chan struct{}), freqThreshold: 25000, // 25 kHz default autoTrackEnabled: true, // Enabled by default ultrabeamDirection: 0, // Normal direction by default freqUpdateCooldown: 500 * time.Millisecond, // 500ms cooldown (was 2sec) } } 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 Ultrabeam log.Printf("Initializing Ultrabeam: host=%s port=%d", dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port) dm.ultrabeam = ultrabeam.New( dm.config.Devices.Ultrabeam.Host, dm.config.Devices.Ultrabeam.Port, ) // Initialize FlexRadio if enabled if dm.config.Devices.FlexRadio.Enabled { log.Printf("Initializing FlexRadio: host=%s port=%d", dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port) dm.flexRadio = flexradio.New( dm.config.Devices.FlexRadio.Host, dm.config.Devices.FlexRadio.Port, dm.config.Devices.FlexRadio.InterlockName, ) // Set callback to check if transmit is allowed (based on Ultrabeam motors) // Use cached state to avoid mutex contention with update loop dm.flexRadio.SetTransmitCheckCallback(func() bool { dm.ultrabeamStateMu.RLock() motorsMoving := dm.ultrabeamMotorsMoving dm.ultrabeamStateMu.RUnlock() // Block transmit if motors are moving return motorsMoving == 0 }) // Set callback for immediate frequency changes (no waiting for update cycle) dm.flexRadio.SetFrequencyChangeCallback(func(freqMHz float64) { dm.handleFrequencyChange(freqMHz) }) } // 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("About to launch Ultrabeam goroutine...") go func() { log.Println("Starting Ultrabeam polling goroutine...") if err := dm.ultrabeam.Start(); err != nil { log.Printf("Warning: Failed to start Ultrabeam polling: %v", err) } }() log.Println("Ultrabeam goroutine launched") // Start FlexRadio if enabled if dm.flexRadio != nil { log.Println("Starting FlexRadio connection...") go func() { if err := dm.flexRadio.Start(); err != nil { log.Printf("Warning: Failed to start FlexRadio: %v", err) } }() log.Println("FlexRadio goroutine launched") } log.Println("Device manager initialized") return nil } func (dm *DeviceManager) Start() error { log.Println("Starting device monitoring...") go dm.monitorDevices() return nil } // handleFrequencyChange is called immediately when FlexRadio frequency changes // This provides instant auto-track response instead of waiting for updateStatus cycle func (dm *DeviceManager) handleFrequencyChange(freqMHz float64) { // Check if ultrabeam is initialized if dm.ultrabeam == nil { return } // Check cooldown first timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime) if timeSinceLastUpdate < dm.freqUpdateCooldown { return } // Use cached status instead of calling GetStatus (which can block) dm.statusMu.RLock() hasStatus := dm.lastStatus != nil var ubStatus *ultrabeam.Status if hasStatus { ubStatus = dm.lastStatus.Ultrabeam } dm.statusMu.RUnlock() if ubStatus == nil || !ubStatus.Connected { return } // Don't update if motors are already moving if ubStatus.MotorsMoving != 0 { return } freqKhz := int(freqMHz * 1000) ultrabeamFreqKhz := ubStatus.Frequency // Only track if in Ultrabeam range (7-54 MHz) if freqKhz < 7000 || freqKhz > 54000 { return } freqDiff := freqKhz - ultrabeamFreqKhz if freqDiff < 0 { freqDiff = -freqDiff } freqDiffHz := freqDiff * 1000 if freqDiffHz >= dm.freqThreshold { directionToUse := dm.ultrabeamDirection if !dm.ultrabeamDirectionSet && ubStatus.Direction != 0 { directionToUse = ubStatus.Direction } log.Printf("Auto-track (immediate): Updating to %d kHz (diff=%d kHz)", freqKhz, freqDiff) if err := dm.ultrabeam.SetFrequency(freqKhz, directionToUse); err != nil { log.Printf("Auto-track (immediate): Failed: %v", err) } else { dm.lastFreqUpdateTime = time.Now() } } } 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() } if dm.ultrabeam != nil { dm.ultrabeam.Stop() } } 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) } // Ultrabeam if ubStatus, err := dm.ultrabeam.GetStatus(); err == nil { status.Ultrabeam = ubStatus // Sync direction with Ultrabeam if user hasn't explicitly set one // This prevents auto-track from using wrong direction before user changes it if !dm.ultrabeamDirectionSet { dm.ultrabeamDirection = ubStatus.Direction } // Cache motors state for FlexRadio interlock callback dm.ultrabeamStateMu.Lock() previousMotors := dm.ultrabeamMotorsMoving dm.ultrabeamMotorsMoving = ubStatus.MotorsMoving dm.ultrabeamStateMu.Unlock() // Log motor state changes if previousMotors != ubStatus.MotorsMoving { if ubStatus.MotorsMoving > 0 { log.Printf("Ultrabeam: Motors STARTED (bitmask=%d)", ubStatus.MotorsMoving) } else { log.Printf("Ultrabeam: Motors STOPPED") } } } else { log.Printf("Ultrabeam error: %v", err) } // FlexRadio (use direct cache access to avoid mutex contention) if dm.flexRadio != nil { // Access lastStatus directly from FlexRadio's internal cache // The messageLoop updates this in real-time, no need to block on GetStatus frStatus, err := dm.flexRadio.GetStatus() if err == nil && frStatus != nil { status.FlexRadio = frStatus } } // Auto frequency tracking: Update Ultrabeam when radio frequency differs from Ultrabeam // Priority: FlexRadio (fast) > TunerGenius (slow backup) var radioFreqKhz int var radioSource string if dm.flexRadio != nil && status.FlexRadio != nil && status.FlexRadio.Connected && status.FlexRadio.Frequency > 0 { // Use FlexRadio frequency (in MHz, convert to kHz) radioFreqKhz = int(status.FlexRadio.Frequency * 1000) radioSource = "FlexRadio" } else if dm.autoTrackEnabled && status.TunerGenius != nil && status.TunerGenius.Connected { // Fallback to TunerGenius frequency (already in kHz) radioFreqKhz = int(status.TunerGenius.FreqA) radioSource = "TunerGenius" } if radioFreqKhz > 0 && status.Ultrabeam != nil && status.Ultrabeam.Connected { ultrabeamFreqKhz := status.Ultrabeam.Frequency // Ultrabeam frequency in kHz // Only do auto-track if frequency is in Ultrabeam range (40M-6M: 7000-54000 kHz) // This prevents retraction when slice is closed (FreqA becomes 0) or on out-of-range bands if radioFreqKhz >= 7000 && radioFreqKhz <= 54000 { freqDiff := radioFreqKhz - ultrabeamFreqKhz if freqDiff < 0 { freqDiff = -freqDiff } // Convert diff to Hz for comparison with threshold (which is in Hz) freqDiffHz := freqDiff * 1000 // Don't send command if motors are already moving if status.Ultrabeam.MotorsMoving == 0 { if freqDiffHz >= dm.freqThreshold { // Use user's explicitly set direction, or fallback to current Ultrabeam direction directionToUse := dm.ultrabeamDirection if !dm.ultrabeamDirectionSet && status.Ultrabeam.Direction != 0 { directionToUse = status.Ultrabeam.Direction } // Check cooldown to prevent rapid fire commands timeSinceLastUpdate := time.Since(dm.lastFreqUpdateTime) if timeSinceLastUpdate < dm.freqUpdateCooldown { log.Printf("Auto-track: Cooldown active (%v remaining), skipping update", dm.freqUpdateCooldown-timeSinceLastUpdate) } else { log.Printf("Auto-track (%s): Frequency differs by %d kHz, updating Ultrabeam to %d kHz (direction=%d)", radioSource, freqDiff, radioFreqKhz, directionToUse) // Send to Ultrabeam with saved or current direction if err := dm.ultrabeam.SetFrequency(radioFreqKhz, directionToUse); err != nil { log.Printf("Auto-track: Failed to update Ultrabeam: %v (will retry)", err) } else { log.Printf("Auto-track: Successfully sent frequency to Ultrabeam") dm.lastFreqUpdateTime = time.Now() // Update cooldown timer } } } } } // If out of range, simply skip auto-track but continue with status broadcast } // 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 } func (dm *DeviceManager) Ultrabeam() *ultrabeam.Client { return dm.ultrabeam } func (dm *DeviceManager) SetAutoTrack(enabled bool, thresholdHz int) { dm.autoTrackEnabled = enabled dm.freqThreshold = thresholdHz } func (dm *DeviceManager) SetUltrabeamDirection(direction int) { dm.ultrabeamDirection = direction dm.ultrabeamDirectionSet = true // Mark that user has explicitly set direction log.Printf("Ultrabeam direction set to: %d (user choice)", direction) }