diff --git a/docs/docs/general/features.md b/docs/docs/general/features.md index a7b2434c..2ed2e9b3 100644 --- a/docs/docs/general/features.md +++ b/docs/docs/general/features.md @@ -83,7 +83,7 @@ CCCM limits the charge/discharge current depending on the highest/lowest cell vo * between `2.8V - 2.9V` → `5A `discharge * below `<= 2.70V` → `0A` discharge -### Temprature +### Temperature * `CCCM_T_ENABLE = True/False` * `DCCM_T_ENABLE = True/False` @@ -121,27 +121,27 @@ If the `MAX_CELL_VOLTAGE` \* `cell count` is reached for `MAX_VOLTAGE_TIME_SEC` ## BMS feature comparison -| Feature | Ant | Daly | ECS | HLPdataBMS4S | JK BMS | Life/Tian Power | LLT/JBD | MNB (1) | Renogy | Seplos | Sinowealth (1) | -| ---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | -| Voltage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Current | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Power | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| State Of Charge | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Battery temperature | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| MOSFET temperature | No | No | No | No | Yes | No | Yes | No | No | No | No | -| Consumed Ah | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Time-to-go | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | -| Min/max cell voltages | Yes | Yes | No | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Min/max temperature | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Installed capacity | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Available capacity | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Cell details | No | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | ? | -| Balancing status | Yes | No | Yes | No | Yes | Yes | No | No | No | No | ? | -| Raise alarms from the BMS | Yes | Yes | Yes (2) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | ? | -| History of charge cycles | Yes | Yes | No | No | Yes | Yes | Yes | No | Yes | Yes | Yes | -| Get CCL/DCL from the BMS | No | No | No | No | Yes | No | No | No | No | No | No | -| Charge current control management (CCCM) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | -| Set battery parameters (DVCC) | Calc | Calc | Yes | Yes | Calc | Calc | Calc | Yes | Calc | Calc | Calc | +| Feature | Ant | Daly | ECS | Heltec | HLPdataBMS4S | JK BMS | Life/Tian Power | LLT/JBD | MNB (1) | Renogy | Seplos | Sinowealth (1) | +| ---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | :---: | +| Voltage | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Current | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Power | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| State Of Charge | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Battery temperature | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| MOSFET temperature | No | No | No | Yes | No | Yes | No | Yes | No | No | No | No | +| Consumed Ah | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Time-to-go | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | Calc | +| Min/max cell voltages | Yes | Yes | No | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | Yes | +| Min/max temperature | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Installed capacity | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Available capacity | Yes | Yes | Yes | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Cell details | No | Yes | Yes | Yes | Yes | Yes | Yes | Yes | No | Yes | Yes | ? | +| Balancing status | Yes | No | Yes | Yes | No | Yes | Yes | No | No | No | No | ? | +| Raise alarms from the BMS | Yes | Yes | Yes (2) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | ? | +| History of charge cycles | Yes | Yes | No | No | No | Yes | Yes | Yes | No | Yes | Yes | Yes | +| Get CCL/DCL from the BMS | No | No | No | Yes | No | Yes | No | No | No | No | No | No | +| Charge current control management (CCCM) | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Set battery parameters (DVCC) | Calc | Calc | Yes | Calc | Yes | Calc | Calc | Calc | Yes | Calc | Calc | Calc | `Calc` means that the value is calculated by the driver. diff --git a/docs/docs/general/install.md b/docs/docs/general/install.md index 56c891bc..4b30e030 100644 --- a/docs/docs/general/install.md +++ b/docs/docs/general/install.md @@ -122,7 +122,7 @@ Select `2` for `nightly build` and then select the branch you want to install fr ### BMS specific settings * ECS BMS → https://github.com/Louisvdw/dbus-serialbattery/issues/254#issuecomment-1275924313 - +* HeltecModbus → in case the modbus slave address of the BMS was adjusted from the factory default, configure the slave addresses to query in config.ini:HELTEC_MODBUS_ADDR. As always the battery settings shall be configured in the BMS already via app or computer. ## How to change the default limits diff --git a/docs/docs/general/supported-bms.md b/docs/docs/general/supported-bms.md index 416e7362..ff247950 100644 --- a/docs/docs/general/supported-bms.md +++ b/docs/docs/general/supported-bms.md @@ -22,6 +22,9 @@ Disabled by default since driver version `v0.14.0` as it causes other issues. Se ### • ECS GreenMeter with LiPro +### • HeltecModbus SmartBMS (YanYang BMS) +Communication to the Heltec SmartBMS (which is a rebranded YYBMS) via Modbus/RS485. + ### • HLPdataBMS4S ### • [JKBMS](https://www.jkbms.com/products/) / Heltec BMS diff --git a/etc/dbus-serialbattery/bms/heltecmodbus.py b/etc/dbus-serialbattery/bms/heltecmodbus.py new file mode 100644 index 00000000..9291fd26 --- /dev/null +++ b/etc/dbus-serialbattery/bms/heltecmodbus.py @@ -0,0 +1,439 @@ +# -*- coding: utf-8 -*- +# known limitations: +# - only BMS variants with 2 cell temperature sensors supported +# - some "interesting" datapoints are not read (e. g. registers 52: switch type, 62: bootloader and firmware version) +# - SOC not yet resettable from Venus (similary to Daly for support of writing SOC), but modbus write to 120 should be +# fairly possible) + + +from battery import Battery, Cell +from utils import logger +import utils +import serial +import time +import minimalmodbus +from typing import Dict +import threading + +# the Heltec BMS is not always as responsive as it should, so let's try it up to (RETRYCNT - 1) times to talk to it +RETRYCNT = 10 + +# the wait time after a communication - normally this should be as defined by modbus RTU and handled in minimalmodbus, +# but yeah, it seems we need it for the Heltec BMS +SLPTIME = 0.03 + +mbdevs: Dict[int, minimalmodbus.Instrument] = {} +locks: Dict[int, any] = {} + + +class HeltecModbus(Battery): + def __init__(self, port, baud, address): + super(HeltecModbus, self).__init__(port, baud, address) + self.type = "Heltec_Smart" + + def test_connection(self): + # call a function that will connect to the battery, send a command and retrieve the result. + # The result or call should be unique to this BMS. Battery name or version, etc. + # Return True if success, False for failure + for self.address in utils.HELTEC_MODBUS_ADDR: + logger.info("Testing on slave address " + str(self.address)) + found = False + if self.address not in locks: + locks[self.address] = threading.Lock() + + # TODO: We need to lock not only based on the address, but based on the port as soon as multiple BMSs + # are supported on the same serial interface. Then locking on the port will be enough. + + with locks[self.address]: + mbdev = minimalmodbus.Instrument( + self.port, + slaveaddress=self.address, + mode="rtu", + close_port_after_each_call=True, + debug=False, + ) + mbdev.serial.parity = minimalmodbus.serial.PARITY_NONE + mbdev.serial.stopbits = serial.STOPBITS_ONE + mbdev.serial.baudrate = 9600 + # yes, 400ms is long but the BMS is sometimes really slow in responding, so this is a good compromise + mbdev.serial.timeout = 0.4 + mbdevs[self.address] = mbdev + + for n in range(1, RETRYCNT): + try: + string = mbdev.read_string(7, 13) + time.sleep(SLPTIME) + found = True + logger.info( + "found in try " + + str(n) + + "/" + + str(RETRYCNT) + + " for " + + self.port + + "(" + + str(self.address) + + "): " + + string + ) + except Exception as e: + logger.warn( + "testing failed (" + + str(e) + + ") " + + str(n) + + "/" + + str(RETRYCNT) + + " for " + + self.port + + "(" + + str(self.address) + + ")" + ) + continue + break + if found: + self.type = "#" + str(self.address) + "_Heltec_Smart" + break + + return ( + found + and self.read_status_data() + and self.get_settings() + and self.refresh_data() + ) + + def get_settings(self): + self.max_battery_voltage = self.max_cell_voltage * self.cell_count + self.min_battery_voltage = self.min_cell_voltage * self.cell_count + + return True + + def refresh_data(self): + # call all functions that will refresh the battery data. + # This will be called for every iteration (1 second) + # Return True if success, False for failure + return self.read_soc_data() and self.read_cell_data() + + def read_status_data(self): + mbdev = mbdevs[self.address] + + with locks[self.address]: + for n in range(1, RETRYCNT): + try: + ccur = mbdev.read_register(191, 0, 3, False) + self.max_battery_charge_current = ( + (int)(((ccur & 0xFF) << 8) | ((ccur >> 8) & 0xFF)) + ) / 100 + time.sleep(SLPTIME) + + dc = mbdev.read_register(194, 0, 3, False) + self.max_battery_discharge_current = ( + ((dc & 0xFF) << 8) | ((dc >> 8) & 0xFF) + ) / 100 + time.sleep(SLPTIME) + + cap = mbdev.read_register(118, 0, 3, False) + self.capacity = (((cap & 0xFF) << 8) | ((cap >> 8) & 0xFF)) / 10 + time.sleep(SLPTIME) + + cap = mbdev.read_register(119, 0, 3, False) + self.actual_capacity = ( + ((cap & 0xFF) << 8) | ((cap >> 8) & 0xFF) + ) / 10 + time.sleep(SLPTIME) + + cap = mbdev.read_register(126, 0, 3, False) + self.learned_capacity = ( + ((cap & 0xFF) << 8) | ((cap >> 8) & 0xFF) + ) / 10 + time.sleep(SLPTIME) + + volt = mbdev.read_register(169, 0, 3, False) + self.max_cell_voltage = ( + ((volt & 0xFF) << 8) | ((volt >> 8) & 0xFF) + ) / 1000 + time.sleep(SLPTIME) + + volt = mbdev.read_register(172, 0, 3, False) + self.min_cell_voltage = ( + ((volt & 0xFF) << 8) | ((volt >> 8) & 0xFF) + ) / 1000 + time.sleep(SLPTIME) + + string = mbdev.read_string(7, 13) + self.hwTypeName = string + time.sleep(SLPTIME) + + string = mbdev.read_string(41, 6) + self.devName = string + time.sleep(SLPTIME) + + serial1 = mbdev.read_registers(2, number_of_registers=4) + self.unique_identifier = "-".join( + "{:04x}".format(x) for x in serial1 + ) + time.sleep(SLPTIME) + + self.pw = mbdev.read_string(47, 2) + time.sleep(SLPTIME) + + tmp = mbdev.read_register(75) + # h: batterytype: 0: Ternery Lithium, 1: Iron Lithium, 2: Lithium Titanat + # l: #of cells + + self.cell_count = (tmp >> 8) & 0xFF + tmp = tmp & 0xFF + if tmp == 0: + self.cellType = "Ternary Lithium" + elif tmp == 1: + self.cellType = "Iron Lithium" + elif tmp == 2: + self.cellType = "Lithium Titatnate" + else: + self.cellType = "unknown" + time.sleep(SLPTIME) + + self.hardware_version = ( + self.devName + + "(" + + str((mbdev.read_register(38) >> 8) & 0xFF) + + ")" + ) + time.sleep(SLPTIME) + + date = mbdev.read_long(39, 3, True, minimalmodbus.BYTEORDER_LITTLE) + self.production_date = ( + str(date & 0xFFFF) + + "-" + + str((date >> 24) & 0xFF) + + "-" + + str((date >> 16) & 0xFF) + ) + time.sleep(SLPTIME) + + # we finished all readings without trouble, so let's break from the retry loop + break + except Exception as e: + logger.warn( + "Error reading settings from BMS, retry (" + + str(n) + + "/" + + str(RETRYCNT) + + "): " + + str(e) + ) + continue + + logger.info(self.hardware_version) + logger.info("Heltec-" + self.hwTypeName) + logger.info(" Dev name: " + self.devName) + logger.info(" Serial: " + self.unique_identifier) + logger.info(" Made on: " + self.production_date) + logger.info(" Cell count: " + str(self.cell_count)) + logger.info(" Cell type: " + self.cellType) + logger.info(" BT password: " + self.pw) + logger.info(" rated capacity: " + str(self.capacity)) + logger.info(" actual capacity: " + str(self.actual_capacity)) + logger.info(" learned capacity: " + str(self.learned_capacity)) + + return True + + def read_soc_data(self): + mbdev = mbdevs[self.address] + + with locks[self.address]: + for n in range(1, RETRYCNT): + try: + self.voltage = ( + mbdev.read_long(76, 3, True, minimalmodbus.BYTEORDER_LITTLE) + / 1000 + ) + time.sleep(SLPTIME) + + self.current = -( + mbdev.read_long(78, 3, True, minimalmodbus.BYTEORDER_LITTLE) + / 100 + ) + time.sleep(SLPTIME) + + runState1 = mbdev.read_long( + 152, 3, True, minimalmodbus.BYTEORDER_LITTLE + ) + time.sleep(SLPTIME) + + # bit 29 is discharge protection + if (runState1 & 0x20000000) == 0: + self.discharge_fet = True + else: + self.discharge_fet = False + + # bit 28 is charge protection + if (runState1 & 0x10000000) == 0: + self.charge_fet = True + else: + self.charge_fet = False + + warnings = mbdev.read_long( + 156, 3, True, minimalmodbus.BYTEORDER_LITTLE + ) + if (warnings & (1 << 3)) or ( + warnings & (1 << 15) + ): # 15 is full protection, 3 is total overvoltage + self.voltage_high = 2 + else: + self.voltage_high = 0 + + if warnings & (1 << 0): + self.protection.voltage_cell_high = 2 + # we handle a single cell OV as total OV, as long as cell_high is not explicitly handled + self.protection.voltage_high = 1 + else: + self.protection.voltage_cell_high = 0 + + if warnings & (1 << 1): + self.protection.voltage_cell_low = 2 + else: + self.protection.voltage_cell_low = 0 + + if warnings & (1 << 4): + self.protection.voltage_low = 2 + else: + self.protection.voltage_low = 0 + + if warnings & (1 << 5): + self.protection.current_over = 2 + else: + self.protection.current_over = 0 + + if warnings & (1 << 7): + self.protection.current_under = 2 + elif warnings & (1 << 6): + self.protection.current_under = 1 + else: + self.protection.current_under = 0 + + if warnings & (1 << 8): # this is a short circuit + self.protection.current_over = 2 + + if warnings & (1 << 9): + self.protection.temp_high_charge = 2 + else: + self.protection.temp_high_charge = 0 + + if warnings & (1 << 10): + self.protection.temp_low_charge = 2 + else: + self.protection.temp_low_charge = 0 + + if warnings & (1 << 11): + self.protection.temp_high_discharge = 2 + else: + self.protection.temp_high_discharge = 0 + + if warnings & (1 << 12): + self.protection.temp_low_discharge = 2 + else: + self.protection.temp_low_discharge = 0 + + if warnings & (1 << 13): # MOS overtemp + self.protection.temp_high_internal = 2 + else: + self.protection.temp_high_internal = 0 + + if warnings & (1 << 14): # SOC low + self.protection.soc_low = 2 + else: + self.protection.soc_low = 0 + + if warnings & (0xFFFF0000): # any other fault + self.protection.internal_failure = 2 + else: + self.protection.internal_failure = 0 + + socsoh = mbdev.read_register(120, 0, 3, False) + self.soh = socsoh & 0xFF + self.soc = (socsoh >> 8) & 0xFF + time.sleep(SLPTIME) + + # we could read min and max temperature, here, but I have a BMS with only 2 sensors, + # so I couldn't test the logic and read therefore only the first two temperatures + # tminmax = mbdev.read_register(117, 0, 3, False) + # nmin = (tminmax & 0xFF) + # nmax = ((tminmax >> 8) & 0xFF) + + temps = mbdev.read_register(113, 0, 3, False) + self.temp1 = (temps & 0xFF) - 40 + self.temp2 = ((temps >> 8) & 0xFF) - 40 + time.sleep(SLPTIME) + + temps = mbdev.read_register(112, 0, 3, False) + most = (temps & 0xFF) - 40 + balt = ((temps >> 8) & 0xFF) - 40 + # balancer temperature is not handled separately in dbus-serialbattery, + # so let's display the max of both temperatures inside the BMS as mos temperature + self.temp_mos = max(most, balt) + time.sleep(SLPTIME) + + return True + + except Exception as e: + logger.warn( + "Error reading SOC, retry (" + + str(n) + + "/" + + str(RETRYCNT) + + ") " + + str(e) + ) + continue + break + logger.warn("Error reading SOC, failed") + return False + + def read_cell_data(self): + result = False + mbdev = mbdevs[self.address] + + with locks[self.address]: + for n in range(1, RETRYCNT): + try: + cells = mbdev.read_registers( + 81, number_of_registers=self.cell_count + ) + time.sleep(SLPTIME) + + balancing = mbdev.read_long( + 139, 3, signed=False, byteorder=minimalmodbus.BYTEORDER_LITTLE + ) + time.sleep(SLPTIME) + + result = True + except Exception as e: + logger.warn( + "read_cell_data() failed (" + + str(e) + + ") " + + str(n) + + "/" + + str(RETRYCNT) + ) + continue + break + if result is False: + return False + + if len(self.cells) != self.cell_count: + self.cells = [] + for idx in range(self.cell_count): + self.cells.append(Cell(False)) + + i = 0 + for cell in cells: + cellV = ((cell & 0xFF) << 8) | ((cell >> 8) & 0xFF) + self.cells[i].voltage = cellV / 1000 + self.cells[i].balance = balancing & (1 << i) != 0 + + i = i + 1 + + return True diff --git a/etc/dbus-serialbattery/config.default.ini b/etc/dbus-serialbattery/config.default.ini index e9b4f403..25762314 100644 --- a/etc/dbus-serialbattery/config.default.ini +++ b/etc/dbus-serialbattery/config.default.ini @@ -240,6 +240,12 @@ LIPRO_START_ADDRESS = 2 LIPRO_END_ADDRESS = 4 LIPRO_CELL_COUNT = 15 +; -- HeltecModbus (Heltec SmartBMS/YYBMS) settings +; Set the Modbus addresses from the adapters +; Separate each address to check by a comma like: 1, 2, 3, ... +; factory default address will be 1 +HELTEC_MODBUS_ADDR = 1 + ; --------- Battery monitor specific settings --------- ; If you are using a SmartShunt or something else as a battery monitor, the battery voltage reported diff --git a/etc/dbus-serialbattery/dbus-serialbattery.py b/etc/dbus-serialbattery/dbus-serialbattery.py index b50c938c..6530ace1 100644 --- a/etc/dbus-serialbattery/dbus-serialbattery.py +++ b/etc/dbus-serialbattery/dbus-serialbattery.py @@ -24,6 +24,7 @@ # import battery classes from bms.daly import Daly from bms.ecs import Ecs +from bms.heltecmodbus import HeltecModbus from bms.hlpdatabms4s import HLPdataBMS4S from bms.jkbms import Jkbms from bms.lifepower import Lifepower @@ -39,6 +40,7 @@ {"bms": Daly, "baud": 9600, "address": b"\x40"}, {"bms": Daly, "baud": 9600, "address": b"\x80"}, {"bms": Ecs, "baud": 19200}, + {"bms": HeltecModbus, "baud": 9600}, {"bms": HLPdataBMS4S, "baud": 9600}, {"bms": Jkbms, "baud": 115200}, {"bms": Lifepower, "baud": 9600}, diff --git a/etc/dbus-serialbattery/utils.py b/etc/dbus-serialbattery/utils.py index c854c4f3..8769d5a0 100644 --- a/etc/dbus-serialbattery/utils.py +++ b/etc/dbus-serialbattery/utils.py @@ -321,6 +321,11 @@ def _get_list_from_config( LIPRO_END_ADDRESS = int(config["DEFAULT"]["LIPRO_END_ADDRESS"]) LIPRO_CELL_COUNT = int(config["DEFAULT"]["LIPRO_CELL_COUNT"]) +# -- HeltecModbus device settings +HELTEC_MODBUS_ADDR = _get_list_from_config( + "DEFAULT", "HELTEC_MODBUS_ADDR", lambda v: int(v) +) + # --------- Battery monitor specific settings --------- # If you are using a SmartShunt or something else as a battery monitor, the battery voltage reported