"""PiSugar UPS driver. Communicates with pisugar-server daemon via TCP socket. Supports PiSugar 2, PiSugar 2 Pro, PiSugar 3, and PiSugar 3 Plus. """ import asyncio import socket from typing import Optional, Tuple from .base import UPSDriver, UPSData class PiSugarDriver(UPSDriver): """Driver for PiSugar UPS devices.""" # Known I2C addresses for PiSugar devices PISUGAR_I2C_ADDRESSES = [0x57, 0x32] # Battery IC, RTC @classmethod async def detect(cls, host: str = "127.0.0.1", port: int = 8423) -> Tuple[bool, Optional[str]]: """Detect if PiSugar hardware is available. Tries multiple detection methods: 1. Check if pisugar-server daemon is responding 2. Scan I2C bus for known PiSugar addresses (fallback) Args: host: PiSugar server host port: PiSugar server port Returns: Tuple of (detected: bool, model_name: Optional[str]) """ # Method 1: Try to connect to pisugar-server daemon try: reader, writer = await asyncio.wait_for( asyncio.open_connection(host, port), timeout=2.0 ) try: # Send model query writer.write(b"get model\n") await writer.drain() response = await asyncio.wait_for( reader.readline(), timeout=2.0 ) model = response.decode().strip() if model and "model:" in model.lower(): # Parse "model: PiSugar 3" format parts = model.split(":", 1) if len(parts) > 1: model_name = parts[1].strip() if model_name and model_name.lower() != "unknown": return (True, model_name) finally: writer.close() await writer.wait_closed() except (asyncio.TimeoutError, ConnectionRefusedError, OSError): pass # Method 2: Check I2C addresses (requires smbus2) # NOTE: We only log if hardware is found - PiSugarDriver requires the daemon # If daemon isn't running, we can't use PiSugarDriver even if hardware exists i2c_found = False try: from smbus2 import SMBus bus = SMBus(1) try: for addr in cls.PISUGAR_I2C_ADDRESSES: try: bus.read_byte(addr) i2c_found = True break except OSError: continue finally: bus.close() except ImportError: pass except Exception: pass if i2c_found: # Hardware found but daemon not running - can't use PiSugarDriver print("PiSugar hardware detected via I2C but pisugar-server daemon not running") print("Install pisugar-server or start the daemon to enable PiSugar UPS support") return (False, None) def __init__(self, host: str = "127.0.0.1", port: int = 8423, timeout: float = 5.0): """Initialize PiSugar driver. Args: host: PiSugar server host (default: localhost) port: PiSugar server port (default: 8423) timeout: Command timeout in seconds (default: 5.0) """ self._host = host self._port = port self._timeout = timeout self._model: Optional[str] = None self._available = False async def initialize(self) -> bool: """Initialize connection to PiSugar daemon. Returns: True if initialization successful, False otherwise """ try: # Test connection by getting model self._model = await self._send_command("get model") if self._model and self._model != "Unknown": self._available = True return True else: self._available = False return False except Exception as e: print(f"Failed to initialize PiSugar: {e}") self._available = False return False async def read_data(self) -> UPSData: """Read current battery data from PiSugar. Returns: UPSData object with current readings """ if not self._available: raise RuntimeError("PiSugar not initialized or not available") # Execute commands in parallel for efficiency results = await asyncio.gather( self._send_command("get battery"), self._send_command("get battery_v"), self._send_command("get battery_i"), self._send_command("get battery_power_plugged"), return_exceptions=True ) # Parse results percentage = self._parse_float(results[0], 0.0) voltage = self._parse_float(results[1], 0.0) current = self._parse_float(results[2], 0.0) is_charging = self._parse_bool(results[3], False) return UPSData( percentage=percentage, voltage=voltage, current=current, is_charging=is_charging, temperature=None, # PiSugar doesn't provide temperature time_remaining=None # Would need battery capacity config to calculate ) async def is_available(self) -> bool: """Check if PiSugar hardware is available. Returns: True if hardware is accessible, False otherwise """ if not self._available: # Try to reinitialize return await self.initialize() return True def close(self): """Close connection to PiSugar (stateless, nothing to close).""" self._available = False def get_model_name(self) -> str: """Get the PiSugar model name. Returns: Human-readable model name """ return self._model or "PiSugar" async def _send_command(self, command: str) -> str: """Send command to PiSugar server and get response. Args: command: Command to send (e.g., "get battery") Returns: Response string from server Raises: RuntimeError: If connection fails or times out """ try: # Connect to PiSugar server reader, writer = await asyncio.wait_for( asyncio.open_connection(self._host, self._port), timeout=self._timeout ) try: # Send command writer.write(f"{command}\n".encode()) await writer.drain() # Read response (single line) response = await asyncio.wait_for( reader.readline(), timeout=self._timeout ) return response.decode().strip() finally: writer.close() await writer.wait_closed() except asyncio.TimeoutError: raise RuntimeError(f"PiSugar command timeout: {command}") except ConnectionRefusedError: raise RuntimeError("PiSugar server not running (connection refused)") except Exception as e: raise RuntimeError(f"PiSugar communication error: {e}") def _parse_float(self, value, default: float = 0.0) -> float: """Parse float value from response. Args: value: Response value (could be string, float, or Exception) default: Default value if parsing fails Returns: Parsed float value or default """ if isinstance(value, Exception): return default try: # PiSugar responses are often in format "battery: 85.5" if isinstance(value, str): # Try to extract number from response parts = value.split(":") if len(parts) > 1: return float(parts[1].strip()) else: return float(value.strip()) return float(value) except (ValueError, AttributeError): return default def _parse_bool(self, value, default: bool = False) -> bool: """Parse boolean value from response. Args: value: Response value (could be string, bool, or Exception) default: Default value if parsing fails Returns: Parsed boolean value or default """ if isinstance(value, Exception): return default try: if isinstance(value, bool): return value if isinstance(value, str): # PiSugar returns "battery_power_plugged: true" or "false" parts = value.split(":") if len(parts) > 1: return parts[1].strip().lower() == "true" else: return value.strip().lower() in ("true", "1", "yes") return bool(value) except (ValueError, AttributeError): return default