Source code for drivers.MAX17261.MAX17261

#!/usr/bin/python
"""Driver for the Maxim MAX17261 fuel gauge IC.

Provides I2C communication with the MAX17261 ModelGauge m5 EZ fuel gauge for
monitoring lithium-ion battery state of charge (SOC), voltage, current, and
remaining capacity. The driver handles power-on-reset (POR) detection,
automatic initialization with battery parameters, and register-level access.

The MAX17261 uses Maxim's ModelGauge m5 EZ algorithm which requires minimal
configuration -- only battery capacity, empty voltage, and charge termination
current. The IC learns the battery characteristics over time.

Hardware:
    - Interface: I2C
    - Default address: 0x36
    - Supply: 1.7V to 5.5V
    - Current sense: External shunt resistor (default 0.02 ohm)
    - Supported chemistries: Li-ion, LiPo, LiFePO4

Typical usage::

    gauge = MAX17261(address=0x36, device_number=0, batteryCapacity=6000)
    gauge.setValues(debug=True)
    soc = gauge.getSOC()
    voltage = gauge.getInstantaneousVoltage()
    current = gauge.getInstantaneousCurrent()

Note:
    Requires ``smbus2``. Register format follows Maxim AN6358 "Standard
    Register Formats". The driver auto-detects POR events and re-initializes
    the IC accordingly.
"""

import time

from smbus2 import SMBus, i2c_msg

from utils.oizom_logger import OizomLogger

# -----------------------------------------------------------------------------
# Configure logging
# -----------------------------------------------------------------------------
basic_logger = OizomLogger(__name__).get()
context_logger = OizomLogger(__name__)


