#!/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}")