"""I2C Fuel Gauge driver for generic UPS HATs. Supports MAX17040/MAX17048 and similar I2C fuel gauge chips. """ import asyncio from typing import Optional, Tuple, List try: from smbus2 import SMBus except ImportError: SMBus = None from .base import UPSDriver, UPSData class I2CFuelGaugeDriver(UPSDriver): """Driver for I2C-based fuel gauge UPS HATs.""" # Known I2C addresses for fuel gauge chips # Excludes PiSugar addresses (0x57, 0x32) which are handled by PiSugarDriver FUEL_GAUGE_ADDRESSES = [ 0x36, # MAX17040/MAX17048/MAX17050 (most common) 0x55, # BQ27441 (TI fuel gauge) 0x62, # BQ27510 (TI fuel gauge) 0x0B, # Some SBS battery monitors ] @classmethod async def detect(cls, i2c_bus: int = 1, exclude_addresses: List[int] = None) -> Tuple[bool, Optional[int]]: """Detect if an I2C fuel gauge is available. Scans known fuel gauge I2C addresses to find hardware. Args: i2c_bus: I2C bus number (default: 1) exclude_addresses: List of addresses to skip (e.g., PiSugar addresses) Returns: Tuple of (detected: bool, i2c_address: Optional[int]) """ if SMBus is None: print("I2C Fuel Gauge: smbus2 not available") return (False, None) exclude = exclude_addresses or [] print(f"I2C Fuel Gauge: Scanning addresses {[hex(a) for a in cls.FUEL_GAUGE_ADDRESSES]}, excluding {[hex(a) for a in exclude]}") try: bus = SMBus(i2c_bus) try: for addr in cls.FUEL_GAUGE_ADDRESSES: if addr in exclude: continue try: # Try to read a byte - if device exists, this will succeed bus.read_byte(addr) print(f"I2C Fuel Gauge: Found device at 0x{addr:02X}") # Additional validation: try to read SOC register (0x04) # MAX17040/MAX17048 should respond to this try: data = bus.read_i2c_block_data(addr, 0x04, 2) print(f"I2C Fuel Gauge: SOC register read OK at 0x{addr:02X}: {data}") return (True, addr) except OSError as e: # Device responded to address but not to register read # Still likely a fuel gauge, just different protocol print(f"I2C Fuel Gauge: SOC register read failed at 0x{addr:02X}: {e}") return (True, addr) except OSError: continue finally: bus.close() except Exception as e: print(f"I2C Fuel Gauge: Detection error: {e}") print("I2C Fuel Gauge: No device found") return (False, None) def __init__(self, i2c_address: int = 0x36, i2c_bus: int = 1): """Initialize I2C fuel gauge driver. Args: i2c_address: I2C address of fuel gauge chip (default: 0x36) i2c_bus: I2C bus number (default: 1 for Raspberry Pi) """ self._i2c_address = i2c_address self._i2c_bus = i2c_bus self._bus: Optional[SMBus] = None self._available = False self._critical_threshold = 15.0 # For status determination async def initialize(self) -> bool: """Initialize I2C connection to fuel gauge. Returns: True if initialization successful, False otherwise """ if SMBus is None: print("smbus2 library not available") self._available = False return False try: # Open I2C bus self._bus = SMBus(self._i2c_bus) # Try to read from device to verify it exists await self._test_read() self._available = True return True except Exception as e: print(f"Failed to initialize I2C fuel gauge: {e}") self._available = False self._bus = None return False async def read_data(self) -> UPSData: """Read current battery data from fuel gauge. Returns: UPSData object with current readings """ if not self._bus or not self._available: raise RuntimeError("I2C fuel gauge not initialized") 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 # Convert to 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 (simple approach, some HATs don't provide this) current = 0.0 # Determine if charging based on voltage # Typically voltage > 4.1V means charging is_charging = voltage > 4100 # 4.1V in mV return UPSData( percentage=percentage, voltage=voltage, current=current, is_charging=is_charging, temperature=None, # Not all fuel gauges provide temperature time_remaining=None # Would need battery capacity config to calculate ) async def is_available(self) -> bool: """Check if I2C fuel gauge is available. Returns: True if hardware is accessible, False otherwise """ if not self._available or not self._bus: # Try to reinitialize return await self.initialize() return True def close(self): """Close I2C bus connection.""" if self._bus: self._bus.close() self._bus = None self._available = False def get_model_name(self) -> str: """Get the fuel gauge model name. Returns: Human-readable model name """ return "I2C Fuel Gauge (MAX17040/MAX17048)" async def _test_read(self): """Test read from device to verify it exists. Raises: Exception if device doesn't respond """ if not self._bus: raise RuntimeError("I2C bus not initialized") loop = asyncio.get_event_loop() # Try to read version register (0x08) await loop.run_in_executor( None, self._bus.read_i2c_block_data, self._i2c_address, 0x08, 2 )