"""Native PiSugar I2C driver - direct hardware communication. Bypasses pisugar-server daemon entirely for minimal CPU usage. Supports: - PiSugar 2 (4-LEDs) - IP5209 chip at I2C address 0x75 - PiSugar 2 Pro - IP5209 chip at I2C address 0x75 - PiSugar 3 / 3 Plus - IP5312 chip at I2C address 0x57 Features: - Battery voltage, current, and percentage reading - Power plug/charging detection - Button tap detection (single, double, long press) - RTC alarm for scheduled wake-up - Force shutdown capability Register information extracted from: https://github.com/PiSugar/pisugar-power-manager-rs """ import asyncio from dataclasses import dataclass from datetime import datetime from enum import Enum from typing import Callable, List, Optional, Tuple try: from smbus2 import SMBus except ImportError: SMBus = None from .base import UPSDriver, UPSData class PiSugarModel(Enum): """Supported PiSugar models.""" PISUGAR_2 = "PiSugar 2" PISUGAR_2_PRO = "PiSugar 2 Pro" PISUGAR_3 = "PiSugar 3" UNKNOWN = "Unknown PiSugar" class ButtonEvent(Enum): """Button event types.""" SINGLE_TAP = "single" DOUBLE_TAP = "double" LONG_PRESS = "long" @dataclass class ChipConfig: """Configuration for a specific battery management chip.""" i2c_address: int voltage_reg_low: int voltage_reg_high: int current_reg_low: int current_reg_high: int power_status_reg: int power_plugged_mask: int model: PiSugarModel # IP5209 chip used in PiSugar 2 series IP5209_CONFIG = ChipConfig( i2c_address=0x75, voltage_reg_low=0xA2, voltage_reg_high=0xA3, current_reg_low=0xA4, current_reg_high=0xA5, power_status_reg=0x55, power_plugged_mask=0x10, # Bit 4 model=PiSugarModel.PISUGAR_2, ) # IP5312 chip used in PiSugar 3 series IP5312_CONFIG = ChipConfig( i2c_address=0x57, voltage_reg_low=0xD0, voltage_reg_high=0xD1, current_reg_low=0xD2, current_reg_high=0xD3, power_status_reg=0xDD, power_plugged_mask=0x1F, # Value 0x1F = plugged in model=PiSugarModel.PISUGAR_3, ) # Battery voltage to percentage lookup curve (voltage in mV -> percentage) # Based on typical LiPo discharge curve BATTERY_CURVE = [ (4160, 100), (4050, 90), (3920, 80), (3800, 70), (3720, 60), (3650, 50), (3580, 40), (3520, 30), (3420, 20), (3300, 10), (3100, 0), ] class PiSugarI2CDriver(UPSDriver): """Native I2C driver for PiSugar UPS devices. Reads directly from the IP5209/IP5312 battery management chip, eliminating the need for the pisugar-server daemon. """ # Known I2C addresses for auto-detection KNOWN_ADDRESSES = { 0x75: IP5209_CONFIG, # PiSugar 2 series 0x57: IP5312_CONFIG, # PiSugar 3 series } # RTC address (SD3078) - used for detection but not read RTC_ADDRESS = 0x32 @classmethod async def detect(cls, i2c_bus: int = 1, retries: int = 3, retry_delay: float = 0.5) -> Tuple[bool, Optional["PiSugarI2CDriver"]]: """Detect PiSugar hardware by probing I2C addresses. Args: i2c_bus: I2C bus number (default: 1 for Raspberry Pi) retries: Number of detection attempts (for early-boot scenarios) retry_delay: Delay between retries in seconds Returns: Tuple of (detected: bool, driver_instance or None) """ if SMBus is None: print("PiSugar I2C: smbus2 not available") return (False, None) for attempt in range(retries): try: bus = SMBus(i2c_bus) try: # Try each known address for addr, config in cls.KNOWN_ADDRESSES.items(): try: # Try to read a byte from the address bus.read_byte(addr) # Validate by reading voltage registers try: low = bus.read_byte_data(addr, config.voltage_reg_low) high = bus.read_byte_data(addr, config.voltage_reg_high) # Success - create driver instance print(f"PiSugar I2C: Found {config.model.value} at 0x{addr:02X} (voltage regs: {low}, {high})") driver = cls(chip_config=config, i2c_bus=i2c_bus) return (True, driver) except OSError as e: # Address responded but not the expected chip print(f"PiSugar I2C: 0x{addr:02X} responded but voltage reg read failed: {e}") continue except OSError: # No device at this address continue finally: bus.close() except Exception as e: if attempt < retries - 1: print(f"PiSugar I2C: Detection attempt {attempt + 1} failed ({e}), retrying...") await asyncio.sleep(retry_delay) else: print(f"PiSugar I2C: All detection attempts failed: {e}") return (False, None) def __init__( self, chip_config: ChipConfig = IP5209_CONFIG, i2c_bus: int = 1 ): """Initialize PiSugar I2C driver. Args: chip_config: Configuration for the specific chip i2c_bus: I2C bus number (default: 1) """ self._config = chip_config self._i2c_bus = i2c_bus self._bus: Optional[SMBus] = None self._available = False async def initialize(self) -> bool: """Initialize I2C connection. Returns: True if initialization successful """ if SMBus is None: print("smbus2 library not available for PiSugar I2C driver") return False try: self._bus = SMBus(self._i2c_bus) # Verify we can read from the chip loop = asyncio.get_event_loop() await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, self._config.voltage_reg_low ) self._available = True return True except Exception as e: print(f"Failed to initialize PiSugar I2C: {e}") self._available = False if self._bus: self._bus.close() self._bus = None return False async def read_data(self) -> UPSData: """Read battery data directly from I2C. Returns: UPSData with current readings """ if not self._bus or not self._available: raise RuntimeError("PiSugar I2C not initialized") loop = asyncio.get_event_loop() # Read voltage voltage = await self._read_voltage(loop) # Read current current = await self._read_current(loop) # Read power status is_charging = await self._read_power_status(loop) # Convert voltage to percentage using lookup curve percentage = self._voltage_to_percentage(voltage) return UPSData( percentage=percentage, voltage=voltage, current=current, is_charging=is_charging, temperature=None, time_remaining=None ) async def _read_voltage(self, loop) -> float: """Read battery voltage in mV.""" low = await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, self._config.voltage_reg_low ) high = await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, self._config.voltage_reg_high ) if self._config.i2c_address == 0x75: # IP5209 # Two's complement with sign bit at 0x20 raw = ((high & 0x1F) << 8) | low if high & 0x20: voltage = 2600.0 - (raw * 0.26855) else: voltage = 2600.0 + (raw * 0.26855) else: # IP5312 raw = ((high & 0x3F) << 8) | low voltage = (raw * 0.26855) + 2600.0 return voltage async def _read_current(self, loop) -> float: """Read battery current in Amps. Returns: Current in Amps (positive = charging, negative = discharging) """ low = await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, self._config.current_reg_low ) high = await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, self._config.current_reg_high ) if self._config.i2c_address == 0x75: # IP5209 if high & 0x20: # Sign bit set = discharging = negative current # Sign extend for proper 2's complement raw_signed = (((high | 0xC0) << 8) | low) # Convert to signed 16-bit if raw_signed > 32767: raw_signed -= 65536 current = raw_signed * 0.745985 / 1000.0 # Result in A else: # Sign bit clear = charging = positive current raw = ((high & 0x1F) << 8) | low current = raw * 0.745985 / 1000.0 # Result in A else: # IP5312 raw = ((high & 0x1F) << 8) | low current = raw * 2.68554 / 1000.0 # Result in A if high & 0x20: current = -current # Sign bit = discharging return current async def _read_power_status(self, loop) -> bool: """Read power plugged/charging status.""" status = await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, self._config.power_status_reg ) if self._config.i2c_address == 0x75: # IP5209 return bool(status & self._config.power_plugged_mask) else: # IP5312 # For IP5312, 0x1F means plugged in return status == self._config.power_plugged_mask def _voltage_to_percentage(self, voltage_mv: float) -> float: """Convert voltage to battery percentage using lookup curve.""" if voltage_mv >= BATTERY_CURVE[0][0]: return 100.0 if voltage_mv <= BATTERY_CURVE[-1][0]: return 0.0 # Linear interpolation between curve points for i in range(len(BATTERY_CURVE) - 1): v_high, p_high = BATTERY_CURVE[i] v_low, p_low = BATTERY_CURVE[i + 1] if v_low <= voltage_mv <= v_high: # Interpolate ratio = (voltage_mv - v_low) / (v_high - v_low) return p_low + ratio * (p_high - p_low) return 50.0 # Fallback async def is_available(self) -> bool: """Check if hardware is available.""" if not self._available: return await self.initialize() return True def close(self): """Close I2C bus.""" if self._bus: self._bus.close() self._bus = None self._available = False def get_model_name(self) -> str: """Get detected model name.""" return self._config.model.value # ========== Button Detection ========== async def read_button_state(self) -> bool: """Read current button GPIO state. Returns: True if button is pressed """ if not self._bus or not self._available: return False loop = asyncio.get_event_loop() try: status = await loop.run_in_executor( None, self._bus.read_byte_data, self._config.i2c_address, 0x55 # GPIO status register ) if self._config.i2c_address == 0x75: # IP5209 # 4-LED models use GPIO4 (bit 4), 2-LED use GPIO1 (bit 1) # Default to 4-LED behavior return bool(status & 0x10) else: # IP5312 return bool(status & 0x10) except Exception: return False # ========== RTC Functions (SD3078 at 0x32) ========== async def get_rtc_time(self) -> Optional[datetime]: """Read current time from RTC. Returns: datetime object or None if RTC not available """ if not self._bus: return None loop = asyncio.get_event_loop() try: # Read 7 bytes starting from register 0x00 data = await loop.run_in_executor( None, self._bus.read_i2c_block_data, self.RTC_ADDRESS, 0x00, 7 ) # Parse BCD format: sec, min, hour, weekday, day, month, year second = self._bcd_to_int(data[0] & 0x7F) minute = self._bcd_to_int(data[1] & 0x7F) hour = self._bcd_to_int(data[2] & 0x3F) # 24-hour format day = self._bcd_to_int(data[4] & 0x3F) month = self._bcd_to_int(data[5] & 0x1F) year = 2000 + self._bcd_to_int(data[6]) return datetime(year, month, day, hour, minute, second) except Exception: return None async def set_rtc_time(self, dt: datetime) -> bool: """Set RTC time. Args: dt: datetime to set Returns: True if successful """ if not self._bus: return False loop = asyncio.get_event_loop() try: # Enable write mode (CTR2 register 0x10, set WRTC1/WRTC2/WRTC3) await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x10, 0x80 # Enable write ) await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x0F, 0x84 # Enable write ) # Write time data in BCD format data = [ self._int_to_bcd(dt.second), self._int_to_bcd(dt.minute), self._int_to_bcd(dt.hour) | 0x80, # 24-hour mode self._int_to_bcd(dt.weekday()), self._int_to_bcd(dt.day), self._int_to_bcd(dt.month), self._int_to_bcd(dt.year % 100) ] await loop.run_in_executor( None, self._bus.write_i2c_block_data, self.RTC_ADDRESS, 0x00, data ) # Disable write mode await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x0F, 0x00 ) await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x10, 0x00 ) return True except Exception: return False async def set_wake_alarm(self, wake_time: datetime, repeat_weekdays: int = 0) -> bool: """Set RTC alarm for scheduled wake-up. Args: wake_time: Time to wake up repeat_weekdays: Bitmask for weekday repeat (0=one-time, 0x7F=daily) Returns: True if successful """ if not self._bus: return False loop = asyncio.get_event_loop() try: # Enable write mode await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x10, 0x80 ) # Write alarm time (registers 0x07-0x0D) alarm_data = [ self._int_to_bcd(wake_time.second), self._int_to_bcd(wake_time.minute), self._int_to_bcd(wake_time.hour) | 0x80, # 24-hour mode repeat_weekdays, # Weekday repeat mask self._int_to_bcd(wake_time.day), self._int_to_bcd(wake_time.month), self._int_to_bcd(wake_time.year % 100) ] await loop.run_in_executor( None, self._bus.write_i2c_block_data, self.RTC_ADDRESS, 0x07, alarm_data ) # Enable alarm (CTR2 register 0x10, set INTAE bit) await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x0E, 0x07 # Enable hour/minute/second alarm match ) await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x10, 0x04 # Enable alarm interrupt ) # Set frequency for auto power-on await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x11, 0x01 # 1/2Hz for auto power-on ) return True except Exception: return False async def clear_wake_alarm(self) -> bool: """Clear/disable wake alarm. Returns: True if successful """ if not self._bus: return False loop = asyncio.get_event_loop() try: # Disable alarm interrupt await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x10, 0x00 ) await loop.run_in_executor( None, self._bus.write_byte_data, self.RTC_ADDRESS, 0x0E, 0x00 ) return True except Exception: return False # ========== Power Control ========== async def force_shutdown(self) -> bool: """Force immediate shutdown of the Pi. This enables light-load auto-shutdown on the IP5209/IP5312 which will cut power when the Pi draws minimal current. Returns: True if command was sent """ if not self._bus or not self._available: return False loop = asyncio.get_event_loop() try: if self._config.i2c_address == 0x75: # IP5209 # Enable light-load auto-shutdown and force shutdown # Register 0x01, set appropriate bits await loop.run_in_executor( None, self._bus.write_byte_data, self._config.i2c_address, 0x01, 0x29 # Enable auto-shutdown ) else: # IP5312 # IP5312 has different shutdown mechanism await loop.run_in_executor( None, self._bus.write_byte_data, self._config.i2c_address, 0x03, 0x08 # Force shutdown ) return True except Exception: return False # ========== Helpers ========== def _bcd_to_int(self, bcd: int) -> int: """Convert BCD byte to integer.""" return (bcd >> 4) * 10 + (bcd & 0x0F) def _int_to_bcd(self, value: int) -> int: """Convert integer to BCD byte.""" return ((value // 10) << 4) | (value % 10)