[docs] class MAX17261: """I2C driver for the Maxim MAX17261 battery fuel gauge. Monitors battery state including voltage, current, state of charge, remaining capacity, and time-to-empty/full. Handles POR detection and automatic re-initialization with configured battery parameters. The register multipliers follow Maxim AN6358 "Standard Register Formats" and are adjusted based on the sense resistor value. Attributes: I2C_ADDRESS: I2C slave address (default 0x36). debug: Enable verbose fuel gauge logging. """ I2C_ADDRESS = 0x36 Status = 0x00 # Maintains all flags related to alert thresholds and battery insertion or removal. VCell = 0x09 # VCell reports the voltage measured between BATT and CSP. AvgVCell = 0x19 # The AvgVCell register reports an average of the VCell register readings. Current = 0x0A # Voltage between the CSP and CSN pins, and would need to convert to current AvgCurrent = 0x0B # The AvgCurrent register reports an average of Current register readings RepSOC = 0x06 # The Reported State of Charge of connected battery. Refer to AN6358 page 23 and 13 RepCap = 0x05 # Reported Capacity. Refer to page 23 and 13 of AN6358. TimeToEmpty = 0x11 # How long before battery is empty (in ms). Refer to page 24 and 13 of AN6358 TimeToFull = 0x20 # How long it will take full the battery DesignCap = 0x18 # Capacity of battery inserted, not typically used for user requested capacity VEmpty = 0x3A Modelcfg = 0xDB # private variables _resistSensor = 0.02 # default internal resist sensor # Based on "Standard Register Formats" AN6358, figure 1.3. # Multipliers are constants used to multiply register value in order to get final result _capacity_multiplier_mAH = 5e-3 # refer to row "Capacity" _current_multiplier_mV = 1.5625e-3 # refer to row "Current" _voltage_multiplier_V = 7.8125e-5 # refer to row "Voltage" _time_multiplier_Hours = ( 5.625 / 3600.0 ) # Least Significant Bit= 5.625 seconds, 3600 converts it to Hours. refer to AN6358 pg 13 figure 1.3 in row "Time" _percentage_multiplier = 1.0 / 256.0 # refer to row "Percentage" _battery_capacity = 6000 debug = False
[docs] def __init__(self, address: int = 54, device_number: int = 0, batteryCapacity: int = 6000) -> None: self.bus = SMBus(device_number) self.I2C_ADDRESS = address time.sleep(0.050) context_logger.info_with_context("Fuel Gauge", f"Battery Capacity: {batteryCapacity}") self._battery_capacity = batteryCapacity
[docs] def read_byte_data(self, reg: int) -> int: return self.bus.read_byte_data(self.I2C_ADDRESS, reg)
[docs] def write_byte_data(self, reg: int, value: int) -> None: return self.bus.write_byte_data(self.I2C_ADDRESS, reg, value)
[docs] def writeReg16Bit(self, reg: int, value: int) -> None: val = [(value & 0xFF), ((value >> 8) & 0xFF)] # 0-> LSB | 1-> MSB self.bus.write_i2c_block_data(self.I2C_ADDRESS, reg, val) time.sleep(0.1)
[docs] def readReg16Bit(self, reg: int) -> int: # Returned value is a list of 16 bytes block = self.bus.read_i2c_block_data(self.I2C_ADDRESS, reg, 2) # 0 -> LSB | 1 -> MSB value = block[0] value |= block[1] << 8 return value
[docs] def set_target(self, target: int) -> None: command = [0xC0 + (target & 0x1F), (target >> 5) & 0x7F] write = i2c_msg.write(self.I2C_ADDRESS, command) self.bus.i2c_rdwr(write)
def _twos_comp(self, val: int, bits: int) -> int: """compute the 2's complement of int value val""" if (val & (1 << (bits - 1))) != 0: # if sign bit is set e.g., 8bit: 128-255 val = val - (1 << bits) # compute negative value return val # return positive value as is
[docs] def getInstantaneousCurrent(self) -> float: current_raw = self.readReg16Bit(self.Current) sign = current_raw & 0x8000 _signed_current = self._twos_comp(current_raw, 16) if _signed_current < 0: context_logger.info_with_context("Fuel Gauge", f"Discharging {hex(current_raw)} {hex(sign)}") else: context_logger.info_with_context("Fuel Gauge", f"Charging {hex(current_raw)} {hex(sign)}") return _signed_current * self._current_multiplier_mV
[docs] def getInstantaneousVoltage(self) -> float: voltage_raw = self.readReg16Bit(self.VCell) return voltage_raw * self._voltage_multiplier_V
[docs] def setCapacity(self, batteryCapacity: int) -> None: self.writeReg16Bit(self.DesignCap, batteryCapacity * 2)
[docs] def setEmptyCell(self, EmptyCell: int) -> None: self.writeReg16Bit(self.VEmpty, EmptyCell)
[docs] def setModelcfg(self, CHEM_ID: int) -> None: self.writeReg16Bit(self.Modelcfg, CHEM_ID)
[docs] def getCapacity(self) -> float: capacity_raw = self.readReg16Bit(self.DesignCap) return capacity_raw * self._capacity_multiplier_mAH
[docs] def getCell(self) -> float: capacity_raw = self.readReg16Bit(self.VEmpty) context_logger.info_with_context("Fuel Gauge", f"capacity_raw : {hex(capacity_raw)}") return capacity_raw * self._capacity_multiplier_mAH
[docs] def getModel(self) -> int: capacity_raw = self.readReg16Bit(self.Modelcfg) context_logger.info_with_context("Fuel Gauge", f"Capacity : {hex(capacity_raw)}") return capacity_raw
[docs] def getValues(self) -> None: context_logger.info_with_context("Fuel Gauge", f"0xDB : {hex(self.readReg16Bit(0xDB))}") context_logger.info_with_context("Fuel Gauge", f"0x18 : {hex(self.readReg16Bit(0x18))}") context_logger.info_with_context("Fuel Gauge", f"0x23 : {hex(self.readReg16Bit(0x23))}") context_logger.info_with_context("Fuel Gauge", f"0x10 : {hex(self.readReg16Bit(0x10))}") context_logger.info_with_context("Fuel Gauge", f"0x13 : {hex(self.readReg16Bit(0x13))}") context_logger.info_with_context("Fuel Gauge", f"0x1E : {hex(self.readReg16Bit(0x1E))}") context_logger.info_with_context("Fuel Gauge", f"0x28 : {hex(self.readReg16Bit(0x28))}") context_logger.info_with_context("Fuel Gauge", f"0x12 : {hex(self.readReg16Bit(0x12))}") context_logger.info_with_context("Fuel Gauge", f"0x22 : {hex(self.readReg16Bit(0x22))}") context_logger.info_with_context("Fuel Gauge", f"0x32 : {hex(self.readReg16Bit(0x32))}") context_logger.info_with_context("Fuel Gauge", f"0x42 : {hex(self.readReg16Bit(0x42))}") context_logger.info_with_context("Fuel Gauge", f"0x38 : {hex(self.readReg16Bit(0x38))}") context_logger.info_with_context("Fuel Gauge", f"0x39 : {hex(self.readReg16Bit(0x39))}") context_logger.info_with_context("Fuel Gauge", f"0x3A : {hex(self.readReg16Bit(0x3A))}") context_logger.info_with_context("Fuel Gauge", f"0xBA : {hex(self.readReg16Bit(0xBA))}")
[docs] def setValues(self, debug: bool = True, res: float = 0.02, resFlag: bool = False) -> None: self.debug = debug # Guard against invalid shunt resistor values from configuration. self._resistSensor = res if self._resistSensor <= 0: context_logger.warning_with_context( "Fuel Gauge", f"Invalid resistor value ({self._resistSensor}); using default 0.02 ohm", ) self._resistSensor = 0.02 if self._battery_capacity > 6000 and not resFlag: self._resistSensor = 0.01 self._capacity_multiplier_mAH = (5e-3) / self._resistSensor # refer to row "Capacity" self._current_multiplier_mV = (1.5625e-3) / self._resistSensor # refer to row "Current" if self.check_por() == 1: context_logger.info_with_context("Fuel Gauge", "MAX17261 has POR detected") self.wait_dnr() self.initialize() self.clear_por()
# self.writeReg16Bit(0xDB, 0x8060) #wrong # self.writeReg16Bit(0x18, 0x2EE0) # self.writeReg16Bit(0x23, 0x2EE0) # self.writeReg16Bit(0x10, 0x2EE0) # self.writeReg16Bit(0x1E, 0x0280) # self.writeReg16Bit(0x12, 0x0400) # self.writeReg16Bit(0x22, 0x0000) # self.writeReg16Bit(0x32, 0x063E) #wrong # self.writeReg16Bit(0x42, 0x0C7C) #wrong # self.writeReg16Bit(0x38, 0x0380) # self.writeReg16Bit(0x39, 0x263E) # self.writeReg16Bit(0x3A, 0x9661) # self.writeReg16Bit(0xBA, 0x870C)
[docs] def getRemainingCapacity(self) -> float: RapCap_raw = self.readReg16Bit(self.RepCap) return RapCap_raw * self._capacity_multiplier_mAH
[docs] def check_por(self) -> bool: status_por = self.readReg16Bit(0x00) & 0x0002 if status_por == 0x0000: return False return True
[docs] def wait_dnr(self) -> None: attempt = 0 while attempt < 20: if self.readReg16Bit(0x3D) & 0x0001 != 0x0001: break time.sleep(0.01) attempt += 1
[docs] def initialize(self) -> None: hib_cfg = self.readReg16Bit(0xBA) self.writeReg16Bit(0x60, 0x90) # Exit Hibernate Mode step 1 self.writeReg16Bit(0xBA, 0x0) self.writeReg16Bit(0x60, 0x00) self.writeReg16Bit(0x18, (self._battery_capacity * 2)) # Battery capacity self.writeReg16Bit(0x1E, 0x0280) self.writeReg16Bit(0x3A, 0x9661) self.writeReg16Bit(0xDB, 0x8060) attempt = 0 while attempt < 20: if self.readReg16Bit(0xDB) & 0x8000 != 0x8000: break time.sleep(0.01) attempt += 1 self.writeReg16Bit(0xBA, hib_cfg)
[docs] def write_and_verify_register(self, reg: int, value: int) -> None: attempt = 0 while attempt < 3: self.writeReg16Bit(reg, value) time.sleep(0.001) # Delay for 1 millisecond register_value_read = self.readReg16Bit(reg) if value == register_value_read: break attempt += 1
[docs] def clear_por(self) -> None: return self.write_and_verify_register(0x00, self.readReg16Bit(0x00) & 0xFFFD)
[docs] def setResistSensor(self, resistorValue: float) -> None: self._resistSensor = resistorValue
[docs] def getResistSensor(self) -> float: return self._resistSensor
[docs] def getSOC(self) -> float: # SOC = State of Charge if self.debug: context_logger.debug_with_context("Fuel Gauge", f"Time To Empty: {self.getTimeToEmpty()}") context_logger.debug_with_context("Fuel Gauge", f"Time To Full: {self.getTimeToFull()}") context_logger.debug_with_context("Fuel Gauge", f"Remaining Capacity: {self.getRemainingCapacity()}") SOC_raw = self.readReg16Bit(self.RepSOC) return SOC_raw * self._percentage_multiplier
[docs] def getTimeToEmpty(self) -> float: TTE_raw = self.readReg16Bit(self.TimeToEmpty) return TTE_raw * self._time_multiplier_Hours
[docs] def getTimeToFull(self) -> float: TTF_raw = self.readReg16Bit(self.TimeToFull) return TTF_raw * self._time_multiplier_Hours
# Gets one or more variables from the Jrk (without clearing them). # Returns a list of byte values (integers between 0 and 255).
[docs] def get_variables(self, offset: int, length: int) -> list[int]: write = i2c_msg.write(self.I2C_ADDRESS, [0xE5, offset]) read = i2c_msg.read(self.I2C_ADDRESS, length) self.bus.i2c_rdwr(write, read) return list(read)
# Gets the Target variable from the Jrk.
[docs] def get_target(self) -> int: b = self.get_variables(0x02, 2) return b[0] + 256 * b[1]
# Gets the Feedback variable from the Jrk.
[docs] def get_feedback(self) -> int: b = self.get_variables(0x04, 2) return b[0] + 256 * b[1]
if __name__ == "__main__": try: max17261 = MAX17261() max17261.setValues() time.sleep(1) # max17261.setResistSensor(0.01) # max17261.setCapacity(6000) # max17261.setEmptyCell(38497) max17261.getValues() time.sleep(1) max17261.getModel() time.sleep(1) context_logger.info_with_context("Fuel Gauge", f"cell -> {max17261.getCell()}") while 1: context_logger.info_with_context("Fuel Gauge", f"Capacity of battery: {max17261.getCapacity()} ") time.sleep(1) context_logger.info_with_context( "Fuel Gauge", f"Get Remaining Capacity {max17261.getRemainingCapacity()}", ) time.sleep(1) context_logger.info_with_context("Fuel Gauge", f"V: {max17261.getInstantaneousVoltage()}") time.sleep(1) context_logger.info_with_context("Fuel Gauge", f"I: {max17261.getInstantaneousCurrent()}") time.sleep(1) context_logger.info_with_context("Fuel Gauge", f"S: {max17261.getSOC()}") time.sleep(2) except Exception as e: context_logger.error_with_context("Fuel Gauge", f"[ERR_MAX17261] main: {e}")