Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,11 @@
|
||||
"""UPS Manager for Dangerous Pi.
|
||||
|
||||
Handles I2C battery monitoring, safe shutdown triggers,
|
||||
and battery percentage reporting for UPS HAT devices.
|
||||
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
|
||||
@@ -9,12 +13,9 @@ from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from typing import Optional, Dict, Any
|
||||
try:
|
||||
from smbus2 import SMBus
|
||||
except ImportError:
|
||||
SMBus = None # Mock for development environments
|
||||
|
||||
from .. import config
|
||||
from .ups_drivers import UPSDriver, PiSugarDriver, I2CFuelGaugeDriver, auto_detect_driver
|
||||
|
||||
|
||||
class BatteryStatus(str, Enum):
|
||||
@@ -51,11 +52,14 @@ class UPSStatus:
|
||||
class UPSManager:
|
||||
"""Manages UPS battery monitoring and safe shutdown."""
|
||||
|
||||
def __init__(self):
|
||||
"""Initialize the UPS manager."""
|
||||
self._i2c_address = int(config.UPS_I2C_ADDRESS, 16)
|
||||
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._bus: Optional[SMBus] = None
|
||||
self._driver = driver or self._create_driver()
|
||||
self._status = UPSStatus(
|
||||
battery_percentage=0.0,
|
||||
voltage=0.0,
|
||||
@@ -67,30 +71,91 @@ class UPSManager:
|
||||
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 = []
|
||||
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize I2C connection to UPS.
|
||||
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
|
||||
"""
|
||||
if SMBus is None:
|
||||
self._status.is_available = False
|
||||
self._status.error_message = "smbus2 library not available"
|
||||
return False
|
||||
|
||||
try:
|
||||
# Try to open I2C bus (usually bus 1 on Raspberry Pi)
|
||||
self._bus = SMBus(1)
|
||||
# If no driver set, try auto-detection (for "auto" or unknown types)
|
||||
if self._driver is None:
|
||||
ups_type = config.UPS_TYPE.lower()
|
||||
|
||||
# Try to read from the device to verify it exists
|
||||
await self._read_battery_data()
|
||||
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
|
||||
|
||||
self._status.is_available = True
|
||||
self._status.error_message = None
|
||||
return True
|
||||
# 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
|
||||
@@ -125,6 +190,116 @@ class UPSManager:
|
||||
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.
|
||||
|
||||
@@ -161,21 +336,64 @@ class UPSManager:
|
||||
self._event_callbacks.append(callback)
|
||||
|
||||
async def _update_battery_status(self):
|
||||
"""Update battery status from I2C device."""
|
||||
if not self._bus or not self._status.is_available:
|
||||
"""Update battery status from UPS hardware."""
|
||||
if not self._status.is_available:
|
||||
return
|
||||
|
||||
try:
|
||||
data = await self._read_battery_data()
|
||||
# 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.power_source = data['power_source']
|
||||
self._status.battery_status = data['battery_status']
|
||||
self._status.time_remaining = data.get('time_remaining')
|
||||
self._status.temperature = data.get('temperature')
|
||||
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
|
||||
|
||||
@@ -183,78 +401,6 @@ class UPSManager:
|
||||
print(f"Error reading battery data: {e}")
|
||||
self._status.error_message = str(e)
|
||||
|
||||
async def _read_battery_data(self) -> Dict[str, Any]:
|
||||
"""Read battery data from I2C device.
|
||||
|
||||
This implementation is for MAX17040/MAX17048 fuel gauge.
|
||||
Adjust register addresses for different UPS HAT models.
|
||||
|
||||
Returns:
|
||||
Dictionary with battery data
|
||||
"""
|
||||
if not self._bus:
|
||||
raise RuntimeError("I2C bus not initialized")
|
||||
|
||||
# Run I2C read in thread pool to avoid blocking
|
||||
loop = asyncio.get_event_loop()
|
||||
|
||||
# Read voltage (registers 0x02-0x03)
|
||||
voltage_bytes = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_i2c_block_data,
|
||||
self._i2c_address,
|
||||
0x02,
|
||||
2
|
||||
)
|
||||
voltage_raw = (voltage_bytes[0] << 8) | voltage_bytes[1]
|
||||
voltage = (voltage_raw >> 4) * 1.25 # mV
|
||||
|
||||
# Read state of charge (registers 0x04-0x05)
|
||||
soc_bytes = await loop.run_in_executor(
|
||||
None,
|
||||
self._bus.read_i2c_block_data,
|
||||
self._i2c_address,
|
||||
0x04,
|
||||
2
|
||||
)
|
||||
soc_raw = (soc_bytes[0] << 8) | soc_bytes[1]
|
||||
percentage = soc_raw / 256.0
|
||||
|
||||
# Estimate current based on voltage change
|
||||
# This is a simple estimation; adjust based on your UPS HAT
|
||||
current = 0.0 # Some UPS HATs don't provide current readings
|
||||
|
||||
# Determine power source and battery status
|
||||
# Typically voltage > 4.1V means charging
|
||||
if voltage > 4100: # 4.1V in mV
|
||||
power_source = PowerSource.AC
|
||||
if percentage >= 99.0:
|
||||
battery_status = BatteryStatus.FULL
|
||||
else:
|
||||
battery_status = BatteryStatus.CHARGING
|
||||
else:
|
||||
power_source = PowerSource.BATTERY
|
||||
if percentage < self._critical_threshold:
|
||||
battery_status = BatteryStatus.CRITICAL
|
||||
else:
|
||||
battery_status = BatteryStatus.DISCHARGING
|
||||
|
||||
# Estimate time remaining (simplified)
|
||||
time_remaining = None
|
||||
if power_source == PowerSource.BATTERY and current > 0:
|
||||
# Rough estimation: battery_mah * (percentage/100) / current_ma
|
||||
# This would need actual battery capacity from config
|
||||
pass
|
||||
|
||||
return {
|
||||
'percentage': percentage,
|
||||
'voltage': voltage,
|
||||
'current': current,
|
||||
'power_source': power_source,
|
||||
'battery_status': battery_status,
|
||||
'time_remaining': time_remaining,
|
||||
'temperature': None # Not all UPS HATs provide temperature
|
||||
}
|
||||
|
||||
async def _check_battery_thresholds(self):
|
||||
"""Check battery levels and trigger actions."""
|
||||
@@ -327,10 +473,9 @@ class UPSManager:
|
||||
self._warning_threshold = threshold
|
||||
|
||||
def close(self):
|
||||
"""Close I2C bus connection."""
|
||||
if self._bus:
|
||||
self._bus.close()
|
||||
self._bus = None
|
||||
"""Close UPS driver connection."""
|
||||
if self._driver:
|
||||
self._driver.close()
|
||||
|
||||
|
||||
# Global UPS manager instance
|
||||
|
||||
Reference in New Issue
Block a user