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:
89
app/backend/managers/ups_drivers/__init__.py
Normal file
89
app/backend/managers/ups_drivers/__init__.py
Normal file
@@ -0,0 +1,89 @@
|
||||
"""UPS driver implementations for different hardware.
|
||||
|
||||
Supports multiple UPS types:
|
||||
- pisugar: PiSugar 2/3 via native I2C (no daemon needed)
|
||||
- pisugar-daemon: PiSugar via pisugar-server TCP daemon (legacy)
|
||||
- i2c: Generic I2C fuel gauge (MAX17040/MAX17048)
|
||||
- auto: Automatically detect available UPS hardware
|
||||
"""
|
||||
from typing import Optional, Tuple
|
||||
from .base import UPSDriver, UPSData
|
||||
from .pisugar_driver import PiSugarDriver # TCP daemon driver (legacy)
|
||||
from .pisugar_i2c_driver import PiSugarI2CDriver, PiSugarModel, ButtonEvent
|
||||
from .i2c_driver import I2CFuelGaugeDriver
|
||||
|
||||
|
||||
async def auto_detect_driver(
|
||||
pisugar_host: str = "127.0.0.1",
|
||||
pisugar_port: int = 8423,
|
||||
prefer_native_i2c: bool = True
|
||||
) -> Tuple[Optional[UPSDriver], str]:
|
||||
"""Auto-detect available UPS hardware and return appropriate driver.
|
||||
|
||||
Detection order (first match wins):
|
||||
1. PiSugar via native I2C (preferred - no daemon overhead)
|
||||
2. PiSugar via TCP daemon (fallback if I2C fails)
|
||||
3. I2C Fuel Gauge (MAX17040/MAX17048 at 0x36 or other addresses)
|
||||
|
||||
Args:
|
||||
pisugar_host: PiSugar server host for daemon detection (legacy)
|
||||
pisugar_port: PiSugar server port (legacy)
|
||||
prefer_native_i2c: If True, prefer native I2C driver over daemon
|
||||
|
||||
Returns:
|
||||
Tuple of (driver_instance or None, detection_message)
|
||||
"""
|
||||
print("UPS auto-detect: Starting detection...")
|
||||
|
||||
# Try native PiSugar I2C first (no daemon overhead, minimal CPU)
|
||||
if prefer_native_i2c:
|
||||
print("UPS auto-detect: Trying PiSugar I2C (native)...")
|
||||
detected, driver = await PiSugarI2CDriver.detect()
|
||||
if detected and driver:
|
||||
model = driver.get_model_name()
|
||||
print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}")
|
||||
return (driver, f"Detected PiSugar UPS via I2C: {model}")
|
||||
print("UPS auto-detect: PiSugar I2C not detected")
|
||||
|
||||
# Try PiSugar daemon (legacy fallback)
|
||||
print("UPS auto-detect: Trying PiSugar daemon...")
|
||||
detected, model = await PiSugarDriver.detect(host=pisugar_host, port=pisugar_port)
|
||||
if detected:
|
||||
driver = PiSugarDriver(host=pisugar_host, port=pisugar_port)
|
||||
print(f"UPS auto-detect: SUCCESS - PiSugar daemon: {model}")
|
||||
return (driver, f"Detected PiSugar UPS via daemon: {model}")
|
||||
print("UPS auto-detect: PiSugar daemon not detected")
|
||||
|
||||
# Try native I2C again if we skipped it earlier
|
||||
if not prefer_native_i2c:
|
||||
print("UPS auto-detect: Trying PiSugar I2C (second attempt)...")
|
||||
detected, driver = await PiSugarI2CDriver.detect()
|
||||
if detected and driver:
|
||||
model = driver.get_model_name()
|
||||
print(f"UPS auto-detect: SUCCESS - PiSugar I2C: {model}")
|
||||
return (driver, f"Detected PiSugar UPS via I2C: {model}")
|
||||
|
||||
# Try generic I2C fuel gauge (exclude PiSugar I2C addresses)
|
||||
print("UPS auto-detect: Trying generic I2C fuel gauge...")
|
||||
exclude_addrs = list(PiSugarI2CDriver.KNOWN_ADDRESSES.keys()) + [PiSugarI2CDriver.RTC_ADDRESS]
|
||||
detected, i2c_addr = await I2CFuelGaugeDriver.detect(exclude_addresses=exclude_addrs)
|
||||
if detected:
|
||||
driver = I2CFuelGaugeDriver(i2c_address=i2c_addr)
|
||||
print(f"UPS auto-detect: SUCCESS - I2C fuel gauge at 0x{i2c_addr:02X}")
|
||||
return (driver, f"Detected I2C fuel gauge at address 0x{i2c_addr:02X}")
|
||||
print("UPS auto-detect: No I2C fuel gauge detected")
|
||||
|
||||
print("UPS auto-detect: No UPS hardware found")
|
||||
return (None, "No UPS hardware detected")
|
||||
|
||||
|
||||
__all__ = [
|
||||
"UPSDriver",
|
||||
"UPSData",
|
||||
"PiSugarDriver",
|
||||
"PiSugarI2CDriver",
|
||||
"PiSugarModel",
|
||||
"ButtonEvent",
|
||||
"I2CFuelGaugeDriver",
|
||||
"auto_detect_driver",
|
||||
]
|
||||
60
app/backend/managers/ups_drivers/base.py
Normal file
60
app/backend/managers/ups_drivers/base.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Base class for UPS drivers."""
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class UPSData:
|
||||
"""Battery data from UPS hardware."""
|
||||
percentage: float # 0-100%
|
||||
voltage: float # mV
|
||||
current: float # A (positive=charging, negative=discharging)
|
||||
is_charging: bool
|
||||
temperature: Optional[float] = None # Celsius
|
||||
time_remaining: Optional[int] = None # Minutes
|
||||
|
||||
|
||||
class UPSDriver(ABC):
|
||||
"""Abstract base class for UPS hardware drivers."""
|
||||
|
||||
@abstractmethod
|
||||
async def initialize(self) -> bool:
|
||||
"""Initialize connection to UPS hardware.
|
||||
|
||||
Returns:
|
||||
True if initialization successful, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def read_data(self) -> UPSData:
|
||||
"""Read current battery data from UPS.
|
||||
|
||||
Returns:
|
||||
UPSData object with current readings
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
async def is_available(self) -> bool:
|
||||
"""Check if UPS hardware is available.
|
||||
|
||||
Returns:
|
||||
True if hardware is accessible, False otherwise
|
||||
"""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def close(self):
|
||||
"""Close connection to UPS hardware."""
|
||||
pass
|
||||
|
||||
@abstractmethod
|
||||
def get_model_name(self) -> str:
|
||||
"""Get the UPS model/type name.
|
||||
|
||||
Returns:
|
||||
Human-readable model name
|
||||
"""
|
||||
pass
|
||||
216
app/backend/managers/ups_drivers/i2c_driver.py
Normal file
216
app/backend/managers/ups_drivers/i2c_driver.py
Normal file
@@ -0,0 +1,216 @@
|
||||
"""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
|
||||
)
|
||||
282
app/backend/managers/ups_drivers/pisugar_driver.py
Normal file
282
app/backend/managers/ups_drivers/pisugar_driver.py
Normal file
@@ -0,0 +1,282 @@
|
||||
"""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
|
||||
659
app/backend/managers/ups_drivers/pisugar_i2c_driver.py
Normal file
659
app/backend/managers/ups_drivers/pisugar_i2c_driver.py
Normal file
@@ -0,0 +1,659 @@
|
||||
"""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)
|
||||
Reference in New Issue
Block a user