Files
pi-pm3/app/backend/managers/ups_drivers/i2c_driver.py
michael 4f35df1781 Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:46:22 -08:00

217 lines
6.8 KiB
Python

"""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
)