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:
michael
2026-01-06 13:45:29 -08:00
parent 1da6730735
commit 4f35df1781
323 changed files with 98287 additions and 1195 deletions

View File

@@ -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