"""UPS Manager for Dangerous Pi. Handles battery monitoring, safe shutdown triggers, and battery percentage reporting for various UPS HAT devices. Supports multiple UPS types via driver architecture: - PiSugar (PiSugar 2/3 series via daemon) - I2C Fuel Gauge (MAX17040/MAX17048) """ import asyncio import subprocess from dataclasses import dataclass from datetime import datetime from enum import Enum from typing import Optional, Dict, Any from .. import config from .ups_drivers import UPSDriver, PiSugarDriver, I2CFuelGaugeDriver, auto_detect_driver class BatteryStatus(str, Enum): """Battery status enum.""" CHARGING = "charging" DISCHARGING = "discharging" FULL = "full" CRITICAL = "critical" UNKNOWN = "unknown" class PowerSource(str, Enum): """Power source enum.""" AC = "ac" BATTERY = "battery" UNKNOWN = "unknown" @dataclass class UPSStatus: """UPS status information.""" battery_percentage: float voltage: float current: float power_source: PowerSource battery_status: BatteryStatus time_remaining: Optional[int] = None # Minutes remaining on battery temperature: Optional[float] = None last_updated: Optional[str] = None is_available: bool = True error_message: Optional[str] = None class UPSManager: """Manages UPS battery monitoring and safe shutdown.""" def __init__(self, driver: Optional[UPSDriver] = None): """Initialize the UPS manager. Args: driver: UPS driver instance. If None, creates driver based on config. """ self._check_interval = config.UPS_CHECK_INTERVAL self._driver = driver or self._create_driver() self._status = UPSStatus( battery_percentage=0.0, voltage=0.0, current=0.0, power_source=PowerSource.UNKNOWN, battery_status=BatteryStatus.UNKNOWN, is_available=False ) self._shutdown_threshold = 10.0 # Shutdown at 10% battery self._warning_threshold = 20.0 # Warning at 20% battery self._critical_threshold = 15.0 # Critical at 15% battery self._battery_capacity_mah = 1200 # Default for PiSugar 2 (1200mAh) self._shutdown_initiated = False self._config_warning_sent = False # Only send config warning once self._event_callbacks = [] def _create_driver(self) -> Optional[UPSDriver]: """Create UPS driver based on configuration. Returns: Appropriate UPS driver instance, or None for auto/none types """ ups_type = config.UPS_TYPE.lower() if ups_type == "pisugar": return PiSugarDriver( host=config.UPS_PISUGAR_HOST, port=config.UPS_PISUGAR_PORT ) elif ups_type == "i2c": i2c_address = int(config.UPS_I2C_ADDRESS, 16) return I2CFuelGaugeDriver(i2c_address=i2c_address) elif ups_type == "auto": # Auto-detection happens in initialize() return None elif ups_type == "none": # No UPS configured return None else: # Default to auto-detection for unknown types print(f"Unknown UPS type '{ups_type}', will auto-detect") return None async def initialize(self, startup_delay: float = 1.0) -> bool: """Initialize connection to UPS hardware. Args: startup_delay: Delay before detection to allow I2C bus to settle Returns: True if initialization successful, False otherwise """ try: # If no driver set, try auto-detection (for "auto" or unknown types) if self._driver is None: ups_type = config.UPS_TYPE.lower() if ups_type == "none": # Explicitly disabled print("UPS disabled by configuration (UPS_TYPE=none)") self._status.is_available = False self._status.error_message = "UPS disabled" return False # Small delay to ensure I2C bus is ready after boot if startup_delay > 0: await asyncio.sleep(startup_delay) # Run auto-detection print("Auto-detecting UPS hardware...") driver, message = await auto_detect_driver( pisugar_host=config.UPS_PISUGAR_HOST, pisugar_port=config.UPS_PISUGAR_PORT ) if driver: self._driver = driver print(message) else: print(message) self._status.is_available = False self._status.error_message = message return False # Initialize the driver success = await self._driver.initialize() if success: self._status.is_available = True self._status.error_message = None print(f"UPS initialized: {self._driver.get_model_name()}") else: self._status.is_available = False self._status.error_message = f"Failed to initialize {self._driver.get_model_name()}" return success except Exception as e: self._status.is_available = False self._status.error_message = f"Failed to initialize UPS: {e}" return False async def start_monitoring(self): """Start periodic battery monitoring in background.""" if not await self.initialize(): print(f"UPS not available: {self._status.error_message}") return while True: try: await self._update_battery_status() await self._check_battery_thresholds() await asyncio.sleep(self._check_interval) except asyncio.CancelledError: break except Exception as e: print(f"Error in UPS monitoring: {e}") self._status.error_message = str(e) await asyncio.sleep(self._check_interval) async def get_status(self) -> UPSStatus: """Get current UPS status. Returns: UPSStatus object with current battery information """ if self._status.is_available: await self._update_battery_status() return self._status def get_power_restrictions(self) -> Dict[str, Any]: """Get current power restrictions based on hardware state. Returns: Dict with restriction info including: - restricted: bool - Whether operations are restricted - reason: Optional[str] - Why operations are restricted - power_source: str - Current power source - ups_available: bool - Whether UPS hardware is present - battery_percentage: Optional[float] - Current battery level - allow_firmware_flash: bool - Can flash firmware (fullimage) - allow_bootloader_flash: bool - Can flash bootloader - allow_intensive_operations: bool - Can run intensive ops - message: Optional[str] - User-friendly message - warning: Optional[str] - Warning message - shutdown_imminent: Optional[bool] - Shutdown about to happen """ # UPS not available = assume AC power, no restrictions if not self._status.is_available: return { "restricted": False, "reason": None, "power_source": "assumed_ac", "ups_available": False, "battery_percentage": None, "allow_firmware_flash": True, "allow_bootloader_flash": True, "allow_intensive_operations": True, "message": "UPS not detected. Assuming stable AC power. Ensure power stability before firmware operations." } # UPS available - check power source and battery state if self._status.power_source == PowerSource.AC: return { "restricted": False, "reason": None, "power_source": "ac", "ups_available": True, "battery_percentage": self._status.battery_percentage, "allow_firmware_flash": True, "allow_bootloader_flash": True, "allow_intensive_operations": True, "message": "On AC power. All operations allowed." } # On battery power - apply restrictions based on battery level battery_pct = self._status.battery_percentage if battery_pct >= 80: return { "restricted": False, "reason": None, "power_source": "battery", "ups_available": True, "battery_percentage": battery_pct, "allow_firmware_flash": True, "allow_bootloader_flash": True, "allow_intensive_operations": True, "warning": "On battery power. Bootloader flashing not recommended below 80%." } elif battery_pct >= 50: return { "restricted": True, "reason": "Battery level below 80%", "power_source": "battery", "ups_available": True, "battery_percentage": battery_pct, "allow_firmware_flash": True, # Fullimage only "allow_bootloader_flash": False, "allow_intensive_operations": True, "message": "Bootloader flashing disabled. Battery too low (minimum 80% required)." } elif battery_pct >= 20: return { "restricted": True, "reason": "Battery level below 50%", "power_source": "battery", "ups_available": True, "battery_percentage": battery_pct, "allow_firmware_flash": False, "allow_bootloader_flash": False, "allow_intensive_operations": True, "message": "Firmware flashing disabled. Battery too low (minimum 50% required)." } elif battery_pct >= 10: return { "restricted": True, "reason": "Battery critically low", "power_source": "battery", "ups_available": True, "battery_percentage": battery_pct, "allow_firmware_flash": False, "allow_bootloader_flash": False, "allow_intensive_operations": False, "message": "Critical battery level. Read-only mode active." } else: return { "restricted": True, "reason": "Battery emergency level", "power_source": "battery", "ups_available": True, "battery_percentage": battery_pct, "allow_firmware_flash": False, "allow_bootloader_flash": False, "allow_intensive_operations": False, "message": "Emergency battery level. System will shutdown soon.", "shutdown_imminent": True } async def shutdown_device(self, delay: int = 30): """Initiate safe shutdown of the device. Args: delay: Delay in seconds before shutdown (default: 30) """ if self._shutdown_initiated: return self._shutdown_initiated = True # Notify all registered callbacks await self._trigger_event("shutdown_initiated", { "delay": delay, "battery_percentage": self._status.battery_percentage }) # Wait for the delay await asyncio.sleep(delay) # Execute shutdown command try: subprocess.run(["sudo", "shutdown", "-h", "now"], check=True) except Exception as e: print(f"Failed to shutdown: {e}") self._shutdown_initiated = False def register_event_callback(self, callback): """Register a callback for UPS events. Args: callback: Async function to call on events """ self._event_callbacks.append(callback) async def _update_battery_status(self): """Update battery status from UPS hardware.""" if not self._status.is_available: return try: # Read data from driver data = await self._driver.read_data() # Check for misconfigured UPS (all values are zero) - only notify once if data.percentage == 0.0 and data.voltage == 0.0 and data.current == 0.0: if not self._config_warning_sent: self._config_warning_sent = True model_name = self._driver.get_model_name() if self._driver else "Unknown" await self._trigger_event("ups_config_required", { "model": model_name, "message": f"UPS '{model_name}' detected but returning no data. May need reconfiguration.", "hint": "Run 'sudo dpkg-reconfigure pisugar-server' to select the correct PiSugar model." }) else: # Reset flag when we get valid data self._config_warning_sent = False # Update status with new data self._status.battery_percentage = data.percentage self._status.voltage = data.voltage self._status.current = data.current self._status.temperature = data.temperature # Calculate time_remaining if not provided by driver if data.time_remaining is not None: self._status.time_remaining = data.time_remaining elif data.current < 0 and data.percentage > 0: # Discharging - estimate time remaining # current is in Amps (negative when discharging) draw_ma = abs(data.current * 1000) if draw_ma > 10: # Only calculate if meaningful draw remaining_mah = self._battery_capacity_mah * (data.percentage / 100) hours_remaining = remaining_mah / draw_ma self._status.time_remaining = int(hours_remaining * 60) # Minutes else: self._status.time_remaining = None else: self._status.time_remaining = None # Determine power source and battery status from driver data if data.is_charging: self._status.power_source = PowerSource.AC if data.percentage >= 99.0: self._status.battery_status = BatteryStatus.FULL else: self._status.battery_status = BatteryStatus.CHARGING else: self._status.power_source = PowerSource.BATTERY if data.percentage < self._critical_threshold: self._status.battery_status = BatteryStatus.CRITICAL else: self._status.battery_status = BatteryStatus.DISCHARGING self._status.last_updated = datetime.utcnow().isoformat() self._status.error_message = None except Exception as e: print(f"Error reading battery data: {e}") self._status.error_message = str(e) async def _check_battery_thresholds(self): """Check battery levels and trigger actions.""" percentage = self._status.battery_percentage power_source = self._status.power_source # Only check thresholds when on battery power if power_source != PowerSource.BATTERY: self._shutdown_initiated = False return # Critical threshold - initiate shutdown if percentage <= self._shutdown_threshold and not self._shutdown_initiated: await self._trigger_event("battery_critical", { "percentage": percentage, "action": "shutdown_initiated" }) await self.shutdown_device(delay=60) # 60 second warning # Warning threshold elif percentage <= self._warning_threshold: await self._trigger_event("battery_warning", { "percentage": percentage, "threshold": self._warning_threshold }) # Critical threshold (but not shutdown yet) elif percentage <= self._critical_threshold: await self._trigger_event("battery_low", { "percentage": percentage, "threshold": self._critical_threshold }) async def _trigger_event(self, event_type: str, data: Dict[str, Any]): """Trigger event callbacks. Args: event_type: Type of event (e.g., "battery_warning") data: Event data """ event = { "type": event_type, "timestamp": datetime.utcnow().isoformat(), "data": data } # Call all registered callbacks for callback in self._event_callbacks: try: await callback(event) except Exception as e: print(f"Error in event callback: {e}") def set_shutdown_threshold(self, threshold: float): """Set battery percentage threshold for automatic shutdown. Args: threshold: Battery percentage (0-100) """ if 0 <= threshold <= 100: self._shutdown_threshold = threshold def set_warning_threshold(self, threshold: float): """Set battery percentage threshold for warnings. Args: threshold: Battery percentage (0-100) """ if 0 <= threshold <= 100: self._warning_threshold = threshold def close(self): """Close UPS driver connection.""" if self._driver: self._driver.close() # Global UPS manager instance _ups_manager: Optional[UPSManager] = None def get_ups_manager() -> UPSManager: """Get the global UPS manager instance.""" global _ups_manager if _ups_manager is None: _ups_manager = UPSManager() return _ups_manager