diff --git a/CHANGELOG.md b/CHANGELOG.md index d3c14b0b..30e52815 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,67 @@ # Changelog -## v1.0.0 +## Breaking changes -### ATTENTION: Breaking changes! The config is now done in the `config.ini`. All values from the `utils.py` gets lost. The changes in the `config.ini` will persists future updates. +* Driver version greater or equal to `v1.0.20230629beta` and smaller or equal to `v1.0.20230926beta`: + + With `v1.0.20230927beta` the following values changed names: + * `BULK_CELL_VOLTAGE` -> `SOC_RESET_VOLTAGE` + * `BULK_AFTER_DAYS` -> `SOC_RESET_AFTER_DAYS` + +## v1.0.x + +* Added: Bluetooth: Show signal strength of BMS in log by @mr-manuel +* Added: Configure logging level in `config.ini` by @mr-manuel +* Added: Create unique identifier, if not provided from BMS by @mr-manuel +* Added: Current average of the last 5 minutes by @mr-manuel +* Added: Daly BMS - Auto reset SoC when changing to float (can be turned off in the config file) by @transistorgit +* Added: Daly BMS connect via CAN (experimental, some limits apply) with https://github.com/Louisvdw/dbus-serialbattery/pull/169 by @SamuelBrucksch and @mr-manuel +* Added: Exclude a device from beeing used by the dbus-serialbattery driver by @mr-manuel +* Added: Implement callback function for update by @seidler2547 +* Added: JKBMS BLE - Automatic SOC reset with https://github.com/Louisvdw/dbus-serialbattery/pull/736 by @ArendsM +* Added: JKBMS BLE - Show last five characters from the MAC address in the custom name (which is displayed in the device list) by @mr-manuel +* Added: JKBMS BMS connect via CAN (experimental, some limits apply) by @IrisCrimson and @mr-manuel +* Added: LLT/JBD BMS - Discharge / Charge Mosfet and disable / enable balancer switching over remote console/GUI with https://github.com/Louisvdw/dbus-serialbattery/pull/761 by @idstein +* Added: LLT/JBD BMS - Show balancer state in GUI under the IO page with https://github.com/Louisvdw/dbus-serialbattery/pull/763 by @idstein +* Added: Load to SOC reset voltage every x days to reset the SoC to 100% for some BMS by @mr-manuel +* Added: Save custom name and make it restart persistant by @mr-manuel +* Added: Temperature names to dbus and mqtt by @mr-manuel +* Added: Use current average of the last 300 cycles for time to go and time to SoC calculation by @mr-manuel +* Added: Validate current, voltage, capacity and SoC for all BMS. This prevents that a device, which is no BMS, is detected as BMS. Fixes also https://github.com/Louisvdw/dbus-serialbattery/issues/479 by @mr-manuel +* Changed: `VOLTAGE_DROP` now behaves differently. Before it reduced the voltage for the check, now the voltage for the charger is increased in order to get the target voltage on the BMS by @mr-manuel +* Changed: Daly BMS - Fix readsentence by @transistorgit +* Changed: Daly BMS - Fixed https://github.com/Louisvdw/dbus-serialbattery/issues/837 by @mr-manuel +* Changed: Enable BMS that are disabled by default by specifying it in the config file. No more need to edit scripts by @mr-manuel +* Changed: Fixed Building wheel for dbus-fast won't finish on weak systems https://github.com/Louisvdw/dbus-serialbattery/issues/785 by @mr-manuel +* Changed: Fixed error in `reinstall-local.sh` script for Bluetooth installation by @mr-manuel +* Changed: Fixed meaningless Time to Go values by @transistorgit +* Changed: Fixed typo in `config.ini` sample by @hoschult +* Changed: For BMS_TYPE now multiple BMS can be specified by @mr-manuel +* Changed: Improved battery error handling on connection loss by @mr-manuel +* Changed: Improved battery voltage handling in linear absorption mode by @ogurevich +* Changed: Improved driver disable script by @md-manuel +* Changed: Improved driver reinstall when multiple Bluetooth BMS are enabled by @mr-manuel +* Changed: JKBMS - Driver do not start if manufacturer date in BMS is empty https://github.com/Louisvdw/dbus-serialbattery/issues/823 by @mr-manuel +* Changed: JKBMS_BLE BMS - Fixed MOSFET Temperature for HW 11 by @jensbehrens & @mr-manuel +* Changed: JKBMS_BLE BMS - Fixed recognition of newer models where no data is shown by @mr-manuel +* Changed: JKBMS_BLE BMS - Improved driver by @seidler2547 & @mr-manuel +* Changed: LLT/JBD BMS - Fix cycle capacity with https://github.com/Louisvdw/dbus-serialbattery/pull/762 by @idstein +* Changed: LLT/JBD BMS - Fixed https://github.com/Louisvdw/dbus-serialbattery/issues/730 by @mr-manuel +* Changed: LLT/JBD BMS - Fixed https://github.com/Louisvdw/dbus-serialbattery/issues/769 by @mr-manuel +* Changed: LLT/JBD BMS - Fixed https://github.com/Louisvdw/dbus-serialbattery/issues/778 with https://github.com/Louisvdw/dbus-serialbattery/pull/798 by @idstein +* Changed: LLT/JBD BMS - Improved error handling and automatical driver restart in case of error. Fixed https://github.com/Louisvdw/dbus-serialbattery/issues/777 by @mr-manuel +* Changed: LLT/JBD BMS - SOC different in Xiaoxiang app and dbus-serialbattery with https://github.com/Louisvdw/dbus-serialbattery/pull/760 by @idstein +* Changed: Make CCL and DCL limiting messages more clear by @mr-manuel +* Changed: Reduce the big inrush current if the CVL jumps from Bulk/Absorbtion to Float https://github.com/Louisvdw/dbus-serialbattery/issues/659 by @Rikkert-RS & @ogurevich +* Changed: Sinowealth BMS - Fix not loading https://github.com/Louisvdw/dbus-serialbattery/issues/702 by @mr-manuel +* Changed: Time-to-Go and Time-to-SoC use the current average of the last 5 minutes for calculation by @mr-manuel +* Changed: Time-to-SoC calculate only positive points by @mr-manuel +* Removed: Cronjob to restart Bluetooth service every 12 hours by @mr-manuel + + +## v1.0.20230531 + +### ATTENTION: Breaking changes! The config is now done in the `config.ini`. All values from the `utils.py` get lost. The changes in the `config.ini` will persists future updates. * Added: `self.unique_identifier` to the battery class. Used to identify a BMS when multiple BMS are connected - planned for future use by @mr-manuel * Added: Alert is triggered, when BMS communication is lost by @mr-manuel diff --git a/etc/dbus-serialbattery/battery.py b/etc/dbus-serialbattery/battery.py index eb5b2e2d..6a229e34 100644 --- a/etc/dbus-serialbattery/battery.py +++ b/etc/dbus-serialbattery/battery.py @@ -1,5 +1,5 @@ # -*- coding: utf-8 -*- -from typing import Union, Tuple, List +from typing import Union, Tuple, List, Callable from utils import logger import utils @@ -7,6 +7,8 @@ import math from time import time from abc import ABC, abstractmethod +import re +import sys class Protection(object): @@ -69,18 +71,20 @@ def __init__(self, port, baud, address): self.max_battery_discharge_current = None self.has_settings = 0 - self.init_values() - - # used to identify a BMS when multiple BMS are connected - planned for future use - self.unique_identifier = None - # fetched from the BMS from a field where the user can input a custom string # only if available self.custom_field = None + self.init_values() + def init_values(self): + """ + Used to reset values, if battery unexpectly disconnects + """ self.voltage = None self.current = None + self.current_avg = None + self.current_avg_lst = [] self.capacity_remain = None self.capacity = None self.cycles = None @@ -102,14 +106,22 @@ def init_values(self): self.cells: List[Cell] = [] self.control_charging = None self.control_voltage = None + self.soc_reset_requested = False + self.soc_reset_last_reached = 0 + self.soc_reset_battery_voltage = None + self.max_battery_voltage = None + self.min_battery_voltage = None self.allow_max_voltage = True + self.max_voltage_start_time = None + self.transition_start_time = None + self.control_voltage_at_transition_start = None self.charge_mode = None + self.charge_mode_debug = "" self.charge_limitation = None self.discharge_limitation = None self.linear_cvl_last_set = 0 self.linear_ccl_last_set = 0 self.linear_dcl_last_set = 0 - self.max_voltage_start_time = None self.control_current = None self.control_previous_total = None self.control_previous_max = None @@ -129,11 +141,35 @@ def test_connection(self) -> bool: # return false when failed, true if successful return False + def unique_identifier(self) -> str: + """ + Used to identify a BMS when multiple BMS are connected + If not provided by the BMS/driver then the hardware version and capacity is used, + since it can be changed by small amounts to make a battery unique. + On +/- 5 Ah you can identify 11 batteries + """ + string = ( + "".join(filter(str.isalnum, str(self.hardware_version))) + "_" + if self.hardware_version is not None and self.hardware_version != "" + else "" + ) + string += str(self.capacity) + "Ah" + return string + def connection_name(self) -> str: return "Serial " + self.port def custom_name(self) -> str: - return "SerialBattery(" + self.type + ")" + """ + Check if the custom name is present in the config file, else return default name + """ + if len(utils.CUSTOM_BATTERY_NAMES) > 0: + for name in utils.CUSTOM_BATTERY_NAMES: + tmp = name.split(":") + if tmp[0].strip() == self.port: + return tmp[1].strip() + else: + return "SerialBattery(" + self.type + ")" def product_name(self) -> str: return "SerialBattery(" + self.type + ")" @@ -150,6 +186,17 @@ def get_settings(self) -> bool: """ return False + def use_callback(self, callback: Callable) -> bool: + """ + Each driver may override this function to indicate whether it is + able to provide value updates on its own. + + :return: false when battery cannot provide updates by itself and will be polled + every poll_interval milliseconds for new values + true if callable should be used for updates as they arrive from the battery + """ + return False + @abstractmethod def refresh_data(self) -> bool: """ @@ -185,6 +232,7 @@ def manage_charge_voltage(self) -> None: manages the charge voltage by setting self.control_voltage :return: None """ + self.prepare_voltage_management() if utils.CVCM_ENABLE: if utils.LINEAR_LIMITATION_ENABLE: self.manage_charge_voltage_linear() @@ -192,9 +240,47 @@ def manage_charge_voltage(self) -> None: self.manage_charge_voltage_step() # on CVCM_ENABLE = False apply max voltage else: - self.control_voltage = round((utils.MAX_CELL_VOLTAGE * self.cell_count), 3) + self.control_voltage = round(self.max_battery_voltage, 3) self.charge_mode = "Keep always max voltage" + def prepare_voltage_management(self) -> None: + soc_reset_last_reached_days_ago = ( + 0 + if self.soc_reset_last_reached == 0 + else (((int(time()) - self.soc_reset_last_reached) / 60 / 60 / 24)) + ) + # set soc_reset_requested to True, if the days are over + # it gets set to False once the bulk voltage was reached once + if ( + utils.SOC_RESET_AFTER_DAYS is not False + and self.soc_reset_requested is False + and self.allow_max_voltage + and ( + self.soc_reset_last_reached == 0 + or utils.SOC_RESET_AFTER_DAYS < soc_reset_last_reached_days_ago + ) + ): + """ + logger.info( + f"set soc_reset_requested to True: first time (0) or {utils.SOC_RESET_AFTER_DAYS}" + + f" < {round(soc_reset_last_reached_days_ago, 2)}" + ) + """ + self.soc_reset_requested = True + + self.soc_reset_battery_voltage = round( + utils.SOC_RESET_VOLTAGE * self.cell_count, 2 + ) + + if self.soc_reset_requested: + self.max_battery_voltage = self.soc_reset_battery_voltage + else: + self.max_battery_voltage = round( + utils.MAX_CELL_VOLTAGE * self.cell_count, 2 + ) + + self.min_battery_voltage = round(utils.MIN_CELL_VOLTAGE * self.cell_count, 2) + def manage_charge_voltage_linear(self) -> None: """ manages the charge voltage using linear interpolation by setting self.control_voltage @@ -204,99 +290,148 @@ def manage_charge_voltage_linear(self) -> None: voltageSum = 0 penaltySum = 0 tDiff = 0 + current_time = int(time()) + + # meassurment and variation tolerance in volts + measurementToleranceVariation = 0.5 try: - if utils.CVCM_ENABLE: - # calculate battery sum - for i in range(self.cell_count): - voltage = self.get_cell_voltage(i) - if voltage: - voltageSum += voltage - - # calculate penalty sum to prevent single cell overcharge by using current cell voltage - if voltage > utils.MAX_CELL_VOLTAGE: - # foundHighCellVoltage: reset to False is not needed, since it is recalculated every second - foundHighCellVoltage = True - penaltySum += voltage - utils.MAX_CELL_VOLTAGE - 0.010 - - voltageDiff = self.get_max_cell_voltage() - self.get_min_cell_voltage() - - if self.max_voltage_start_time is None: - # start timer, if max voltage is reached and cells are balanced + # calculate battery sum and check for cell overvoltage + for i in range(self.cell_count): + voltage = self.get_cell_voltage(i) + if voltage: + voltageSum += voltage + + # calculate penalty sum to prevent single cell overcharge by using current cell voltage if ( - (utils.MAX_CELL_VOLTAGE * self.cell_count) - utils.VOLTAGE_DROP - <= voltageSum - and voltageDiff - <= utils.CELL_VOLTAGE_DIFF_KEEP_MAX_VOLTAGE_UNTIL - and self.allow_max_voltage + self.max_battery_voltage != self.soc_reset_battery_voltage + and voltage > utils.MAX_CELL_VOLTAGE ): - self.max_voltage_start_time = time() - - # allow max voltage again, if cells are unbalanced or SoC threshold is reached + # foundHighCellVoltage: reset to False is not needed, since it is recalculated every second + foundHighCellVoltage = True + penaltySum += voltage - utils.MAX_CELL_VOLTAGE elif ( - utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT > self.soc - or voltageDiff >= utils.CELL_VOLTAGE_DIFF_TO_RESET_VOLTAGE_LIMIT - ) and not self.allow_max_voltage: - self.allow_max_voltage = True + self.max_battery_voltage == self.soc_reset_battery_voltage + and voltage > utils.SOC_RESET_VOLTAGE + ): + # foundHighCellVoltage: reset to False is not needed, since it is recalculated every second + foundHighCellVoltage = True + penaltySum += voltage - utils.SOC_RESET_VOLTAGE + + voltageDiff = self.get_max_cell_voltage() - self.get_min_cell_voltage() + + if self.max_voltage_start_time is None: + # start timer, if max voltage is reached and cells are balanced + if ( + self.max_battery_voltage <= voltageSum + and voltageDiff <= utils.CELL_VOLTAGE_DIFF_KEEP_MAX_VOLTAGE_UNTIL + and self.allow_max_voltage + ): + self.max_voltage_start_time = current_time + + # allow max voltage again, if cells are unbalanced or SoC threshold is reached + elif ( + utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT > self.soc + or voltageDiff >= utils.CELL_VOLTAGE_DIFF_TO_RESET_VOLTAGE_LIMIT + ) and not self.allow_max_voltage: + self.allow_max_voltage = True else: - tDiff = time() - self.max_voltage_start_time - # if utils.MAX_VOLTAGE_TIME_SEC < tDiff: - # keep max voltage for 300 more seconds - if 300 < tDiff: - self.allow_max_voltage = False - self.max_voltage_start_time = None + pass + + else: + tDiff = current_time - self.max_voltage_start_time + # keep max voltage for MAX_VOLTAGE_TIME_SEC more seconds + if utils.MAX_VOLTAGE_TIME_SEC < tDiff: + self.allow_max_voltage = False + self.max_voltage_start_time = None + if self.soc <= utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT: + # write to log, that reset to float was not possible + logger.error( + f"Could not change to float voltage. Battery SoC ({self.soc}%) is lower" + + f" than SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT ({utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT}%)." + + " Please reset SoC manually or lower the SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT in the" + + ' "config.ini".' + ) + + # we don't forget to reset max_voltage_start_time wenn we going to bulk(dynamic) mode + # regardless of whether we were in absorption mode or not + if ( + voltageSum + < self.max_battery_voltage - measurementToleranceVariation + ): + self.max_voltage_start_time = None # INFO: battery will only switch to Absorption, if all cells are balanced. # Reach MAX_CELL_VOLTAGE * cell count if they are all balanced. if foundHighCellVoltage and self.allow_max_voltage: - # set CVL only once every LINEAR_RECALCULATION_EVERY seconds - if ( - int(time()) - self.linear_cvl_last_set - >= utils.LINEAR_RECALCULATION_EVERY - ): - self.linear_cvl_last_set = int(time()) - - # Keep penalty above min battery voltage and below max battery voltage - self.control_voltage = round( - min( - max( - voltageSum - penaltySum, - utils.MIN_CELL_VOLTAGE * self.cell_count, - ), - utils.MAX_CELL_VOLTAGE * self.cell_count, + # Keep penalty above min battery voltage and below max battery voltage + control_voltage = round( + min( + max( + voltageSum - penaltySum, + self.min_battery_voltage, ), - 3, - ) + self.max_battery_voltage, + ), + 3, + ) + self.set_cvl_linear(control_voltage) self.charge_mode = ( "Bulk dynamic" - # + " (vS: " - # + str(round(voltageSum, 2)) - # + " - pS: " - # + str(round(penaltySum, 2)) - # + ")" if self.max_voltage_start_time is None else "Absorption dynamic" - # + "(vS: " - # + str(round(voltageSum, 2)) - # + " - pS: " - # + str(round(penaltySum, 2)) - # + ")" ) + if self.max_battery_voltage == self.soc_reset_battery_voltage: + self.charge_mode += " & SoC Reset" + elif self.allow_max_voltage: - self.control_voltage = round( - (utils.MAX_CELL_VOLTAGE * self.cell_count), 3 - ) + self.control_voltage = round(self.max_battery_voltage, 3) self.charge_mode = ( "Bulk" if self.max_voltage_start_time is None else "Absorption" ) + if self.max_battery_voltage == self.soc_reset_battery_voltage: + self.charge_mode += " & SoC Reset" + else: - self.control_voltage = round( - (utils.FLOAT_CELL_VOLTAGE * self.cell_count), 3 - ) - self.charge_mode = "Float" + floatVoltage = round((utils.FLOAT_CELL_VOLTAGE * self.cell_count), 3) + chargeMode = "Float" + # reset bulk when going into float + if self.soc_reset_requested: + # logger.info("set soc_reset_requested to False") + self.soc_reset_requested = False + # IDEA: Save "soc_reset_last_reached" in the dbus path com.victronenergy.settings + # to make it restart persistent + self.soc_reset_last_reached = current_time + if self.control_voltage: + # check if battery changed from bulk/absoprtion to float + if not self.charge_mode.startswith("Float"): + self.transition_start_time = current_time + self.initial_control_voltage = self.control_voltage + chargeMode = "Float Transition" + # Assume battery SOC ist 100% at this stage + self.trigger_soc_reset() + elif self.charge_mode.startswith("Float Transition"): + elapsed_time = current_time - self.transition_start_time + # Voltage reduction per second + VOLTAGE_REDUCTION_PER_SECOND = 0.01 / 10 + voltage_reduction = min( + VOLTAGE_REDUCTION_PER_SECOND * elapsed_time, + self.initial_control_voltage - floatVoltage, + ) + self.set_cvl_linear( + self.initial_control_voltage - voltage_reduction + ) + if self.control_voltage <= floatVoltage: + self.control_voltage = floatVoltage + chargeMode = "Float" + else: + chargeMode = "Float Transition" + else: + self.control_voltage = floatVoltage + self.charge_mode = chargeMode if ( self.allow_max_voltage @@ -307,10 +442,63 @@ def manage_charge_voltage_linear(self) -> None: self.charge_mode += " (Linear Mode)" + # uncomment for enabling debugging infos in GUI + """ + self.charge_mode_debug = ( + f"max_battery_voltage: {round(self.max_battery_voltage, 2)}V" + ) + self.charge_mode_debug += ( + f" - VOLTAGE_DROP: {round(utils.VOLTAGE_DROP, 2)}V" + ) + self.charge_mode_debug += f"\nvoltageSum: {round(voltageSum, 2)}V" + self.charge_mode_debug += f" • voltageDiff: {round(voltageDiff, 3)}V" + self.charge_mode_debug += ( + f"\ncontrol_voltage: {round(self.control_voltage, 2)}V" + ) + self.charge_mode_debug += f" • penaltySum: {round(penaltySum, 3)}V" + self.charge_mode_debug += f"\ntDiff: {tDiff}/{utils.MAX_VOLTAGE_TIME_SEC}" + self.charge_mode_debug += f" • SoC: {self.soc}%" + self.charge_mode_debug += ( + f" • Reset SoC: {utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT}%" + ) + self.charge_mode_debug += f"\nallow_max_voltage: {self.allow_max_voltage}" + self.charge_mode_debug += ( + f"\nmax_voltage_start_time: {self.max_voltage_start_time}" + ) + self.charge_mode_debug += f"\ncurrent_time: {current_time}" + self.charge_mode_debug += ( + f"\nlinear_cvl_last_set: {self.linear_cvl_last_set}" + ) + soc_reset_days_ago = round( + (current_time - self.soc_reset_last_reached) / 60 / 60 / 24, 2 + ) + soc_reset_in_days = round(utils.SOC_RESET_AFTER_DAYS - soc_reset_days_ago, 2) + self.charge_mode_debug += "\nsoc_reset_last_reached: " + str( + "Never" + if self.soc_reset_last_reached == 0 + else str(soc_reset_days_ago) + + " days ago - next in " + + str(soc_reset_in_days) + + "days" + ) + # """ + except TypeError: self.control_voltage = None self.charge_mode = "--" + def set_cvl_linear(self, control_voltage) -> bool: + """ + set CVL only once every LINEAR_RECALCULATION_EVERY seconds + :return: bool + """ + current_time = int(time()) + if utils.LINEAR_RECALCULATION_EVERY <= current_time - self.linear_cvl_last_set: + self.control_voltage = control_voltage + self.linear_cvl_last_set = current_time + return True + return False + def manage_charge_voltage_step(self) -> None: """ manages the charge voltage using a step function by setting self.control_voltage @@ -318,54 +506,64 @@ def manage_charge_voltage_step(self) -> None: """ voltageSum = 0 tDiff = 0 + current_time = int(time()) try: - if utils.CVCM_ENABLE: - # calculate battery sum - for i in range(self.cell_count): - voltage = self.get_cell_voltage(i) - if voltage: - voltageSum += voltage - - if self.max_voltage_start_time is None: - # check if max voltage is reached and start timer to keep max voltage - if ( - utils.MAX_CELL_VOLTAGE * self.cell_count - ) - utils.VOLTAGE_DROP <= voltageSum and self.allow_max_voltage: - # example 2 - self.max_voltage_start_time = time() + # calculate battery sum + for i in range(self.cell_count): + voltage = self.get_cell_voltage(i) + if voltage: + voltageSum += voltage + + if self.max_voltage_start_time is None: + # check if max voltage is reached and start timer to keep max voltage + if self.max_battery_voltage <= voltageSum and self.allow_max_voltage: + # example 2 + self.max_voltage_start_time = current_time + + # check if reset soc is greater than battery soc + # this prevents flapping between max and float voltage + elif ( + utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT > self.soc + and not self.allow_max_voltage + ): + self.allow_max_voltage = True - # check if reset soc is greater than battery soc - # this prevents flapping between max and float voltage - elif ( - utils.SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT > self.soc - and not self.allow_max_voltage - ): - self.allow_max_voltage = True + # do nothing + else: + pass - # do nothing - else: - pass + # timer started + else: + tDiff = current_time - self.max_voltage_start_time + if utils.MAX_VOLTAGE_TIME_SEC < tDiff: + self.allow_max_voltage = False + self.max_voltage_start_time = None - # timer started else: - tDiff = time() - self.max_voltage_start_time - if utils.MAX_VOLTAGE_TIME_SEC < tDiff: - self.allow_max_voltage = False - self.max_voltage_start_time = None - - else: - pass + pass if self.allow_max_voltage: - self.control_voltage = utils.MAX_CELL_VOLTAGE * self.cell_count + self.control_voltage = self.max_battery_voltage self.charge_mode = ( "Bulk" if self.max_voltage_start_time is None else "Absorption" ) + if self.max_battery_voltage == self.soc_reset_battery_voltage: + self.charge_mode += " & SoC Reset" + else: + # check if battery changed from bulk/absoprtion to float + if not self.charge_mode.startswith("Float"): + # Assume battery SOC ist 100% at this stage + self.trigger_soc_reset() self.control_voltage = utils.FLOAT_CELL_VOLTAGE * self.cell_count self.charge_mode = "Float" + # reset bulk when going into float + if self.soc_reset_requested: + # logger.info("set soc_reset_requested to False") + self.soc_reset_requested = False + self.soc_reset_last_reached = current_time self.charge_mode += " (Step Mode)" @@ -375,17 +573,24 @@ def manage_charge_voltage_step(self) -> None: def manage_charge_current(self) -> None: # Manage Charge Current Limitations - charge_limits = {utils.MAX_BATTERY_CHARGE_CURRENT: "Config Limit"} + charge_limits = {utils.MAX_BATTERY_CHARGE_CURRENT: "Max Battery Charge Current"} - # if values are not the same, then the limit was read also from the BMS - if utils.MAX_BATTERY_CHARGE_CURRENT != self.max_battery_charge_current: - charge_limits.update({self.max_battery_charge_current: "BMS Limit"}) + # if BMS limit is lower then config limit and therefore the values are not the same, + # then the limit was also read from the BMS + if utils.MAX_BATTERY_CHARGE_CURRENT > self.max_battery_charge_current: + charge_limits.update({self.max_battery_charge_current: "BMS Settings"}) if utils.CCCM_CV_ENABLE: tmp = self.calcMaxChargeCurrentReferringToCellVoltage() if self.max_battery_charge_current != tmp: if tmp in charge_limits: - charge_limits.update({tmp: charge_limits[tmp] + ", Cell Voltage"}) + # do not add string, if global limitation is applied + if charge_limits[tmp] != "Max Battery Charge Current": + charge_limits.update( + {tmp: charge_limits[tmp] + ", Cell Voltage"} + ) + else: + pass else: charge_limits.update({tmp: "Cell Voltage"}) @@ -393,7 +598,11 @@ def manage_charge_current(self) -> None: tmp = self.calcMaxChargeCurrentReferringToTemperature() if self.max_battery_charge_current != tmp: if tmp in charge_limits: - charge_limits.update({tmp: charge_limits[tmp] + ", Temp"}) + # do not add string, if global limitation is applied + if charge_limits[tmp] != "Max Battery Charge Current": + charge_limits.update({tmp: charge_limits[tmp] + ", Temp"}) + else: + pass else: charge_limits.update({tmp: "Temp"}) @@ -401,7 +610,11 @@ def manage_charge_current(self) -> None: tmp = self.calcMaxChargeCurrentReferringToSoc() if self.max_battery_charge_current != tmp: if tmp in charge_limits: - charge_limits.update({tmp: charge_limits[tmp] + ", SoC"}) + # do not add string, if global limitation is applied + if charge_limits[tmp] != "Max Battery Charge Current": + charge_limits.update({tmp: charge_limits[tmp] + ", SoC"}) + else: + pass else: charge_limits.update({tmp: "SoC"}) @@ -439,19 +652,28 @@ def manage_charge_current(self) -> None: ##### # Manage Discharge Current Limitations - discharge_limits = {utils.MAX_BATTERY_DISCHARGE_CURRENT: "Config Limit"} - - # if values are not the same, then the limit was read also from the BMS - if utils.MAX_BATTERY_DISCHARGE_CURRENT != self.max_battery_discharge_current: - discharge_limits.update({self.max_battery_discharge_current: "BMS Limit"}) + discharge_limits = { + utils.MAX_BATTERY_DISCHARGE_CURRENT: "Max Battery Discharge Current" + } + + # if BMS limit is lower then config limit and therefore the values are not the same, + # then the limit was also read from the BMS + if utils.MAX_BATTERY_DISCHARGE_CURRENT > self.max_battery_discharge_current: + discharge_limits.update( + {self.max_battery_discharge_current: "BMS Settings"} + ) if utils.DCCM_CV_ENABLE: tmp = self.calcMaxDischargeCurrentReferringToCellVoltage() if self.max_battery_discharge_current != tmp: if tmp in discharge_limits: - discharge_limits.update( - {tmp: discharge_limits[tmp] + ", Cell Voltage"} - ) + # do not add string, if global limitation is applied + if discharge_limits[tmp] != "Max Battery Discharge Current": + discharge_limits.update( + {tmp: discharge_limits[tmp] + ", Cell Voltage"} + ) + else: + pass else: discharge_limits.update({tmp: "Cell Voltage"}) @@ -459,7 +681,11 @@ def manage_charge_current(self) -> None: tmp = self.calcMaxDischargeCurrentReferringToTemperature() if self.max_battery_discharge_current != tmp: if tmp in discharge_limits: - discharge_limits.update({tmp: discharge_limits[tmp] + ", Temp"}) + # do not add string, if global limitation is applied + if discharge_limits[tmp] != "Max Battery Discharge Current": + discharge_limits.update({tmp: discharge_limits[tmp] + ", Temp"}) + else: + pass else: discharge_limits.update({tmp: "Temp"}) @@ -467,7 +693,11 @@ def manage_charge_current(self) -> None: tmp = self.calcMaxDischargeCurrentReferringToSoc() if self.max_battery_discharge_current != tmp: if tmp in discharge_limits: - discharge_limits.update({tmp: discharge_limits[tmp] + ", SoC"}) + # do not add string, if global limitation is applied + if discharge_limits[tmp] != "Max Battery Discharge Current": + discharge_limits.update({tmp: discharge_limits[tmp] + ", SoC"}) + else: + pass else: discharge_limits.update({tmp: "SoC"}) @@ -694,6 +924,14 @@ def get_timeToSoc(self, socnum, crntPrctPerSec, onlyNumber=False) -> str: else: diffSoc = self.soc - socnum + """ + calculate only positive SoC points, since negative points have no sense + when charging only points above current SoC are shown + when discharging only points below current SoC are shown + """ + if diffSoc < 0: + return None + ttgStr = None if self.soc != socnum and (diffSoc > 0 or utils.TIME_TO_SOC_INC_FROM is True): secondstogo = int(diffSoc / crntPrctPerSec) @@ -930,6 +1168,34 @@ def get_mos_temp(self) -> Union[float, None]: else: return None + def validate_data(self) -> bool: + """ + Used to validate the data received from the BMS. + If the data is in the thresholds return True, + else return False since it's very probably not a BMS + """ + if self.capacity is not None and (self.capacity < 0 or self.capacity > 1000): + logger.debug( + "Capacity outside of thresholds (from 0 to 1000): " + str(self.capacity) + ) + return False + if self.current is not None and abs(self.current) > 1000: + logger.debug( + "Current outside of thresholds (from -1000 to 1000): " + + str(self.current) + ) + return False + if self.voltage is not None and (self.voltage < 0 or self.voltage > 100): + logger.debug( + "Voltage outside of thresholds (form 0 to 100): " + str(self.voltage) + ) + return False + if self.soc is not None and (self.soc < 0 or self.soc > 100): + logger.debug("SoC outside of thresholds (from 0 to 100): " + str(self.soc)) + return False + + return True + def log_cell_data(self) -> bool: if logger.getEffectiveLevel() > logging.INFO and len(self.cells) == 0: return False @@ -983,11 +1249,150 @@ def log_settings(self) -> None: logger.info( f"> CCCM SOC: {str(utils.CCCM_SOC_ENABLE).ljust(5)} | DCCM SOC: {utils.DCCM_SOC_ENABLE}" ) - if self.unique_identifier is not None: - logger.info(f"Serial Number/Unique Identifier: {self.unique_identifier}") + logger.info(f"Serial Number/Unique Identifier: {self.unique_identifier()}") return + # save custom name to config file + def custom_name_callback(self, path, value): + try: + if path == "/CustomName": + file = open( + "/data/etc/dbus-serialbattery/" + utils.PATH_CONFIG_USER, "r" + ) + lines = file.readlines() + last = len(lines) + + # remove not allowed characters + value = value.replace(":", "").replace("=", "").replace(",", "").strip() + + # empty string to save new config file + config_file_new = "" + + # make sure we are in the [DEFAULT] section + current_line_in_default_section = False + default_section_checked = False + + # check if already exists + exists = False + + # count lines + i = 0 + # looping through the file + for line in lines: + # increment by one + i += 1 + + # stripping line break + line = line.strip() + + # check, if current line is after the [DEFAULT] section + if line == "[DEFAULT]": + current_line_in_default_section = True + + # check, if current line starts a new section + if line != "[DEFAULT]" and re.match(r"^\[.*\]", line): + # set default_section_checked to true, if it was already checked and a new section comes on + if current_line_in_default_section and not exists: + default_section_checked = True + current_line_in_default_section = False + + # check, if the current line is the last line + if i == last: + default_section_checked = True + + # insert or replace only in [DEFAULT] section + if current_line_in_default_section and re.match( + r"^CUSTOM_BATTERY_NAMES.*", line + ): + # set that the setting was found, else a new one is created + exists = True + + # remove setting name + line = re.sub( + "^CUSTOM_BATTERY_NAMES\s*=\s*", "", line # noqa: W605 + ) + + # change only the name of the current BMS + result = [] + bms_name_list = line.split(",") + for bms_name_pair in bms_name_list: + tmp = bms_name_pair.split(":") + if tmp[0] == self.port: + result.append(tmp[0] + ":" + value) + else: + result.append(bms_name_pair) + + new_line = "CUSTOM_BATTERY_NAMES = " + ",".join(result) + + else: + if default_section_checked and not exists: + exists = True + + # add before current line + if i != last: + new_line = ( + "CUSTOM_BATTERY_NAMES = " + + self.port + + ":" + + value + + "\n\n" + + line + ) + + # add at the end if last line + else: + new_line = ( + line + + "\n\n" + + "CUSTOM_BATTERY_NAMES = " + + self.port + + ":" + + value + ) + else: + new_line = line + # concatenate the new string and add an end-line break + config_file_new = config_file_new + new_line + "\n" + + # close the file + file.close() + # Open file in write mode + write_file = open( + "/data/etc/dbus-serialbattery/" + utils.PATH_CONFIG_USER, "w" + ) + # overwriting the old file contents with the new/replaced content + write_file.write(config_file_new) + # close the file + write_file.close() + + # logger.error("value (saved): " + str(value)) + + """ + # this removes all comments and tranfsorm the values to lowercase + utils.config.set( + "DEFAULT", + "CUSTOM_BATTERY_NAMES", + self.port + ":" + value, + ) + + # Writing our configuration file to 'example.ini' + with open( + "/data/etc/dbus-serialbattery/" + utils.PATH_CONFIG_USER, "w" + ) as configfile: + type(utils.config.write(configfile)) + """ + + except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"Exception occurred: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) + + return value + def reset_soc_callback(self, path, value): # callback for handling reset soc request return @@ -1000,3 +1405,9 @@ def force_discharging_off_callback(self, path, value): def turn_balancing_off_callback(self, path, value): return + + def trigger_soc_reset(self): + """ + This method can be used to implement SOC reset when the battery is assumed to be full + """ + return diff --git a/etc/dbus-serialbattery/bms/ant.py b/etc/dbus-serialbattery/bms/ant.py index 124f036f..ceb6be68 100644 --- a/etc/dbus-serialbattery/bms/ant.py +++ b/etc/dbus-serialbattery/bms/ant.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -# disable ANT BMS by default as it causes other issues but can be enabled manually +# ANT BMS is disabled by default as it causes issues with other devices +# can be enabled by specifying it in the BMS_TYPE setting in the "config.ini" # https://github.com/Louisvdw/dbus-serialbattery/issues/479 from battery import Battery @@ -9,9 +10,9 @@ from struct import unpack_from -class Ant(Battery): +class ANT(Battery): def __init__(self, port, baud, address): - super(Ant, self).__init__(port, baud, address) + super(ANT, self).__init__(port, baud, address) self.type = self.BATTERYTYPE command_general = b"\xDB\xDB\x00\x00\x00\x00" @@ -30,6 +31,7 @@ def test_connection(self): result = False try: result = self.read_status_data() + result = result and self.refresh_data() except Exception as err: logger.error(f"Unexpected {err=}, {type(err)=}") result = False @@ -61,6 +63,7 @@ def read_status_data(self): voltage = unpack_from(">H", status_data, 4) self.voltage = voltage[0] * 0.1 + current, self.soc = unpack_from(">lB", status_data, 70) self.current = 0.0 if current == 0 else current / -10 diff --git a/etc/dbus-serialbattery/bms/battery_template.py b/etc/dbus-serialbattery/bms/battery_template.py index e32e424b..e859c675 100644 --- a/etc/dbus-serialbattery/bms/battery_template.py +++ b/etc/dbus-serialbattery/bms/battery_template.py @@ -28,14 +28,22 @@ def test_connection(self): try: result = self.read_status_data() # get first data to show in startup log, only if result is true - if result: - self.refresh_data() + result = result and self.refresh_data() except Exception as err: logger.error(f"Unexpected {err=}, {type(err)=}") result = False return result + def unique_identifier(self) -> str: + """ + Used to identify a BMS when multiple BMS are connected + Provide a unique identifier from the BMS to identify a BMS, if multiple same BMS are connected + e.g. the serial number + If there is no such value, please remove this function + """ + return self.serialnumber + def get_settings(self): # After successful connection get_settings will be call to set up the battery. # Set the current limits, populate cell count, etc @@ -53,11 +61,6 @@ def get_settings(self): self.max_battery_voltage = utils.MAX_CELL_VOLTAGE * self.cell_count self.min_battery_voltage = utils.MIN_CELL_VOLTAGE * self.cell_count - # provide a unique identifier from the BMS to identify a BMS, if multiple same BMS are connected - # e.g. the serial number - # If there is no such value, please leave the line commented. In this case the capacity is used, - # since it can be changed by small amounts to make a battery unique. On +/- 5 Ah you can identify 11 batteries - # self.unique_identifier = str() return True def refresh_data(self): @@ -83,6 +86,8 @@ def read_status_data(self): self.cycles, ) = unpack_from(">bb??bhx", status_data) + # Integrate a check to be sure, that the received data is from the BMS type you are making this driver for + self.hardware_version = "TemplateBMS " + str(self.cell_count) + " cells" logger.info(self.hardware_version) return True diff --git a/etc/dbus-serialbattery/bms/daly.py b/etc/dbus-serialbattery/bms/daly.py index 87510c48..0c033254 100644 --- a/etc/dbus-serialbattery/bms/daly.py +++ b/etc/dbus-serialbattery/bms/daly.py @@ -27,6 +27,7 @@ def __init__(self, port, baud, address): self.trigger_force_disable_discharge = None self.trigger_force_disable_charge = None self.cells_volts_data_lastreadbad = False + self.last_charge_mode = self.charge_mode # command bytes [StartFlag=A5][Address=40][Command=94][DataLength=8][8x zero bytes][checksum] command_base = b"\xA5\x40\x94\x08\x00\x00\x00\x00\x00\x00\x00\x00\x81" @@ -174,6 +175,9 @@ def refresh_data(self): self.write_charge_discharge_mos(ser) + if utils.AUTO_RESET_SOC: + self.update_soc(ser) + except OSError: logger.warning("Couldn't open serial port") @@ -181,6 +185,16 @@ def refresh_data(self): logger.info("refresh_data: result: " + str(result)) return result + def update_soc(self, ser): + if self.last_charge_mode is not None and self.charge_mode is not None: + if not self.last_charge_mode.startswith( + "Float" + ) and self.charge_mode.startswith("Float"): + # we just entered float mode, so the battery must be full + self.soc_to_set = 100 + self.write_soc_and_datetime(ser) + self.last_charge_mode = self.charge_mode + def read_status_data(self, ser): status_data = self.request_data(ser, self.command_status) # check if connection success @@ -229,7 +243,10 @@ def read_soc_data(self, ser): ) if crntMinValid < current < crntMaxValid: self.voltage = voltage / 10 - self.current = current + # apply exponential smoothing on the flickering current measurement + self.current = (0.1 * current) + ( + 0.9 * (0 if self.current is None else self.current) + ) self.soc = soc / 10 return True @@ -387,7 +404,7 @@ def read_cells_volts(self, ser): for idx in range(self.cell_count): self.cells.append(Cell(True)) - # logger.warning("data " + bytes(cells_volts_data).hex()) + # logger.warning("data " + bytearray_to_string(cells_volts_data)) # from each of the received sentences, read up to 3 voltages for i in range(sentences_expected): @@ -509,7 +526,7 @@ def read_battery_code(self, ser): return False battery_code = "" - # logger.warning("data " + bytes(cells_volts_data).hex()) + # logger.warning("data " + utils.bytearray_to_string(cells_volts_data)) for i in range(5): nr, part = unpack_from(">B7s", data, i * 8) if nr != i + 1: @@ -520,15 +537,19 @@ def read_battery_code(self, ser): self.custom_field = sub( " +", " ", - (battery_code.strip()), - ) - self.unique_identifier = self.custom_field.replace(" ", "_") - else: - self.unique_identifier = ( - str(self.production) + "_" + str(int(self.capacity)) + (battery_code.replace("\x00", " ").strip()), ) return True + def unique_identifier(self) -> str: + """ + Used to identify a BMS when multiple BMS are connected + """ + if self.custom_field != "": + return self.custom_field.replace(" ", "_") + else: + return str(self.production) + "_" + str(int(self.capacity)) + def reset_soc_callback(self, path, value): if value is None: return False @@ -544,6 +565,10 @@ def write_soc_and_datetime(self, ser): if self.soc_to_set is None: return False + # wait shortly, else the Daly is not ready and throws a lot of no reply errors + # if you see a lot of errors, try to increase in steps of 0.005 + sleep(0.020) + cmd = bytearray(13) now = datetime.now() @@ -573,7 +598,7 @@ def write_soc_and_datetime(self, ser): ser.write(cmd) reply = self.read_sentence(ser, self.command_set_soc) - if reply[0] != 1: + if reply is False or reply[0] != 1: logger.error("write soc failed") return True @@ -606,12 +631,20 @@ def force_discharging_off_callback(self, path, value): return False def write_charge_discharge_mos(self, ser): + # wait shortly, else the Daly is not ready and throws a lot of no reply errors + # if you see a lot of errors, try to increase in steps of 0.005 + sleep(0.020) + if ( self.trigger_force_disable_charge is None and self.trigger_force_disable_discharge is None ): return False + # wait shortly, else the Daly is not ready and throws a lot of no reply errors + # if you see a lot of errors, try to increase in steps of 0.005 + sleep(0.020) + cmd = bytearray(self.command_base) if self.trigger_force_disable_charge is not None: @@ -687,7 +720,7 @@ def read_sentence(self, ser, expected_reply, timeout=0.5): reply = ser.read_until(b"\xA5") if not reply or b"\xA5" not in reply: logger.debug( - f"read_sentence {bytes(expected_reply).hex()}: no sentence start received" + f"read_sentence {utils.bytearray_to_string(expected_reply)}: no sentence start received" ) return False @@ -699,21 +732,27 @@ def read_sentence(self, ser, expected_reply, timeout=0.5): toread = ser.inWaiting() time_run = time() - time_start if time_run > timeout: - logger.debug(f"read_sentence {bytes(expected_reply).hex()}: timeout") + logger.debug( + f"read_sentence {utils.bytearray_to_string(expected_reply)}: timeout" + ) return False reply += ser.read(12) _, id, cmd, length = unpack_from(">BBBB", reply) - # logger.info(f"reply: {bytes(reply).hex()}") # debug + # logger.info(f"reply: {utils.bytearray_to_string(reply)}") # debug if id != 1 or length != 8 or cmd != expected_reply[0]: - logger.debug(f"read_sentence {bytes(expected_reply).hex()}: wrong header") + logger.debug( + f"read_sentence {utils.bytearray_to_string(expected_reply)}: wrong header" + ) return False chk = unpack_from(">B", reply, 12)[0] if sum(reply[:12]) & 0xFF != chk: - logger.debug(f"read_sentence {bytes(expected_reply).hex()}: wrong checksum") + logger.debug( + f"read_sentence {utils.bytearray_to_string(expected_reply)}: wrong checksum" + ) return False return reply[4:12] diff --git a/etc/dbus-serialbattery/bms/daly_can.py b/etc/dbus-serialbattery/bms/daly_can.py new file mode 100644 index 00000000..5b4927ef --- /dev/null +++ b/etc/dbus-serialbattery/bms/daly_can.py @@ -0,0 +1,384 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals +from battery import Battery, Cell +from utils import ( + BATTERY_CAPACITY, + INVERT_CURRENT_MEASUREMENT, + logger, + MAX_BATTERY_CHARGE_CURRENT, + MAX_BATTERY_DISCHARGE_CURRENT, + MAX_CELL_VOLTAGE, + MIN_CELL_VOLTAGE, +) +from struct import unpack_from +import can + +""" +https://github.com/Louisvdw/dbus-serialbattery/pull/169 +""" + + +class Daly_Can(Battery): + def __init__(self, port, baud, address): + super(Daly_Can, self).__init__(port, baud, address) + self.charger_connected = None + self.load_connected = None + self.cell_min_voltage = None + self.cell_max_voltage = None + self.cell_min_no = None + self.cell_max_no = None + self.poll_interval = 1000 + self.poll_step = 0 + self.type = self.BATTERYTYPE + self.can_bus = None + + # command bytes [Priority=18][Command=94][BMS ID=01][Uplink ID=40] + command_base = 0x18940140 + command_soc = 0x18900140 + command_minmax_cell_volts = 0x18910140 + command_minmax_temp = 0x18920140 + command_fet = 0x18930140 + command_status = 0x18940140 + command_cell_volts = 0x18950140 + command_temp = 0x18960140 + command_cell_balance = 0x18970140 + command_alarm = 0x18980140 + + response_base = 0x18944001 + response_soc = 0x18904001 + response_minmax_cell_volts = 0x18914001 + response_minmax_temp = 0x18924001 + response_fet = 0x18934001 + response_status = 0x18944001 + response_cell_volts = 0x18954001 + response_temp = 0x18964001 + response_cell_balance = 0x18974001 + response_alarm = 0x18984001 + + BATTERYTYPE = "Daly_Can" + LENGTH_CHECK = 4 + LENGTH_POS = 3 + CURRENT_ZERO_CONSTANT = 30000 + TEMP_ZERO_CONSTANT = 40 + + def test_connection(self): + result = False + + # TODO handle errors? + can_filters = [ + {"can_id": self.response_base, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_soc, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_minmax_cell_volts, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_minmax_temp, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_fet, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_status, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_cell_volts, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_temp, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_cell_balance, "can_mask": 0xFFFFFFF}, + {"can_id": self.response_alarm, "can_mask": 0xFFFFFFF}, + ] + self.can_bus = can.Bus( + interface="socketcan", + channel=self.port, + receive_own_messages=False, + can_filters=can_filters, + ) + + result = self.read_status_data(self.can_bus) + + return result + + def get_settings(self): + self.capacity = BATTERY_CAPACITY + self.max_battery_current = MAX_BATTERY_CHARGE_CURRENT + self.max_battery_discharge_current = MAX_BATTERY_DISCHARGE_CURRENT + return True + + def refresh_data(self): + result = False + + result = self.read_soc_data(self.can_bus) + result = result and self.read_fed_data(self.can_bus) + if self.poll_step == 0: + # This must be listed in step 0 as get_min_cell_voltage and get_max_cell_voltage in battery.py + # needs it at first cycle for publish_dbus in dbushelper.py + result = result and self.read_cell_voltage_range_data(self.can_bus) + elif self.poll_step == 1: + result = result and self.read_alarm_data(self.can_bus) + elif self.poll_step == 2: + result = result and self.read_cells_volts(self.can_bus) + elif self.poll_step == 3: + result = result and self.read_temperature_range_data(self.can_bus) + # else: # A placeholder to remind this is the last step. Add any additional steps before here + # This is last step so reset poll_step + self.poll_step = -1 + + self.poll_step += 1 + + return result + + def read_status_data(self, can_bus): + status_data = self.read_bus_data_daly(can_bus, self.command_status) + # check if connection success + if status_data is False: + logger.debug("read_status_data") + return False + + ( + self.cell_count, + self.temp_sensors, + self.charger_connected, + self.load_connected, + state, + self.cycles, + ) = unpack_from(">bb??bhx", status_data) + + self.max_battery_voltage = MAX_CELL_VOLTAGE * self.cell_count + self.min_battery_voltage = MIN_CELL_VOLTAGE * self.cell_count + + self.hardware_version = "DalyBMS " + str(self.cell_count) + " cells" + logger.info(self.hardware_version) + return True + + def read_soc_data(self, ser): + # Ensure data received is valid + crntMinValid = -(MAX_BATTERY_DISCHARGE_CURRENT * 2.1) + crntMaxValid = MAX_BATTERY_CHARGE_CURRENT * 1.3 + triesValid = 2 + while triesValid > 0: + soc_data = self.read_bus_data_daly(ser, self.command_soc) + # check if connection success + if soc_data is False: + return False + + voltage, tmp, current, soc = unpack_from(">hhhh", soc_data) + current = ( + (current - self.CURRENT_ZERO_CONSTANT) + / -10 + * INVERT_CURRENT_MEASUREMENT + ) + # logger.info("voltage: " + str(voltage) + ", current: " + str(current) + ", soc: " + str(soc)) + if crntMinValid < current < crntMaxValid: + self.voltage = voltage / 10 + self.current = current + self.soc = soc / 10 + return True + + logger.warning("read_soc_data - triesValid " + str(triesValid)) + triesValid -= 1 + + return False + + def read_alarm_data(self, ser): + alarm_data = self.read_bus_data_daly(ser, self.command_alarm) + # check if connection success + if alarm_data is False: + logger.warning("read_alarm_data") + return False + + ( + al_volt, + al_temp, + al_crnt_soc, + al_diff, + al_mos, + al_misc1, + al_misc2, + al_fault, + ) = unpack_from(">bbbbbbbb", alarm_data) + + if al_volt & 48: + # High voltage levels - Alarm + self.voltage_high = 2 + elif al_volt & 15: + # High voltage Warning levels - Pre-alarm + self.voltage_high = 1 + else: + self.voltage_high = 0 + + if al_volt & 128: + # Low voltage level - Alarm + self.voltage_low = 2 + elif al_volt & 64: + # Low voltage Warning level - Pre-alarm + self.voltage_low = 1 + else: + self.voltage_low = 0 + + if al_temp & 2: + # High charge temp - Alarm + self.temp_high_charge = 2 + elif al_temp & 1: + # High charge temp - Pre-alarm + self.temp_high_charge = 1 + else: + self.temp_high_charge = 0 + + if al_temp & 8: + # Low charge temp - Alarm + self.temp_low_charge = 2 + elif al_temp & 4: + # Low charge temp - Pre-alarm + self.temp_low_charge = 1 + else: + self.temp_low_charge = 0 + + if al_temp & 32: + # High discharge temp - Alarm + self.temp_high_discharge = 2 + elif al_temp & 16: + # High discharge temp - Pre-alarm + self.temp_high_discharge = 1 + else: + self.temp_high_discharge = 0 + + if al_temp & 128: + # Low discharge temp - Alarm + self.temp_low_discharge = 2 + elif al_temp & 64: + # Low discharge temp - Pre-alarm + self.temp_low_discharge = 1 + else: + self.temp_low_discharge = 0 + + # if al_crnt_soc & 2: + # # High charge current - Alarm + # self.current_over = 2 + # elif al_crnt_soc & 1: + # # High charge current - Pre-alarm + # self.current_over = 1 + # else: + # self.current_over = 0 + + # if al_crnt_soc & 8: + # # High discharge current - Alarm + # self.current_over = 2 + # elif al_crnt_soc & 4: + # # High discharge current - Pre-alarm + # self.current_over = 1 + # else: + # self.current_over = 0 + + if al_crnt_soc & 2 or al_crnt_soc & 8: + # High charge/discharge current - Alarm + self.current_over = 2 + elif al_crnt_soc & 1 or al_crnt_soc & 4: + # High charge/discharge current - Pre-alarm + self.current_over = 1 + else: + self.current_over = 0 + + if al_crnt_soc & 128: + # Low SoC - Alarm + self.soc_low = 2 + elif al_crnt_soc & 64: + # Low SoC Warning level - Pre-alarm + self.soc_low = 1 + else: + self.soc_low = 0 + + return True + + def read_cells_volts(self, can_bus): + if self.cell_count is not None: + cells_volts_data = self.read_bus_data_daly( + can_bus, self.command_cell_volts, 6 + ) + if cells_volts_data is False: + logger.warning("read_cells_volts") + return False + + frameCell = [0, 0, 0] + lowMin = MIN_CELL_VOLTAGE / 2 + frame = 0 + bufIdx = 0 + + if len(self.cells) != self.cell_count: + # init the numbers of cells + self.cells = [] + for idx in range(self.cell_count): + self.cells.append(Cell(True)) + + while bufIdx < len(cells_volts_data): + frame, frameCell[0], frameCell[1], frameCell[2] = unpack_from( + ">Bhhh", cells_volts_data, bufIdx + ) + for idx in range(3): + cellnum = ((frame - 1) * 3) + idx # daly is 1 based, driver 0 based + if cellnum >= self.cell_count: + break + cellVoltage = frameCell[idx] / 1000 + self.cells[cellnum].voltage = ( + None if cellVoltage < lowMin else cellVoltage + ) + bufIdx += 8 + + return True + + def read_cell_voltage_range_data(self, ser): + minmax_data = self.read_bus_data_daly(ser, self.command_minmax_cell_volts) + # check if connection success + if minmax_data is False: + logger.warning("read_cell_voltage_range_data") + return False + + ( + cell_max_voltage, + self.cell_max_no, + cell_min_voltage, + self.cell_min_no, + ) = unpack_from(">hbhb", minmax_data) + # Daly cells numbers are 1 based and not 0 based + self.cell_min_no -= 1 + self.cell_max_no -= 1 + # Voltage is returned in mV + self.cell_max_voltage = cell_max_voltage / 1000 + self.cell_min_voltage = cell_min_voltage / 1000 + return True + + def read_temperature_range_data(self, ser): + minmax_data = self.read_bus_data_daly(ser, self.command_minmax_temp) + # check if connection success + if minmax_data is False: + logger.debug("read_temperature_range_data") + return False + + max_temp, max_no, min_temp, min_no = unpack_from(">bbbb", minmax_data) + self.temp1 = min_temp - self.TEMP_ZERO_CONSTANT + self.temp2 = max_temp - self.TEMP_ZERO_CONSTANT + return True + + def read_fed_data(self, ser): + fed_data = self.read_bus_data_daly(ser, self.command_fet) + # check if connection success + if fed_data is False: + logger.debug("read_fed_data") + return False + + ( + status, + self.charge_fet, + self.discharge_fet, + bms_cycles, + capacity_remain, + ) = unpack_from(">b??BL", fed_data) + self.capacity_remain = capacity_remain / 1000 + return True + + def read_bus_data_daly(self, can_bus, command, expectedMessageCount=1): + # TODO handling of error cases + message = can.Message(arbitration_id=command) + can_bus.send(message, timeout=0.2) + response = bytearray() + + # TODO use async notifier instead of this where we expect a specific frame to be received + # this could end up in a deadlock if a package is not received + count = 0 + for msg in can_bus: + # print(f"{msg.arbitration_id:X}: {msg.data}") + # logger.info('Frame: ' + ", ".join(hex(b) for b in msg.data)) + response.extend(msg.data) + count += 1 + if count == expectedMessageCount: + break + return response diff --git a/etc/dbus-serialbattery/bms/heltecmodbus.py b/etc/dbus-serialbattery/bms/heltecmodbus.py index e7f866a0..1ceaa43e 100644 --- a/etc/dbus-serialbattery/bms/heltecmodbus.py +++ b/etc/dbus-serialbattery/bms/heltecmodbus.py @@ -30,6 +30,7 @@ class HeltecModbus(Battery): def __init__(self, port, baud, address): super(HeltecModbus, self).__init__(port, baud, address) self.type = "Heltec_Smart" + self.unique_identifier_tmp = "" def test_connection(self): # call a function that will connect to the battery, send a command and retrieve the result. @@ -174,7 +175,7 @@ def read_status_data(self): time.sleep(SLPTIME) serial1 = mbdev.read_registers(2, number_of_registers=4) - self.unique_identifier = "-".join( + self.unique_identifier_tmp = "-".join( "{:04x}".format(x) for x in serial1 ) time.sleep(SLPTIME) @@ -234,7 +235,7 @@ def read_status_data(self): logger.info(self.hardware_version) logger.info("Heltec-" + self.hwTypeName) logger.info(" Dev name: " + self.devName) - logger.info(" Serial: " + self.unique_identifier) + logger.info(" Serial: " + self.unique_identifier_tmp) logger.info(" Made on: " + self.production_date) logger.info(" Cell count: " + str(self.cell_count)) logger.info(" Cell type: " + self.cellType) @@ -245,6 +246,12 @@ def read_status_data(self): return True + def unique_identifier(self) -> str: + """ + Used to identify a BMS when multiple BMS are connected + """ + return self.unique_identifier_tmp + def read_soc_data(self): mbdev = mbdevs[self.address] diff --git a/etc/dbus-serialbattery/bms/hlpdatabms4s.py b/etc/dbus-serialbattery/bms/hlpdatabms4s.py index 7faf8b2c..9f33dc25 100644 --- a/etc/dbus-serialbattery/bms/hlpdatabms4s.py +++ b/etc/dbus-serialbattery/bms/hlpdatabms4s.py @@ -54,21 +54,8 @@ def refresh_data(self): pass return result - # def log_settings(self): - # logger.info(f'Battery {self.type} connected to dbus from {self.port}') - # logger.info(f'=== Settings ===') - # cell_counter = len(self.cells) - # logger.info(f'> Connection voltage {self.voltage}V | current {self.current}A | SOC {self.soc}%') - # logger.info(f'> Cell count {self.cell_count} | cells populated {cell_counter}') - # logger.info(f'> CCCM SOC {CCCM_SOC_ENABLE} | DCCM SOC {DCCM_SOC_ENABLE}') - # logger.info(f'> CCCM CV {CCCM_CV_ENABLE} | DCCM CV {DCCM_CV_ENABLE}') - # logger.info(f'> CCCM T {CCCM_T_ENABLE} | DCCM T {DCCM_T_ENABLE}') - # logger.info(f'> MIN_CELL_VOLTAGE {MIN_CELL_VOLTAGE}V | MAX_CELL_VOLTAGE {MAX_CELL_VOLTAGE}V') - - return - def read_test_data(self): - test_data = self.read_serial_data_HLPdataBMS4S(b"pv\n", 1, 15) + test_data = self.read_serial_data_HLPdataBMS4S(b"pv\n", 0.2, 12) if test_data is False: return False s1 = str(test_data) @@ -196,19 +183,15 @@ def manage_charge_current(self): self.control_discharge_current = 1000 def read_serial_data_HLPdataBMS4S(self, command, time, min_len): - data = read_serial_data2(command, self.port, self.baud_rate, time, min_len) - if data is False: - return False + data = read_serial_data(command, self.port, self.baud_rate, time, min_len) return data -def read_serial_data2(command, port, baud, time, min_len): +def read_serial_data(command, port, baud, time, min_len): try: with serial.Serial(port, baudrate=baud, timeout=0.5) as ser: - ret = read_serialport_data2(ser, command, time, min_len) - if ret is True: - return ret - return False + ret = read_serialport_data(ser, command, time, min_len) + return ret except serial.SerialException as e: logger.error(e) @@ -218,8 +201,11 @@ def read_serial_data2(command, port, baud, time, min_len): return False -def read_serialport_data2(ser, command, time, min_len): +def read_serialport_data(ser, command, time, min_len): try: + if min_len == 12: + ser.write(b"\n") + sleep(0.2) cnt = 0 while cnt < 3: cnt += 1 @@ -227,7 +213,8 @@ def read_serialport_data2(ser, command, time, min_len): ser.flushInput() ser.write(command) sleep(time) - res = ser.read(1000) + toread = ser.inWaiting() + res = ser.read(toread) if len(res) >= min_len: return res return False diff --git a/etc/dbus-serialbattery/bms/jkbms.py b/etc/dbus-serialbattery/bms/jkbms.py index 0aca8876..0a391d39 100644 --- a/etc/dbus-serialbattery/bms/jkbms.py +++ b/etc/dbus-serialbattery/bms/jkbms.py @@ -10,6 +10,7 @@ class Jkbms(Battery): def __init__(self, port, baud, address): super(Jkbms, self).__init__(port, baud, address) self.type = self.BATTERYTYPE + self.unique_identifier_tmp = "" BATTERYTYPE = "Jkbms" LENGTH_CHECK = 1 @@ -126,6 +127,9 @@ def read_status_data(self): unpack_from(">H", self.get_data(status_data, b"\x99", offset, 2))[0] ) + # the JKBMS resets to + # 95% SoC, if all cell voltages are above or equal to OVPR (Over Voltage Protection Recovery) + # 100% Soc, if all cell voltages are above or equal to OVP (Over Voltage Protection) offset = cellbyte_count + 18 self.soc = unpack_from(">B", self.get_data(status_data, b"\x85", offset, 1))[0] @@ -171,11 +175,14 @@ def read_status_data(self): self.custom_field = tmp if tmp != "Input Us" else None # production date - offset = cellbyte_count + 164 - tmp = unpack_from(">4s", self.get_data(status_data, b"\xB5", offset, 4))[ - 0 - ].decode() - self.production = "20" + tmp + "01" if tmp and tmp != "" else None + try: + offset = cellbyte_count + 164 + tmp = unpack_from(">4s", self.get_data(status_data, b"\xB5", offset, 4))[ + 0 + ].decode() + self.production = "20" + tmp + "01" if tmp and tmp != "" else None + except UnicodeDecodeError: + self.production = None offset = cellbyte_count + 174 self.version = unpack_from( @@ -183,9 +190,9 @@ def read_status_data(self): )[0].decode() offset = cellbyte_count + 197 - self.unique_identifier = sub( + self.unique_identifier_tmp = sub( " +", - " ", + "_", ( unpack_from(">24s", self.get_data(status_data, b"\xBA", offset, 24))[0] .decode() @@ -208,6 +215,12 @@ def read_status_data(self): # logger.info(self.hardware_version) return True + def unique_identifier(self) -> str: + """ + Used to identify a BMS when multiple BMS are connected + """ + return self.unique_identifier_tmp + def to_fet_bits(self, byte_data): tmp = bin(byte_data)[2:].rjust(3, utils.zero_char) self.charge_fet = is_bit_set(tmp[2]) @@ -271,6 +284,8 @@ def to_protection_bits(self, byte_data): # MOSFET temperature alarm self.protection.temp_high_internal = 2 if is_bit_set(tmp[pos - 1]) else 0 # charge over voltage alarm + # TODO: check if "self.soc_reset_requested is False" works, + # else use "self.soc_reset_last_reached < int(time()) - (60 * 60)" self.protection.voltage_high = 2 if is_bit_set(tmp[pos - 2]) else 0 # discharge under voltage alarm self.protection.voltage_low = 2 if is_bit_set(tmp[pos - 3]) else 0 @@ -328,6 +343,8 @@ def read_serial_data_jkbms(self, command: str) -> bool: s = sum(data[0:-4]) + logger.debug("bytearray: " + utils.bytearray_to_string(data)) + if start == 0x4E57 and end == 0x68 and s == crc_lo: return data[10 : length - 7] elif s != crc_lo: diff --git a/etc/dbus-serialbattery/bms/jkbms_ble.py b/etc/dbus-serialbattery/bms/jkbms_ble.py index 276103c4..46742807 100644 --- a/etc/dbus-serialbattery/bms/jkbms_ble.py +++ b/etc/dbus-serialbattery/bms/jkbms_ble.py @@ -1,12 +1,15 @@ # -*- coding: utf-8 -*- from battery import Battery, Cell +from typing import Callable from utils import logger +import utils +from time import sleep, time from bms.jkbms_brn import Jkbms_Brn -from bleak import BleakScanner, BleakError -import asyncio -import time import os +# from bleak import BleakScanner, BleakError +# import asyncio + class Jkbms_Ble(Battery): BATTERYTYPE = "Jkbms_Ble" @@ -17,93 +20,60 @@ def __init__(self, port, baud, address): self.address = address self.type = self.BATTERYTYPE self.jk = Jkbms_Brn(address) + self.unique_identifier_tmp = "" logger.info("Init of Jkbms_Ble at " + address) def connection_name(self) -> str: return "BLE " + self.address + def custom_name(self) -> str: + return "SerialBattery(" + self.type + ") " + self.address[-5:] + 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 - - # check if device with given mac is found, otherwise abort - - logger.info("Test of Jkbms_Ble at " + self.jk.address) + result = False + logger.info("Test of Jkbms_Ble at " + self.address) try: - loop = asyncio.get_event_loop() - t = loop.create_task(BleakScanner.discover()) - devices = loop.run_until_complete(t) - except BleakError as err: - logger.error(str(err)) - return False - except Exception as err: - logger.error(f"Unexpected {err=}, {type(err)=}") - return False + if self.address and self.address != "": + result = True - found = False - for d in devices: - if d.address == self.jk.address: - found = True - if not found: - logger.error("No Jkbms_Ble found at " + self.jk.address) - return False - - """ - # before indipended service, has to be checked - - logger.info("test of jkbmsble") - tries = 0 - while True: - try: - loop = asyncio.get_event_loop() - t = loop.create_task( - BleakScanner.find_device_by_address(self.jk.address) - ) - device = loop.run_until_complete(t) - - if device is None: - logger.info("jkbmsble not found") - if tries > 2: - return False - else: - # device found, exit loop and continue test - break - except BleakError as e: - if tries > 2: - return False - # recover from error if tries left - logger.error(str(e)) - self.reset_bluetooth() - tries += 1 - """ + if result: + # start scraping + self.jk.start_scraping() + tries = 1 - # device was found, presumeably a jkbms so start scraping - self.jk.start_scraping() - tries = 1 + while self.jk.get_status() is None and tries < 20: + sleep(0.5) + tries += 1 - while self.jk.get_status() is None and tries < 20: - time.sleep(0.5) - tries += 1 + # load initial data, from here on get_status has valid values to be served to the dbus + status = self.jk.get_status() - # load initial data, from here on get_status has valid values to be served to the dbus - status = self.jk.get_status() - if status is None: - self.jk.stop_scraping() - return False + if status is None: + self.jk.stop_scraping() + result = False - if not status["device_info"]["vendor_id"].startswith(("JK-", "JK_")): - self.jk.stop_scraping() - return False + if result and not status["device_info"]["vendor_id"].startswith( + ("JK-", "JK_") + ): + self.jk.stop_scraping() + result = False - logger.info("Jkbms_Ble found!") + # get first data to show in startup log + if result: + self.get_settings() + self.refresh_data() + if not result: + logger.error("No BMS found at " + self.address) - # get first data to show in startup log - self.get_settings() - self.refresh_data() + except Exception as err: + logger.error(f"Unexpected {err=}, {type(err)=}") + result = False - return True + return result def get_settings(self): # After successful connection get_settings will be call to set up the battery. @@ -116,6 +86,11 @@ def get_settings(self): self.max_battery_voltage = st["cell_ovp"] * self.cell_count self.min_battery_voltage = st["cell_uvp"] * self.cell_count + # Persist initial OVP and OPVR settings of JK BMS BLE + if self.jk.ovp_initial_voltage is None or self.jk.ovpr_initial_voltage is None: + self.jk.ovp_initial_voltage = st["cell_ovp"] + self.jk.ovpr_initial_voltage = st["cell_ovpr"] + # "User Private Data" field in APP tmp = self.jk.get_status()["device_info"]["production"] self.custom_field = tmp if tmp != "Input Us" else None @@ -123,7 +98,9 @@ def get_settings(self): tmp = self.jk.get_status()["device_info"]["manufacturing_date"] self.production = "20" + tmp if tmp and tmp != "" else None - self.unique_identifier = self.jk.get_status()["device_info"]["serial_number"] + self.unique_identifier_tmp = self.jk.get_status()["device_info"][ + "serial_number" + ] for c in range(self.cell_count): self.cells.append(Cell(False)) @@ -141,6 +118,16 @@ def get_settings(self): logger.info("BAT: " + self.hardware_version) return True + def unique_identifier(self) -> str: + """ + Used to identify a BMS when multiple BMS are connected + """ + return self.unique_identifier_tmp + + def use_callback(self, callback: Callable) -> bool: + self.jk.set_callback(callback) + return callback is not None + def refresh_data(self): # call all functions that will refresh the battery data. # This will be called for every iteration (1 second) @@ -151,16 +138,31 @@ def refresh_data(self): st = self.jk.get_status() if st is None: return False - if time.time() - st["last_update"] > 30: - # if data not updated for more than 30s, sth is wrong, then fail - logger.info("Jkbms_Ble: Bluetooth died") - # if the thread is still alive but data too old there is sth + last_update = int(time() - st["last_update"]) + if last_update >= 15 and last_update % 15 == 0: + logger.info( + f"Jkbms_Ble: Bluetooth connection interrupted. Got no fresh data since {last_update}s." + ) + # show Bluetooth signal strength (RSSI) + bluetoothctl_info = os.popen( + "bluetoothctl info " + + self.address + + ' | grep -i -E "device|name|alias|pair|trusted|blocked|connected|rssi|power"' + ) + logger.info(bluetoothctl_info.read()) + bluetoothctl_info.close() + + # if the thread is still alive but data too old there is something # wrong with the bt-connection; restart whole stack - if not self.resetting: + if not self.resetting and last_update >= 60: + logger.error( + "Jkbms_Ble: Bluetooth died. Restarting Bluetooth system driver." + ) self.reset_bluetooth() + sleep(2) self.jk.start_scraping() - time.sleep(2) + sleep(2) return False else: @@ -237,20 +239,29 @@ def refresh_data(self): return True def reset_bluetooth(self): - logger.info("Reset of Bluetooth triggered") + logger.info("Reset of system Bluetooth daemon triggered") self.resetting = True - # if self.jk.is_running(): - # self.jk.stop_scraping() - logger.info("Scraping ended, issuing sys-commands") + if self.jk.is_running(): + if self.jk.stop_scraping(): + logger.info("Scraping stopped, issuing sys-commands") + else: + logger.warning("Scraping was unable to stop, issuing sys-commands") + # process kill is needed, since the service/bluetooth driver is probably freezed os.system('pkill -f "bluetoothd"') # stop will not work, if service/bluetooth driver is stuck # os.system("/etc/init.d/bluetooth stop") - time.sleep(2) + sleep(2) os.system("rfkill block bluetooth") os.system("rfkill unblock bluetooth") os.system("/etc/init.d/bluetooth start") - logger.info("Bluetooth should have been restarted") + logger.info("System Bluetooth daemon should have been restarted") def get_balancing(self): return 1 if self.balancing else 0 + + def trigger_soc_reset(self): + if utils.AUTO_RESET_SOC: + self.jk.max_cell_voltage = self.get_max_cell_voltage() + self.jk.trigger_soc_reset = True + return diff --git a/etc/dbus-serialbattery/bms/jkbms_brn.py b/etc/dbus-serialbattery/bms/jkbms_brn.py index 917f291f..f0c29ee1 100644 --- a/etc/dbus-serialbattery/bms/jkbms_brn.py +++ b/etc/dbus-serialbattery/bms/jkbms_brn.py @@ -1,13 +1,21 @@ -import asyncio -from bleak import BleakScanner, BleakClient -import time -from logging import info, debug -import logging from struct import unpack_from, calcsize +from bleak import BleakScanner, BleakClient +from time import sleep, time +import asyncio import threading -logging.basicConfig(level=logging.INFO) +# if used as standalone script then use custom logger +# else import logger from utils +if __name__ == "__main__": + import logging + + logger = logging.basicConfig(level=logging.DEBUG) + + def bytearray_to_string(data): + return "".join("\\x" + format(byte, "02x") for byte in data) +else: + from utils import bytearray_to_string, logger # zero means parse all incoming data (every second) CELL_INFO_REFRESH_S = 0 @@ -22,6 +30,8 @@ FRAME_VERSION_JK02_32S = 0x03 PROTOCOL_VERSION_JK02 = 0x02 +JK_REGISTER_OVPR = 0x05 +JK_REGISTER_OVP = 0x04 protocol_version = PROTOCOL_VERSION_JK02 @@ -55,8 +65,7 @@ [["settings", "balancing_switch"], 126, "4?"], ] - -TRANSLATE_CELL_INFO = [ +TRANSLATE_CELL_INFO_24S = [ [["cell_info", "voltages", 32], 6, " 0 + + # logger can be removed after releasing next stable + # current version v1.0.20231102dev + logger.debug(f"fb[38]: {fb[36]}.{fb[37]}.{fb[38]}.{fb[39]}.{fb[40]}") + logger.debug(f"fb[54]: {fb[52]}.{fb[53]}.{fb[54]}.{fb[55]}.{fb[56]}") + logger.debug(f"fb[70]: {fb[68]}.{fb[69]}.{fb[70]}.{fb[71]}.{fb[72]}") + logger.debug(f"fb[134]: {fb[132]}.{fb[133]}.{fb[134]}.{fb[135]}.{fb[136]}") + logger.debug(f"fb[144]: {fb[142]}.{fb[143]}.{fb[144]}.{fb[145]}.{fb[146]}") + logger.debug(f"fb[289]: {fb[287]}.{fb[288]}.{fb[289]}.{fb[290]}.{fb[291]}") + + # if BMS has a max of 32s the data at fb[287] is not empty + if fb[287] > 0: + self.bms_max_cell_count = 32 + self.translate_cell_info = TRANSLATE_CELL_INFO_32S + # if BMS has a max of 24s the data ends at fb[219] + else: + self.bms_max_cell_count = 24 + self.translate_cell_info = TRANSLATE_CELL_INFO_24S + + logger.debug(f"bms_max_cell_count recognized: {self.bms_max_cell_count}") # iterative implementation maybe later due to referencing def translate(self, fb, translation, o, f32s=False, i=0): @@ -185,42 +265,45 @@ def decode_device_info_jk02(self): def decode_cellinfo_jk02(self): fb = self.frame_buffer - has32s = fb[189] == 0x00 and fb[189 + 32] > 0 - for t in TRANSLATE_CELL_INFO: + has32s = self.bms_max_cell_count == 32 + for t in self.translate_cell_info: self.translate(fb, t, self.bms_status, f32s=has32s) self.decode_warnings(fb) - debug(self.bms_status) + logger.debug("decode_cellinfo_jk02(): self.frame_buffer") + logger.debug(self.frame_buffer) + logger.debug(self.bms_status) def decode_settings_jk02(self): fb = self.frame_buffer for t in TRANSLATE_SETTINGS: self.translate(fb, t, self.bms_status) - debug(self.bms_status) + logger.debug(self.bms_status) def decode(self): # check what kind of info the frame contains info_type = self.frame_buffer[4] + self.get_bms_max_cell_count() if info_type == 0x01: - info("Processing frame with settings info") + logger.debug("Processing frame with settings info") if protocol_version == PROTOCOL_VERSION_JK02: self.decode_settings_jk02() # adapt translation table for cell array lengths ccount = self.bms_status["settings"]["cell_count"] - for i, t in enumerate(TRANSLATE_CELL_INFO): + for i, t in enumerate(self.translate_cell_info): if t[0][-2] == "voltages" or t[0][-2] == "voltages": - TRANSLATE_CELL_INFO[i][0][-1] = ccount - self.bms_status["last_update"] = time.time() + self.translate_cell_info[i][0][-1] = ccount + self.bms_status["last_update"] = time() elif info_type == 0x02: if ( CELL_INFO_REFRESH_S == 0 - or time.time() - self.last_cell_info > CELL_INFO_REFRESH_S + or time() - self.last_cell_info > CELL_INFO_REFRESH_S ): - self.last_cell_info = time.time() - info("processing frame with battery cell info") + self.last_cell_info = time() + logger.debug("processing frame with battery cell info") if protocol_version == PROTOCOL_VERSION_JK02: self.decode_cellinfo_jk02() - self.bms_status["last_update"] = time.time() + self.bms_status["last_update"] = time() # power is calculated from voltage x current as # register 122 contains unsigned power-value self.bms_status["cell_info"]["power"] = ( @@ -231,18 +314,27 @@ def decode(self): self.waiting_for_response = "" elif info_type == 0x03: - info("processing frame with device info") + logger.debug("processing frame with device info") if protocol_version == PROTOCOL_VERSION_JK02: self.decode_device_info_jk02() - self.bms_status["last_update"] = time.time() + self.bms_status["last_update"] = time() else: return if self.waiting_for_response == "device_info": self.waiting_for_response = "" + def set_callback(self, callback): + self._new_data_callback = callback + def assemble_frame(self, data: bytearray): + logger.debug( + f"--> assemble_frame() -> self.frame_buffer (before extend) -> lenght: {len(self.frame_buffer)}" + ) + logger.debug(self.frame_buffer) if len(self.frame_buffer) > MAX_RESPONSE_SIZE: - info("data dropped because it alone was longer than max frame length") + logger.debug( + "data dropped because it alone was longer than max frame length" + ) self.frame_buffer = [] if data[0] == 0x55 and data[1] == 0xAA and data[2] == 0xEB and data[3] == 0x90: @@ -251,19 +343,26 @@ def assemble_frame(self, data: bytearray): self.frame_buffer.extend(data) + logger.debug( + f"--> assemble_frame() -> self.frame_buffer (after extend) -> lenght: {len(self.frame_buffer)}" + ) + logger.debug(self.frame_buffer) if len(self.frame_buffer) >= MIN_RESPONSE_SIZE: # check crc; always at position 300, independent of # actual frame-lentgh, so crc up to 299 ccrc = self.crc(self.frame_buffer, 300 - 1) rcrc = self.frame_buffer[300 - 1] - debug(f"compair recvd. crc: {rcrc} vs calc. crc: {ccrc}") + logger.debug(f"compair recvd. crc: {rcrc} vs calc. crc: {ccrc}") if ccrc == rcrc: - debug("great success! frame complete and sane, lets decode") + logger.debug("great success! frame complete and sane, lets decode") self.decode() self.frame_buffer = [] + if self._new_data_callback is not None: + self._new_data_callback() def ncallback(self, sender: int, data: bytearray): - debug(f"------> NEW PACKAGE!laenge: {len(data)}") + logger.debug(f"--> NEW PACKAGE! lenght: {len(data)}") + logger.debug("ncallback(): " + bytearray_to_string(data)) self.assemble_frame(data) def crc(self, arr: bytearray, length: int) -> int: @@ -273,7 +372,12 @@ def crc(self, arr: bytearray, length: int) -> int: return crc.to_bytes(2, "little")[0] async def write_register( - self, address, vals: bytearray, length: int, bleakC: BleakClient + self, + address, + vals: bytearray, + length: int, + bleakC: BleakClient, + awaitresponse: bool, ): frame = bytearray(20) frame[0] = 0xAA # start sequence @@ -296,15 +400,17 @@ async def write_register( frame[17] = 0x00 frame[18] = 0x00 frame[19] = self.crc(frame, len(frame) - 1) - debug("Write register: ", frame) - await bleakC.write_gatt_char(CHAR_HANDLE, frame, False) + logger.debug("Write register: " + str(address) + " " + str(frame)) + await bleakC.write_gatt_char(CHAR_HANDLE, frame, response=awaitresponse) + if awaitresponse: + await asyncio.sleep(5) async def request_bt(self, rtype: str, client): - timeout = time.time() + timeout = time() - while self.waiting_for_response != "" and time.time() - timeout < 10: + while self.waiting_for_response != "" and time() - timeout < 10: await asyncio.sleep(1) - print(self.waiting_for_response) + logger.debug(self.waiting_for_response) if rtype == "cell_info": cmd = COMMAND_CELL_INFO @@ -315,7 +421,7 @@ async def request_bt(self, rtype: str, client): else: return - await self.write_register(cmd, b"\0\0\0\0", 0x00, client) + await self.write_register(cmd, b"\0\0\0\0", 0x00, client, False) def get_status(self): if "settings" in self.bms_status and "cell_info" in self.bms_status: @@ -326,14 +432,18 @@ def get_status(self): def connect_and_scrape(self): asyncio.run(self.asy_connect_and_scrape()) + # self.bt_thread async def asy_connect_and_scrape(self): - print("connect and scrape on address: " + self.address) + logger.debug( + "--> asy_connect_and_scrape(): Connect and scrape on address: " + + self.address + ) self.run = True while self.run and self.main_thread.is_alive(): # autoreconnect client = BleakClient(self.address) - print("btloop") + logger.debug("--> asy_connect_and_scrape(): btloop") try: - print("reconnect") + logger.debug("--> asy_connect_and_scrape(): reconnect") await client.connect() self.bms_status["model_nbr"] = ( await client.read_gatt_char(MODEL_NBR_UUID) @@ -344,27 +454,36 @@ async def asy_connect_and_scrape(self): await self.request_bt("cell_info", client) # await self.enable_charging(client) - # last_dev_info = time.time() + # last_dev_info = time() while client.is_connected and self.run and self.main_thread.is_alive(): + if self.trigger_soc_reset: + self.trigger_soc_reset = False + await self.reset_soc_jk(client) await asyncio.sleep(0.01) - except Exception as e: - info("error while connecting to bt: " + str(e)) + except Exception as err: self.run = False + logger.info( + f"--> asy_connect_and_scrape(): error while connecting to bt: {err}" + ) finally: + self.run = False if client.is_connected: try: await client.disconnect() - except Exception as e: - info("error while disconnecting: " + str(e)) + except Exception as err: + logger.info( + f"--> asy_connect_and_scrape(): error while disconnecting: {err}" + ) - print("Exiting bt-loop") + logger.info("--> asy_connect_and_scrape(): Exit") def start_scraping(self): self.main_thread = threading.current_thread() if self.is_running(): + logger.debug("screaping thread already running") return self.bt_thread.start() - info( + logger.debug( "scraping thread started -> main thread id: " + str(self.main_thread.ident) + " scraping thread: " @@ -373,10 +492,10 @@ def start_scraping(self): def stop_scraping(self): self.run = False - stop = time.time() + stop = time() while self.is_running(): - time.sleep(0.1) - if time.time() - stop > 10: + sleep(0.1) + if time() - stop > 10: return False return True @@ -388,17 +507,58 @@ async def enable_charging(self, c): # data is 01 00 00 00 for on 00 00 00 00 for off; # the following bytes up to 19 are unclear and changing # dynamically -> auth-mechanism? - await self.write_register(0x1D, b"\x01\x00\x00\x00", 4, c) - await self.write_register(0x1E, b"\x01\x00\x00\x00", 4, c) - await self.write_register(0x1F, b"\x01\x00\x00\x00", 4, c) - await self.write_register(0x40, b"\x01\x00\x00\x00", 4, c) + await self.write_register(0x1D, b"\x01\x00\x00\x00", 4, c, True) + await self.write_register(0x1E, b"\x01\x00\x00\x00", 4, c, True) + await self.write_register(0x1F, b"\x01\x00\x00\x00", 4, c, True) + await self.write_register(0x40, b"\x01\x00\x00\x00", 4, c, True) + + def jk_float_to_hex_little(self, val: float): + intval = int(val * 1000) + hexval = f"{intval:0>8X}" + return bytearray.fromhex(hexval)[::-1] + + async def reset_soc_jk(self, c): + # Lowering OVPR / OVP based on the maximum cell voltage at the time + # That will trigger a High Voltage Alert and resets SOC to 100% + ovp_trigger = round(self.max_cell_voltage - 0.05, 3) + ovpr_trigger = round(self.max_cell_voltage - 0.10, 3) + await self.write_register( + JK_REGISTER_OVPR, self.jk_float_to_hex_little(ovpr_trigger), 0x04, c, True + ) + await self.write_register( + JK_REGISTER_OVP, self.jk_float_to_hex_little(ovp_trigger), 0x04, c, True + ) + + # Give BMS some time to recognize + await asyncio.sleep(5) + + # Set values back to initial values + await self.write_register( + JK_REGISTER_OVP, + self.jk_float_to_hex_little(self.ovp_initial_voltage), + 0x04, + c, + True, + ) + await self.write_register( + JK_REGISTER_OVPR, + self.jk_float_to_hex_little(self.ovpr_initial_voltage), + 0x04, + c, + True, + ) + + logger.info("JK BMS SOC reset finished.") -""" if __name__ == "__main__": - jk = Jkbms_Brn("C8:47:8C:00:00:00") - jk.start_scraping() - while True: - print(jk.get_status()) - time.sleep(5) -""" + import sys + + jk = Jkbms_Brn(sys.argv[1]) + if not jk.test_connection(): + logger.error(">>> ERROR: Unable to connect") + else: + jk.start_scraping() + while True: + logger.debug(jk.get_status()) + sleep(5) diff --git a/etc/dbus-serialbattery/bms/jkbms_can.py b/etc/dbus-serialbattery/bms/jkbms_can.py new file mode 100644 index 00000000..2021771d --- /dev/null +++ b/etc/dbus-serialbattery/bms/jkbms_can.py @@ -0,0 +1,267 @@ +# -*- coding: utf-8 -*- +from __future__ import absolute_import, division, print_function, unicode_literals +from battery import Battery, Cell +from utils import ( + is_bit_set, + logger, + MAX_BATTERY_CHARGE_CURRENT, + MAX_BATTERY_DISCHARGE_CURRENT, + MAX_CELL_VOLTAGE, + MIN_CELL_VOLTAGE, + zero_char, +) +from struct import unpack_from +import can +import time + +""" +https://github.com/Louisvdw/dbus-serialbattery/compare/dev...IrisCrimson:dbus-serialbattery:jkbms_can + +# Restrictions seen from code: +- +""" + + +class Jkbms_Can(Battery): + def __init__(self, port, baud, address): + super(Jkbms_Can, self).__init__(port, baud, address) + self.can_bus = False + self.cell_count = 1 + self.poll_interval = 1500 + self.type = self.BATTERYTYPE + self.last_error_time = time.time() + self.error_active = False + + def __del__(self): + if self.can_bus: + self.can_bus.shutdown() + self.can_bus = False + logger.debug("bus shutdown") + + BATTERYTYPE = "Jkbms_Can" + CAN_BUS_TYPE = "socketcan" + + CURRENT_ZERO_CONSTANT = 400 + BATT_STAT = "BATT_STAT" + CELL_VOLT = "CELL_VOLT" + CELL_TEMP = "CELL_TEMP" + ALM_INFO = "ALM_INFO" + + MESSAGES_TO_READ = 100 + + CAN_FRAMES = { + BATT_STAT: 0x02F4, + CELL_VOLT: 0x04F4, + CELL_TEMP: 0x05F4, + ALM_INFO: 0x07F4, + } + + 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 + return self.read_status_data() + + def get_settings(self): + # After successful connection get_settings will be call to set up the battery. + # Set the current limits, populate cell count, etc + # Return True if success, False for failure + self.max_battery_current = MAX_BATTERY_CHARGE_CURRENT + self.max_battery_discharge_current = MAX_BATTERY_DISCHARGE_CURRENT + self.max_battery_voltage = MAX_CELL_VOLTAGE * self.cell_count + self.min_battery_voltage = MIN_CELL_VOLTAGE * self.cell_count + + # init the cell array add only missing Cell instances + missing_instances = self.cell_count - len(self.cells) + if missing_instances > 0: + for c in range(missing_instances): + self.cells.append(Cell(False)) + + self.hardware_version = "JKBMS CAN " + str(self.cell_count) + " cells" + 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 + result = self.read_status_data() + + return result + + def read_status_data(self): + status_data = self.read_serial_data_jkbms_CAN() + # check if connection success + if status_data is False: + return False + + return True + + def to_fet_bits(self, byte_data): + tmp = bin(byte_data)[2:].rjust(2, zero_char) + self.charge_fet = is_bit_set(tmp[1]) + self.discharge_fet = is_bit_set(tmp[0]) + + def to_protection_bits(self, byte_data): + tmp = bin(byte_data | 0xFF00000000) + pos = len(tmp) + logger.debug(tmp) + self.protection.cell_overvoltage = 2 if int(tmp[pos - 2 : pos], 2) > 0 else 0 + self.protection.voltage_cell_low = ( + 2 if int(tmp[pos - 4 : pos - 2], 2) > 0 else 0 + ) + self.protection.voltage_high = 2 if int(tmp[pos - 6 : pos - 4], 4) > 0 else 0 + self.protection.voltage_low = 2 if int(tmp[pos - 8 : pos - 6], 2) > 0 else 0 + self.protection.cell_imbalance = 2 if int(tmp[pos - 10 : pos - 8], 2) > 0 else 0 + self.protection.current_under = 2 if int(tmp[pos - 12 : pos - 10], 2) > 0 else 0 + self.protection.current_over = 2 if int(tmp[pos - 14 : pos - 12], 2) > 0 else 0 + + # there is just a BMS and Battery temp alarm (not for charg and discharge) + self.protection.temp_high_charge = ( + 2 if int(tmp[pos - 16 : pos - 14], 2) > 0 else 0 + ) + self.protection.temp_high_discharge = ( + 2 if int(tmp[pos - 16 : pos - 14], 2) > 0 else 0 + ) + self.protection.temp_low_charge = ( + 2 if int(tmp[pos - 18 : pos - 16], 2) > 0 else 0 + ) + self.protection.temp_low_discharge = ( + 2 if int(tmp[pos - 18 : pos - 16], 2) > 0 else 0 + ) + self.protection.temp_high_charge = ( + 2 if int(tmp[pos - 20 : pos - 18], 2) > 0 else 0 + ) + self.protection.temp_high_discharge = ( + 2 if int(tmp[pos - 20 : pos - 18], 2) > 0 else 0 + ) + self.protection.soc_low = 2 if int(tmp[pos - 22 : pos - 20], 2) > 0 else 0 + self.protection.internal_failure = ( + 2 if int(tmp[pos - 24 : pos - 22], 2) > 0 else 0 + ) + self.protection.internal_failure = ( + 2 if int(tmp[pos - 26 : pos - 24], 2) > 0 else 0 + ) + self.protection.internal_failure = ( + 2 if int(tmp[pos - 28 : pos - 26], 2) > 0 else 0 + ) + self.protection.internal_failure = ( + 2 if int(tmp[pos - 30 : pos - 28], 2) > 0 else 0 + ) + + def reset_protection_bits(self): + self.protection.cell_overvoltage = 0 + self.protection.voltage_cell_low = 0 + self.protection.voltage_high = 0 + self.protection.voltage_low = 0 + self.protection.cell_imbalance = 0 + self.protection.current_under = 0 + self.protection.current_over = 0 + + # there is just a BMS and Battery temp alarm (not for charg and discharge) + self.protection.temp_high_charge = 0 + self.protection.temp_high_discharge = 0 + self.protection.temp_low_charge = 0 + self.protection.temp_low_discharge = 0 + self.protection.temp_high_charge = 0 + self.protection.temp_high_discharge = 0 + self.protection.soc_low = 0 + self.protection.internal_failure = 0 + self.protection.internal_failure = 0 + self.protection.internal_failure = 0 + self.protection.internal_failure = 0 + + def read_serial_data_jkbms_CAN(self): + if self.can_bus is False: + logger.debug("Can bus init") + # intit the can interface + try: + self.can_bus = can.interface.Bus( + bustype=self.CAN_BUS_TYPE, channel=self.port, bitrate=self.baud_rate + ) + except can.CanError as e: + logger.error(e) + + if self.can_bus is None: + return False + + logger.debug("Can bus init done") + + # reset errors after timeout + if ((time.time() - self.last_error_time) > 120.0) and self.error_active is True: + self.error_active = False + self.reset_protection_bits() + + # read msgs until we get one we want + messages_to_read = self.MESSAGES_TO_READ + while messages_to_read > 0: + msg = self.can_bus.recv(1) + if msg is None: + logger.info("No CAN Message received") + return False + + if msg is not None: + # print("message received") + messages_to_read -= 1 + # print(messages_to_read) + if msg.arbitration_id == self.CAN_FRAMES[self.BATT_STAT]: + voltage = unpack_from(" self.cell_count: + self.cell_count = max_cell_cnt + self.get_settings() + + for c_nr in range(len(self.cells)): + self.cells[c_nr].balance = False + + if self.cell_count == len(self.cells): + self.cells[max_cell_nr - 1].voltage = max_cell_volt + self.cells[max_cell_nr - 1].balance = True + + self.cells[min_cell_nr - 1].voltage = min_cell_volt + self.cells[min_cell_nr - 1].balance = True + + elif msg.arbitration_id == self.CAN_FRAMES[self.CELL_TEMP]: + max_temp = unpack_from("H", cycle_cap)[0]) charge_over_current = self.read_serial_data_llt(readCmd(REG_CHGOC)) if charge_over_current: self.max_battery_charge_current = float( @@ -263,6 +296,10 @@ def get_settings(self): self.max_battery_discharge_current = float( unpack_from(">h", discharge_over_current)[0] / -100.0 ) + func_config = self.read_serial_data_llt(readCmd(REG_FUNC_CONFIG)) + if func_config: + self.func_config = unpack_from(">H", func_config)[0] + self.balance_fet = (self.func_config & FUNC_BALANCE_EN) != 0 return True @@ -288,10 +325,123 @@ def write_soc(self): pack_voltage = struct.pack(">H", int(self.voltage * 10)) self.read_serial_data_llt(writeCmd(REG_CAP_100, pack_voltage)) + def force_charging_off_callback(self, path, value): + if value is None: + return False + + if value == 0: + self.trigger_force_disable_charge = False + return True + + if value == 1: + self.trigger_force_disable_charge = True + return True + + return False + + def force_discharging_off_callback(self, path, value): + if value is None: + return False + + if value == 0: + self.trigger_force_disable_discharge = False + return True + + if value == 1: + self.trigger_force_disable_discharge = True + return True + + return False + + def write_charge_discharge_mos(self): + if ( + self.trigger_force_disable_charge is None + and self.trigger_force_disable_discharge is None + ): + return False + + charge_disabled = 0 if self.charge_fet else 1 + if self.trigger_force_disable_charge is not None and self.control_allow_charge: + charge_disabled = 1 if self.trigger_force_disable_charge else 0 + logger.info( + f"write force disable charging: {'true' if self.trigger_force_disable_charge else 'false'}" + ) + self.trigger_force_disable_charge = None + + discharge_disabled = 0 if self.discharge_fet else 1 + if ( + self.trigger_force_disable_discharge is not None + and self.control_allow_discharge + ): + discharge_disabled = 1 if self.trigger_force_disable_discharge else 0 + logger.info( + f"write force disable discharging: {'true' if self.trigger_force_disable_discharge else 'false'}" + ) + self.trigger_force_disable_discharge = None + + mosdata = pack(">BB", 0, charge_disabled | (discharge_disabled << 1)) + + reply = self.read_serial_data_llt(writeCmd(REG_CTRL_MOSFET, mosdata)) + + if reply is False: + logger.error("write force disable charge/discharge failed") + return False + + def turn_balancing_off_callback(self, path, value): + if value is None: + return False + + if value == 0: + self.trigger_disable_balancer = False + return True + + if value == 1: + self.trigger_disable_balancer = True + return True + + return False + + def write_balancer(self): + if self.trigger_disable_balancer is None: + return False + + disable_balancer = self.trigger_disable_balancer + logger.info( + f"write disable balancer: {'true' if self.trigger_disable_balancer else 'false'}" + ) + self.trigger_disable_balancer = None + new_func_config = None + + with self.eeprom(): + func_config = self.read_serial_data_llt(readCmd(REG_FUNC_CONFIG)) + if func_config: + self.func_config = unpack_from(">H", func_config)[0] + balancer_enabled = self.func_config & FUNC_BALANCE_EN + # Balance is enabled, force disable OR balancer is disabled and force enable + if (balancer_enabled != 0 and disable_balancer) or ( + balancer_enabled == 0 and not disable_balancer + ): + new_func_config = self.func_config ^ FUNC_BALANCE_EN + + if new_func_config: + new_func_config_bytes = pack(">H", new_func_config) + with self.eeprom(writable=True): + reply = self.read_serial_data_llt( + writeCmd(REG_FUNC_CONFIG, new_func_config_bytes) + ) + if reply is False: + logger.error("write force disable balancer failed") + return False + else: + self.func_config = new_func_config + self.balance_fet = (self.func_config & FUNC_BALANCE_EN) != 0 + + return True + def refresh_data(self): - result = self.read_gen_data() - result = result and self.read_cell_data() - return result + self.write_charge_discharge_mos() + self.write_balancer() + return self.read_gen_data() and self.read_cell_data() def to_protection_bits(self, byte_data): tmp = bin(byte_data)[2:].rjust(13, utils.zero_char) @@ -381,7 +531,7 @@ def to_fet_bits(self, byte_data): def read_gen_data(self): gen_data = self.read_serial_data_llt(self.command_general) # check if connect success - if gen_data is False or len(gen_data) < 27: + if gen_data is False or len(gen_data) < 23: return False ( @@ -402,7 +552,10 @@ def read_gen_data(self): ) = unpack_from(">HhHHHHhHHBBBBB", gen_data) self.voltage = voltage / 100 self.current = current / 100 - self.soc = round(100 * capacity_remain / capacity, 2) + # https://github.com/Louisvdw/dbus-serialbattery/issues/769#issuecomment-1720805325 + if not self.cycle_capacity or self.cycle_capacity < capacity_remain: + self.cycle_capacity = capacity + self.soc = round(100 * capacity_remain / self.cycle_capacity, 2) self.capacity_remain = capacity_remain / 100 self.capacity = capacity / 100 self.to_cell_bits(balance, balance2) @@ -416,6 +569,13 @@ def read_gen_data(self): # 0 = MOS, 1 = temp 1, 2 = temp 2 for t in range(self.temp_sensors): + if len(gen_data) < 23 + (2 * t) + 2: + logger.warn( + "Expected %d temperature sensors, but received only %d sensor readings!", + self.temp_sensors, + t, + ) + return True temp1 = unpack_from(">H", gen_data, 23 + (2 * t))[0] self.to_temp(t, utils.kelvin_to_celsius(temp1 / 10)) @@ -450,13 +610,13 @@ def read_hardware_data(self): @staticmethod def validate_packet(data): - if not data: - return False - if data is False: return False start, op, status, payload_length = unpack_from("BBBB", data) + + logger.debug("bytearray: " + utils.bytearray_to_string(data)) + if start != 0xDD: logger.error( ">>> ERROR: Invalid response packet. Expected begin packet character 0xDD" diff --git a/etc/dbus-serialbattery/bms/lltjbd_ble.py b/etc/dbus-serialbattery/bms/lltjbd_ble.py index de995492..449e7d0b 100644 --- a/etc/dbus-serialbattery/bms/lltjbd_ble.py +++ b/etc/dbus-serialbattery/bms/lltjbd_ble.py @@ -2,11 +2,15 @@ import asyncio import atexit import functools +import os import threading +import sys from asyncio import CancelledError +from time import sleep from typing import Union, Optional from utils import logger from bleak import BleakClient, BleakScanner, BLEDevice +from bleak.exc import BleakDBusError from bms.lltjbd import LltJbdProtection, LltJbd BLE_SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb" @@ -55,25 +59,72 @@ async def bt_main_loop(self): self.device = await BleakScanner.find_device_by_address( self.address, cb=dict(use_bdaddr=True) ) - except Exception as e: - logger.error(">>> ERROR: Bluetooth stack failed.", e) + + except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"BleakScanner(): Exception occurred: {repr(exception_object)} of type {exception_type} " + f"in {file} line #{line}" + ) self.device = None await asyncio.sleep(0.5) + # allow the bluetooth connection to recover + sleep(5) if not self.device: self.run = False return - async with BleakClient( - self.device, disconnected_callback=self.on_disconnect - ) as client: - self.bt_client = client - self.bt_loop = asyncio.get_event_loop() - self.response_queue = asyncio.Queue() - self.ready_event.set() - while self.run and client.is_connected and self.main_thread.is_alive(): - await asyncio.sleep(0.1) - self.bt_loop = None + try: + async with BleakClient( + self.device, disconnected_callback=self.on_disconnect + ) as client: + self.bt_client = client + self.bt_loop = asyncio.get_event_loop() + self.response_queue = asyncio.Queue() + self.ready_event.set() + while self.run and client.is_connected and self.main_thread.is_alive(): + await asyncio.sleep(0.1) + self.bt_loop = None + + # Exception occurred: TimeoutError() of type + except asyncio.exceptions.TimeoutError: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"BleakClient(): asyncio.exceptions.TimeoutError: {repr(exception_object)} of type {exception_type} " + f"in {file} line #{line}" + ) + # needed? + self.run = False + return + + except TimeoutError: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"BleakClient(): TimeoutError: {repr(exception_object)} of type {exception_type} " + f"in {file} line #{line}" + ) + # needed? + self.run = False + return + + except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"BleakClient(): Exception occurred: {repr(exception_object)} of type {exception_type} " + f"in {file} line #{line}" + ) + # needed? + self.run = False + return def background_loop(self): while self.run and self.main_thread.is_alive(): @@ -110,8 +161,13 @@ def test_connection(self): result = super().test_connection() if not result: logger.error("No BMS found at " + self.address) - except Exception as err: - logger.error(f"Unexpected {err=}, {type(err)=}") + except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"Exception occurred: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) result = False return result @@ -154,8 +210,23 @@ async def async_read_serial_data_llt(self, command): except asyncio.TimeoutError: logger.error(">>> ERROR: No reply - returning") return False - except Exception as e: - logger.error(">>> ERROR: No reply - returning", e) + except BleakDBusError: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"BleakDBusError: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) + self.reset_bluetooth() + return False + except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"Exception occurred: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) + self.reset_bluetooth() return False def read_serial_data_llt(self, command): @@ -165,19 +236,54 @@ def read_serial_data_llt(self, command): data = asyncio.run(self.async_read_serial_data_llt(command)) return self.validate_packet(data) except CancelledError as e: - logger.error(">>> ERROR: No reply - canceled - returning", e) + logger.error(">>> ERROR: No reply - canceled - returning") + logger.error(e) return False - except Exception as e: - logger.error(">>> ERROR: No reply - returning", e) + # except Exception as e: + # logger.error(">>> ERROR: No reply - returning") + # logger.error(e) + # return False + except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"Exception occurred: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) return False + def reset_bluetooth(self): + logger.error("Reset of system Bluetooth daemon triggered") + self.bt_loop = False + + # process kill is needed, since the service/bluetooth driver is probably freezed + # os.system('pkill -f "bluetoothd"') + # stop will not work, if service/bluetooth driver is stuck + os.system("/etc/init.d/bluetooth stop") + sleep(2) + os.system("rfkill block bluetooth") + os.system("rfkill unblock bluetooth") + os.system("/etc/init.d/bluetooth start") + logger.error("System Bluetooth daemon should have been restarted") + sleep(5) + sys.exit(1) -if __name__ == "__main__": - import sys +if __name__ == "__main__": bat = LltJbd_Ble("Foo", -1, sys.argv[1]) if not bat.test_connection(): logger.error(">>> ERROR: Unable to connect") else: + # Allow to change charge / discharge FET + bat.control_allow_charge = True + bat.control_allow_discharge = True + + bat.trigger_disable_balancer = True + bat.trigger_force_disable_charge = True + bat.trigger_force_disable_discharge = True + bat.refresh_data() + bat.trigger_disable_balancer = False + bat.trigger_force_disable_charge = False + bat.trigger_force_disable_discharge = False bat.refresh_data() bat.get_settings() diff --git a/etc/dbus-serialbattery/bms/mnb.py b/etc/dbus-serialbattery/bms/mnb.py index 84365866..ac95608d 100644 --- a/etc/dbus-serialbattery/bms/mnb.py +++ b/etc/dbus-serialbattery/bms/mnb.py @@ -1,9 +1,9 @@ # -*- coding: utf-8 -*- -# disable MNB battery by default -# https://github.com/Louisvdw/dbus-serialbattery/commit/65241cbff36feb861ff43dbbcfb2b495f14a01ce -# remove duplicate MNB lines -# https://github.com/Louisvdw/dbus-serialbattery/commit/23afec33c2fd87fd4d4c53516f0a25f290643c82 +# # MNB is disabled by default +# can be enabled by specifying it in the BMS_TYPE setting in the "config.ini" +# https://github.com/Louisvdw/dbus-serialbattery/issues/590 +# https://community.victronenergy.com/comments/231924/view.html from battery import Protection, Battery, Cell from utils import logger diff --git a/etc/dbus-serialbattery/bms/renogy.py b/etc/dbus-serialbattery/bms/renogy.py index acfe2335..e920a771 100644 --- a/etc/dbus-serialbattery/bms/renogy.py +++ b/etc/dbus-serialbattery/bms/renogy.py @@ -48,8 +48,7 @@ def test_connection(self): try: result = self.read_gen_data() # get first data to show in startup log - if result: - self.refresh_data() + result = result and self.refresh_data() except Exception as err: logger.error(f"Unexpected {err=}, {type(err)=}") result = False @@ -145,6 +144,8 @@ def read_cell_data(self): self.cells[c].voltage = 0 return True + """ + # Did not found who changed this. "command_env_temp_count" is missing def read_temp_data(self): # Check to see how many Enviromental Temp Sensors this battery has, it may have none. num_env_temps = self.read_serial_data_renogy(self.command_env_temp_count) @@ -172,6 +173,17 @@ def read_temp_data(self): logger.info("temp2 = %s °C", temp2) return True + """ + + def read_temp_data(self): + temp1 = self.read_serial_data_renogy(self.command_bms_temp1) + temp2 = self.read_serial_data_renogy(self.command_bms_temp2) + if temp1 is False: + return False + self.temp1 = unpack(">H", temp1)[0] / 10 + self.temp2 = unpack(">H", temp2)[0] / 10 + + return True def read_bms_config(self): return True diff --git a/etc/dbus-serialbattery/bms/seplos.py b/etc/dbus-serialbattery/bms/seplos.py index b7c9a2e1..0a0c3fe2 100644 --- a/etc/dbus-serialbattery/bms/seplos.py +++ b/etc/dbus-serialbattery/bms/seplos.py @@ -115,7 +115,6 @@ def refresh_data(self): # This will be called for every iteration (self.poll_interval) # Return True if success, False for failure result_status = self.read_status_data() - # sleep(0.5) result_alarm = self.read_alarm_data() return result_status and result_alarm @@ -129,15 +128,20 @@ def decode_alarm_byte(data_byte: int, alarm_bit: int, warn_bit: int): return Protection.OK def read_alarm_data(self): + logger.debug("read alarm data") data = self.read_serial_data_seplos( self.encode_cmd(address=0x00, cid2=self.COMMAND_ALARM, info=b"01") ) - # check if connection success - if data is False: + # check if we could successfully read data and we have the expected length of 98 bytes + if data is False or len(data) != 98: return False - logger.debug("alarm info raw {}".format(data)) - return self.decode_alarm_data(bytes.fromhex(data.decode("ascii"))) + try: + logger.debug("alarm info raw {}".format(data)) + return self.decode_alarm_data(bytes.fromhex(data.decode("ascii"))) + except (ValueError, UnicodeDecodeError) as e: + logger.warning("could not hex-decode raw alarm data", exc_info=e) + return False def decode_alarm_data(self, data: bytes): logger.debug("alarm info decoded {}".format(data)) @@ -191,14 +195,21 @@ def decode_alarm_data(self, data: bytes): def read_status_data(self): logger.debug("read status data") + data = self.read_serial_data_seplos( self.encode_cmd(address=0x00, cid2=0x42, info=b"01") ) - # check if connection success - if data is False: + # check if reading data was successful and has the expected data length of 150 byte + if data is False or len(data) != 150: + return False + + if not self.decode_status_data(data): return False + return True + + def decode_status_data(self, data): cell_count_offset = 4 voltage_offset = 6 temps_offset = 72 @@ -218,7 +229,6 @@ def read_status_data(self): ) / 10 self.cells[i].temp = temp logger.debug("Temp cell[{}]={}°C".format(i, temp)) - self.temp1 = ( Seplos.int_from_2byte_hex_ascii(data, temps_offset + 4 * 4) - 2731 ) / 10 @@ -234,7 +244,6 @@ def read_status_data(self): self.soc = Seplos.int_from_2byte_hex_ascii(data, offset=114) / 10 self.cycles = Seplos.int_from_2byte_hex_ascii(data, offset=122) self.hardware_version = "Seplos BMS {} cells".format(self.cell_count) - logger.debug("Current = {}A , Voltage = {}V".format(self.current, self.voltage)) logger.debug( "Capacity = {}/{}Ah , SOC = {}%".format( @@ -297,7 +306,9 @@ def read_serial_data_seplos(self, command): return_data = data[length_pos + 3 : -5] info_length = Seplos.int_from_2byte_hex_ascii(b"0" + data[length_pos:], 0) logger.debug( - "return info data of length {} : {}".format(info_length, return_data) + "returning info data of length {}, info_length is {} : {}".format( + len(return_data), info_length, return_data + ) ) return return_data diff --git a/etc/dbus-serialbattery/bms/sinowealth.py b/etc/dbus-serialbattery/bms/sinowealth.py index 7a9d9fdb..8e4e900e 100755 --- a/etc/dbus-serialbattery/bms/sinowealth.py +++ b/etc/dbus-serialbattery/bms/sinowealth.py @@ -1,6 +1,7 @@ # -*- coding: utf-8 -*- -# disable Sinowealth by default as it causes other issues but can be enabled manually +# Sinowealth is disabled by default as it causes issues with other devices +# can be enabled by specifying it in the BMS_TYPE setting in the "config.ini" # https://github.com/Louisvdw/dbus-serialbattery/commit/7aab4c850a5c8d9c205efefc155fe62bb527da8e from battery import Battery, Cell @@ -44,8 +45,8 @@ def test_connection(self): result = False try: result = self.read_status_data() - result = result and self.read_remaining_capacity() - result = result and self.read_pack_config_data() + result = result and self.get_settings() + result = result and self.refresh_data() except Exception as err: logger.error(f"Unexpected {err=}, {type(err)=}") result = False @@ -64,9 +65,10 @@ def get_settings(self): self.min_battery_voltage = utils.MIN_CELL_VOLTAGE * self.cell_count self.hardware_version = "Daly/Sinowealth BMS " + str(self.cell_count) + " cells" - logger.info(self.hardware_version) + logger.debug(self.hardware_version) - self.read_capacity() + if not self.read_capacity(): + return False for c in range(self.cell_count): self.cells.append(Cell(False)) @@ -95,7 +97,7 @@ def read_status_data(self): # [1] - FAST_DSG MID_DSG SLOW_DSG DSGING CHGING DSGMOS CHGMOS self.discharge_fet = bool(status_data[1] >> 1 & int(1)) # DSGMOS self.charge_fet = bool(status_data[1] & int(1)) # CHGMOS - logger.info( + logger.debug( ">>> INFO: Discharge fet: %s, charge fet: %s", self.discharge_fet, self.charge_fet, @@ -145,8 +147,9 @@ def read_soc(self): # check if connection success if soc_data is False: return False - logger.info(">>> INFO: current SOC: %u", soc_data[1]) - self.soc = soc_data[1] + logger.debug(">>> INFO: current SOC: %u", soc_data[1]) + soc = soc_data[1] + self.soc = soc return True def read_cycle_count(self): @@ -156,7 +159,7 @@ def read_cycle_count(self): if cycle_count is False: return False self.cycles = int(unpack_from(">H", cycle_count[:2])[0]) - logger.info(">>> INFO: current cycle count: %u", self.cycles) + logger.debug(">>> INFO: current cycle count: %u", self.cycles) return True def read_pack_voltage(self): @@ -165,8 +168,8 @@ def read_pack_voltage(self): return False pack_voltage = unpack_from(">H", pack_voltage_data[:-1]) pack_voltage = pack_voltage[0] / 1000 - logger.info(">>> INFO: current pack voltage: %f", pack_voltage) self.voltage = pack_voltage + logger.debug(">>> INFO: current pack voltage: %f", self.voltage) return True def read_pack_current(self): @@ -175,7 +178,8 @@ def read_pack_current(self): return False current = unpack_from(">i", current_data[:-1]) current = current[0] / 1000 - logger.info(">>> INFO: current pack current: %f", current) + logger.debug(">>> INFO: current pack current: %f", current) + self.current = current return True @@ -187,7 +191,9 @@ def read_remaining_capacity(self): return False remaining_capacity = unpack_from(">i", remaining_capacity_data[:-1]) self.capacity_remain = remaining_capacity[0] / 1000 - logger.info(">>> INFO: remaining battery capacity: %f Ah", self.capacity_remain) + logger.debug( + ">>> INFO: remaining battery capacity: %f Ah", self.capacity_remain + ) return True def read_capacity(self): @@ -195,8 +201,10 @@ def read_capacity(self): if capacity_data is False: return False capacity = unpack_from(">i", capacity_data[:-1]) - logger.info(">>> INFO: Battery capacity: %f Ah", capacity[0] / 1000) - self.capacity = capacity[0] / 1000 + capacity = capacity[0] / 1000 + logger.debug(">>> INFO: Battery capacity: %f Ah", capacity) + + self.capacity = capacity return True def read_pack_config_data(self): @@ -210,12 +218,12 @@ def read_pack_config_data(self): if self.cell_count < 1 or self.cell_count > 32: logger.error(">>> ERROR: No valid cell count returnd: %u", self.cell_count) return False - logger.info(">>> INFO: Number of cells: %u", self.cell_count) + logger.debug(">>> INFO: Number of cells: %u", self.cell_count) temp_sens_mask = int(~(1 << 6)) self.temp_sensors = ( 1 if (pack_config_data[1] & temp_sens_mask) else 2 ) # one means two - logger.info(">>> INFO: Number of temperatur sensors: %u", self.temp_sensors) + logger.debug(">>> INFO: Number of temperatur sensors: %u", self.temp_sensors) return True def read_cell_data(self): @@ -235,7 +243,7 @@ def read_cell_voltage(self, cell_index): cell_voltage = unpack_from(">H", cell_data[:-1]) cell_voltage = cell_voltage[0] / 1000 - logger.info(">>> INFO: Cell %u voltage: %f V", cell_index, cell_voltage) + logger.debug(">>> INFO: Cell %u voltage: %f V", cell_index, cell_voltage) return cell_voltage def read_temperature_data(self): @@ -248,7 +256,7 @@ def read_temperature_data(self): temp_ext1 = unpack_from(">H", temp_ext1_data[:-1]) self.to_temp(1, kelvin_to_celsius(temp_ext1[0] / 10)) - logger.info(">>> INFO: BMS external temperature 1: %f C", self.temp1) + logger.debug(">>> INFO: BMS external temperature 1: %f C", self.temp1) if self.temp_sensors == 2: temp_ext2_data = self.read_serial_data_sinowealth(self.command_temp_ext2) @@ -257,7 +265,7 @@ def read_temperature_data(self): temp_ext2 = unpack_from(">H", temp_ext2_data[:-1]) self.to_temp(2, kelvin_to_celsius(temp_ext2[0] / 10)) - logger.info(">>> INFO: BMS external temperature 2: %f C", self.temp2) + logger.debug(">>> INFO: BMS external temperature 2: %f C", self.temp2) # Internal temperature 1 seems to give a logical value temp_int1_data = self.read_serial_data_sinowealth(self.command_temp_int1) @@ -265,7 +273,7 @@ def read_temperature_data(self): return False temp_int1 = unpack_from(">H", temp_int1_data[:-1]) - logger.info( + logger.debug( ">>> INFO: BMS internal temperature 1: %f C", kelvin_to_celsius(temp_int1[0] / 10), ) @@ -276,7 +284,7 @@ def read_temperature_data(self): return False temp_int2 = unpack_from(">H", temp_int2_data[:-1]) - logger.info( + logger.debug( ">>> INFO: BMS internal temperature 2: %f C", kelvin_to_celsius(temp_int2[0] / 10), ) diff --git a/etc/dbus-serialbattery/config.default.ini b/etc/dbus-serialbattery/config.default.ini index e7c967ff..af546e91 100644 --- a/etc/dbus-serialbattery/config.default.ini +++ b/etc/dbus-serialbattery/config.default.ini @@ -1,5 +1,12 @@ [DEFAULT] +; --------- Set logging level --------- +; ERROR: Only errors are logged +; WARNING: Errors and warnings are logged +; INFO: Errors, warnings and info messages are logged +; DEBUG: Errors, warnings, info and debug messages are logged +LOGGING = INFO + ; --------- Battery Current limits --------- MAX_BATTERY_CHARGE_CURRENT = 50.0 MAX_BATTERY_DISCHARGE_CURRENT = 60.0 @@ -8,19 +15,55 @@ MAX_BATTERY_DISCHARGE_CURRENT = 60.0 ; Description: Cell min/max voltages which are used to calculate the min/max battery voltage ; Example: 16 cells * 3.45V/cell = 55.2V max charge voltage. 16 cells * 2.90V = 46.4V min discharge voltage MIN_CELL_VOLTAGE = 2.900 -; Max voltage can seen as absorption voltage +; Max voltage (can seen as absorption voltage) MAX_CELL_VOLTAGE = 3.450 +; Float voltage (can be seen as resting voltage) FLOAT_CELL_VOLTAGE = 3.375 +; --------- SOC reset voltage --------- +; Description: May be needed to reset the SoC to 100% once in a while for some BMS, because of SoC drift. +; Specify the cell voltage where the SoC should be reset to 100% by the BMS. +; - JKBMS: SoC is reset to 100% if one cell reaches OVP (over voltage protection) voltage +; As you have to adopt this value to your system, I reccomend to start with +; OVP voltage - 0.030 (see Example). +; - Try to increase (add) by 0.005 in steps, if the system does not switch to float mode, even if +; the target voltage SOC_RESET_VOLTAGE * CELL_COUNT is reached. +; - Try to decrease (lower) by 0.005 in steps, if the system hits the OVP too fast, before all +; cells could be balanced and the system goes into protection mode multiple times. +; Example: If OVP is 3.650, then start with 3.620 and increase/decrease by 0.005 +; Note: The value has to be higher as the MAX_CELL_VOLTAGE +SOC_RESET_VOLTAGE = 3.650 +; Specify after how many days the soc reset voltage should be reached again +; The timer is reset when the soc reset voltage is reached +; Leave empty if you don't want to use this +; Example: Value is set to 15 +; day 1: soc reset reached once +; day 16: soc reset reached twice +; day 31: soc reset not reached since it's very cloudy +; day 34: soc reset reached since the sun came out +; day 49: soc reset reached again, since last time it took 3 days to reach soc reset voltage +SOC_RESET_AFTER_DAYS = + ; --------- Bluetooth BMS --------- -; Description: List the Bluetooth BMS here that you want to install +; Description: Specify the Bluetooth BMS and it's MAC address that you want to install. Leave emty to disable ; -- Available Bluetooth BMS: ; Jkbms_Ble, LltJbd_Ble -; Example: -; 1 BMS: Jkbms_Ble C8:47:8C:00:00:00 -; 3 BMS: Jkbms_Ble C8:47:8C:00:00:00, Jkbms_Ble C8:47:8C:00:00:11, Jkbms_Ble C8:47:8C:00:00:22 +; Example for one BMS: +; BLUETOOTH_BMS = Jkbms_Ble C8:47:8C:00:00:00 +; Example for multiple BMS: +; BLUETOOTH_BMS = Jkbms_Ble C8:47:8C:00:00:00, Jkbms_Ble C8:47:8C:00:00:11, Jkbms_Ble C8:47:8C:00:00:22 BLUETOOTH_BMS = +; --------- CAN BMS --------- +; Description: Specify the CAN port(s) where the BMS is connected to. Leave empty to disable +; -- Available CAN BMS: +; Daly_Can, Jkbms_Can +; Example for one CAN port: +; CAN_PORT = can0 +; Example for multiple CAN ports: +; CAN_PORT = can0, can8, can9 +CAN_PORT = + ; --------- BMS disconnect behaviour --------- ; Description: Block charge and discharge when the communication to the BMS is lost. If you are removing the ; BMS on purpose, then you have to restart the driver/system to reset the block. @@ -60,11 +103,11 @@ LINEAR_RECALCULATION_ON_PERC_CHANGE = 5 ; it switches back to max voltage. ; Example: The battery reached max voltage of 55.2V and hold it for 900 seconds, the the CVL is switched to ; float voltage of 53.6V to don't stress the batteries. Allow max voltage of 55.2V again, if SoC is -; once below 90% +; once below 80% ; OR ; The battery reached max voltage of 55.2V and the max cell difference is 0.010V, then switch to float ; voltage of 53.6V after 300 additional seconds to don't stress the batteries. Allow max voltage of -; 55.2V again if max cell difference is above 0.080V or SoC below 90%. +; 55.2V again if max cell difference is above 0.080V or SoC below 80%. ; Charge voltage control management enable (True/False). CVCM_ENABLE = True @@ -76,11 +119,15 @@ CELL_VOLTAGE_DIFF_KEEP_MAX_VOLTAGE_UNTIL = 0.010 ; e.g. 3.2 V * 5 / 100 = 0.160 V CELL_VOLTAGE_DIFF_TO_RESET_VOLTAGE_LIMIT = 0.080 -; -- CVL reset based on SoC option (step mode) -; Specify how long the max voltage should be kept, if reached then switch to float voltage +; -- CVL reset based on SoC option (step mode & linear mode) +; Specify how long the max voltage should be kept +; Step mode: If reached then switch to float voltage +; Linear mode: If cells are balanced keep max voltage for further MAX_VOLTAGE_TIME_SEC seconds MAX_VOLTAGE_TIME_SEC = 900 -; Specify SoC where CVL limit is reset to max voltage, if value gets below -SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT = 90 +; Specify SoC where CVL limit is reset to max voltage +; Step mode: If SoC gets below +; Linear mode: If cells are unbalanced or if SoC gets below +SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT = 80 ; --------- Cell Voltage Current limitation (affecting CCL/DCL) --------- @@ -131,7 +178,7 @@ CCCM_SOC_ENABLE = True ; Discharge current control management enable (True/False). DCCM_SOC_ENABLE = True -; Charge current soc limits +; Charge current SoC limits CC_SOC_LIMIT1 = 98 CC_SOC_LIMIT2 = 95 CC_SOC_LIMIT3 = 91 @@ -141,7 +188,7 @@ CC_CURRENT_LIMIT1_FRACTION = 0.1 CC_CURRENT_LIMIT2_FRACTION = 0.3 CC_CURRENT_LIMIT3_FRACTION = 0.5 -; Discharge current soc limits +; Discharge current SoC limits DC_SOC_LIMIT1 = 10 DC_SOC_LIMIT2 = 20 DC_SOC_LIMIT3 = 30 @@ -183,14 +230,28 @@ TIME_TO_SOC_INC_FROM = False ; --------- Additional settings --------- -; Specify only one BMS type to load else leave empty to try to load all available +; Specify one or more BMS types to load else leave empty to try to load all available ; -- Available BMS: ; Daly, Ecs, HeltecModbus, HLPdataBMS4S, Jkbms, Lifepower, LltJbd, Renogy, Seplos -; -- Available BMS, but disabled by default: -; https://louisvdw.github.io/dbus-serialbattery/general/install#how-to-enable-a-disabled-bms -; Ant, MNB, Sinowealth +; -- Available BMS, but disabled by default (just enter one or more below and it will be enabled): +; ANT, MNB, Sinowealth BMS_TYPE = +; Exclute this serial devices from the driver startup +; Example: /dev/ttyUSB2, /dev/ttyUSB4 +EXCLUDED_DEVICES = + +; Enter custom battery names here or change it over the GUI +; Example: +; /dev/ttyUSB0:My first battery +; /dev/ttyUSB0:My first battery,/dev/ttyUSB1:My second battery +CUSTOM_BATTERY_NAMES = + +; Auto reset SoC +; If on, then SoC is reset to 100%, if the value switches from absorption to float voltage +; Currently only working for Daly BMS and JK BMS BLE +AUTO_RESET_SOC = True + ; Publish the config settings to the dbus path "/Info/Config/" PUBLISH_CONFIG_VALUES = 1 @@ -254,16 +315,18 @@ LIPRO_CELL_COUNT = 15 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 -; from the BMS and SmartShunt could differ. This causes, that the driver never goapplies the float voltage, -; since max voltage is never reached. +; --------- Voltage drop --------- +; If you have a voltage drop between the BMS and the charger because of wire size or length +; then you can specify the voltage drop here. The driver will then add the voltage drop +; to the calculated CVL to compensate. ; Example: ; cell count: 16 ; MAX_CELL_VOLTAGE = 3.45 ; max voltage calculated = 16 * 3.45 = 55.20 -; CVL is set to 55.20 and the battery is now charged until the SmartShunt measures 55.20 V. The BMS -; now measures 55.05 V since there is a voltage drop of 0.15 V. Since the dbus-serialbattery measures -; 55.05 V the max voltage is never reached for the driver and max voltage is kept forever. -; Set VOLTAGE_DROP to 0.15 +; CVL is set to 55.20 V and the battery is now charged until the charger reaches 55.20 V. +; The BMS now measures 55.05 V since there is a voltage drop of 0.15 V on the cable. +; Since the dbus-serialbattery reads the voltage of 55.05 V from the BMS the max voltage +; of 55.20 V is never reached and max voltage is kept forever. +; By setting the VOLTAGE_DROP to 0.15 V the voltage on the charger is increased and the +; target voltage on the BMS is reached. VOLTAGE_DROP = 0.00 diff --git a/etc/dbus-serialbattery/dbus-serialbattery.py b/etc/dbus-serialbattery/dbus-serialbattery.py index 4bca9b35..69c90437 100644 --- a/etc/dbus-serialbattery/dbus-serialbattery.py +++ b/etc/dbus-serialbattery/dbus-serialbattery.py @@ -5,7 +5,6 @@ from time import sleep from dbus.mainloop.glib import DBusGMainLoop -# from threading import Thread ## removed with https://github.com/Louisvdw/dbus-serialbattery/pull/582 import sys if sys.version_info.major == 2: @@ -32,9 +31,13 @@ from bms.renogy import Renogy from bms.seplos import Seplos -# from bms.ant import Ant -# from bms.mnb import MNB -# from bms.sinowealth import Sinowealth +# enabled only if explicitly set in config under "BMS_TYPE" +if "ANT" in utils.BMS_TYPE: + from bms.ant import ANT +if "MNB" in utils.BMS_TYPE: + from bms.mnb import MNB +if "Sinowealth" in utils.BMS_TYPE: + from bms.sinowealth import Sinowealth supported_bms_types = [ {"bms": Daly, "baud": 9600, "address": b"\x40"}, @@ -48,21 +51,30 @@ {"bms": Renogy, "baud": 9600, "address": b"\x30"}, {"bms": Renogy, "baud": 9600, "address": b"\xF7"}, {"bms": Seplos, "baud": 19200}, - # {"bms": Ant, "baud": 19200}, - # {"bms": MNB, "baud": 9600}, - # {"bms": Sinowealth}, ] + +# enabled only if explicitly set in config under "BMS_TYPE" +if "ANT" in utils.BMS_TYPE: + supported_bms_types.append({"bms": ANT, "baud": 19200}) +if "MNB" in utils.BMS_TYPE: + supported_bms_types.append({"bms": MNB, "baud": 9600}) +if "Sinowealth" in utils.BMS_TYPE: + supported_bms_types.append({"bms": Sinowealth, "baud": 9600}) + expected_bms_types = [ battery_type for battery_type in supported_bms_types - if battery_type["bms"].__name__ == utils.BMS_TYPE or utils.BMS_TYPE == "" + if battery_type["bms"].__name__ in utils.BMS_TYPE or len(utils.BMS_TYPE) == 0 ] -print("") +logger.info("") logger.info("Starting dbus-serialbattery") def main(): + # NameError: free variable 'expected_bms_types' referenced before assignment in enclosing scope + global expected_bms_types + def poll_battery(loop): helper.publish_battery(loop) return True @@ -70,19 +82,33 @@ def poll_battery(loop): def get_battery(_port) -> Union[Battery, None]: # all the different batteries the driver support and need to test for # try to establish communications with the battery 3 times, else exit - count = 3 - while count > 0: + retry = 1 + retries = 3 + while retry <= retries: + logger.info( + "-- Testing BMS: " + str(retry) + " of " + str(retries) + " rounds" + ) # create a new battery object that can read the battery and run connection test for test in expected_bms_types: # noinspection PyBroadException try: - logger.info("Testing " + test["bms"].__name__) + logger.info( + "Testing " + + test["bms"].__name__ + + ( + ' at address "' + + utils.bytearray_to_string(test["address"]) + + '"' + if "address" in test + else "" + ) + ) batteryClass = test["bms"] baud = test["baud"] battery: Battery = batteryClass( port=_port, baud=baud, address=test.get("address") ) - if battery.test_connection(): + if battery.test_connection() and battery.validate_data(): logger.info( "Connection established to " + battery.__class__.__name__ ) @@ -90,9 +116,19 @@ def get_battery(_port) -> Union[Battery, None]: except KeyboardInterrupt: return None except Exception: + ( + exception_type, + exception_object, + exception_traceback, + ) = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"Exception occurred: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) # Ignore any malfunction test_function() pass - count -= 1 + retry += 1 sleep(0.5) return None @@ -100,11 +136,21 @@ def get_battery(_port) -> Union[Battery, None]: def get_port() -> str: # Get the port we need to use from the argument if len(sys.argv) > 1: - return sys.argv[1] + port = sys.argv[1] + if port not in utils.EXCLUDED_DEVICES: + return port + else: + logger.debug( + "Stopping dbus-serialbattery: " + + str(port) + + " is excluded trough the config file" + ) + sleep(60) + sys.exit(0) else: # just for MNB-SPI logger.info("No Port needed") - return "/dev/tty/USB9" + return "/dev/ttyUSB9" logger.info("dbus-serialbattery v" + str(utils.DRIVER_VERSION)) @@ -125,10 +171,35 @@ def get_port() -> str: class_ = eval(port) testbms = class_("", 9600, sys.argv[2]) - if testbms.test_connection() is True: + if testbms.test_connection(): logger.info("Connection established to " + testbms.__class__.__name__) battery = testbms + elif port.startswith("can"): + """ + Import CAN classes only, if it's a can port, else the driver won't start due to missing python modules + This prevent problems when using the driver only with a serial connection + """ + from bms.daly_can import Daly_Can + from bms.jkbms_can import Jkbms_Can + + # only try CAN BMS on CAN port + supported_bms_types = [ + {"bms": Daly_Can, "baud": 250000}, + {"bms": Jkbms_Can, "baud": 250000}, + ] + + expected_bms_types = [ + battery_type + for battery_type in supported_bms_types + if battery_type["bms"].__name__ in utils.BMS_TYPE + or len(utils.BMS_TYPE) == 0 + ] + + battery = get_battery(port) else: + # wait some seconds to be sure that the serial connection is ready + # else the error throw a lot of timeouts + sleep(16) battery = get_battery(port) # exit if no battery could be found @@ -151,8 +222,12 @@ def get_port() -> str: logger.error("ERROR >>> Problem with battery set up at " + port) sys.exit(1) - # Poll the battery at INTERVAL and run the main loop - gobject.timeout_add(battery.poll_interval, lambda: poll_battery(mainloop)) + # try using active callback on this battery + if not battery.use_callback(lambda: poll_battery(mainloop)): + # if not possible, poll the battery every poll_interval milliseconds + gobject.timeout_add(battery.poll_interval, lambda: poll_battery(mainloop)) + + # Run the main loop try: mainloop.run() except KeyboardInterrupt: diff --git a/etc/dbus-serialbattery/dbushelper.py b/etc/dbus-serialbattery/dbushelper.py index 0cca3187..5e47fef4 100644 --- a/etc/dbus-serialbattery/dbushelper.py +++ b/etc/dbus-serialbattery/dbushelper.py @@ -2,7 +2,7 @@ import sys import os import platform -import dbus +import dbus # pyright: ignore[reportMissingImports] import traceback from time import time @@ -14,8 +14,10 @@ "/opt/victronenergy/dbus-systemcalc-py/ext/velib_python", ), ) -from vedbus import VeDbusService # noqa: E402 -from settingsdevice import SettingsDevice # noqa: E402 +from vedbus import VeDbusService # noqa: E402 # pyright: ignore[reportMissingImports] +from settingsdevice import ( # noqa: E402 # pyright: ignore[reportMissingImports] + SettingsDevice, +) from utils import logger, publish_config_variables # noqa: E402 import utils # noqa: E402 @@ -33,7 +35,7 @@ def __init__(self, battery): self.battery = battery self.instance = 1 self.settings = None - self.error_count = 0 + self.error = {"count": 0, "timestamp_first": None, "timestamp_last": None} self.block_because_disconnect = False self._dbusservice = VeDbusService( "com.victronenergy.battery." @@ -54,32 +56,6 @@ def setup_instance(self): 0, 0, ], - # 'CellVoltageMin': [path + '/CellVoltageMin', 2.8, 0.0, 5.0], - # 'CellVoltageMax': [path + '/CellVoltageMax', 3.45, 0.0, 5.0], - # 'CellVoltageFloat': [path + '/CellVoltageFloat', 3.35, 0.0, 5.0], - # 'VoltageMaxTime': [path + '/VoltageMaxTime', 900, 0, 0], - # 'VoltageResetSocLimit': [path + '/VoltageResetSocLimit', 90, 0, 100], - # 'MaxChargeCurrent': [path + '/MaxCurrentCharge', 5, 0.0, 500], - # 'MaxDischargeCurrent': [path + '/MaxCurrentDischarge', 7, 0.0, 500], - # 'AllowDynamicChargeCurrent': [path + '/AllowDynamicChargeCurrent', 1, 0, 1], - # 'AllowDynamicDischargeCurrent': [path + '/AllowDynamicDischargeCurrent', 1, 0, 1], - # 'AllowDynamicChargeVoltage': [path + '/AllowDynamicChargeVoltage', 0, 0, 1], - # 'SocLowWarning': [path + '/SocLowWarning', 20, 0, 100], - # 'SocLowAlarm': [path + '/SocLowAlarm', 10, 0, 100], - # 'Capacity': [path + '/Capacity', '', 0, 500], - # 'EnableInvertedCurrent': [path + '/EnableInvertedCurrent', 0, 0, 1], - # 'CCMSocLimitCharge1': [path + '/CCMSocLimitCharge1', 98, 0, 100], - # 'CCMSocLimitCharge2': [path + '/CCMSocLimitCharge2', 95, 0, 100], - # 'CCMSocLimitCharge3': [path + '/CCMSocLimitCharge3', 91, 0, 100], - # 'CCMSocLimitDischarge1': [path + '/CCMSocLimitDischarge1', 10, 0, 100], - # 'CCMSocLimitDischarge2': [path + '/CCMSocLimitDischarge2', 20, 0, 100], - # 'CCMSocLimitDischarge3': [path + '/CCMSocLimitDischarge3', 30, 0, 100], - # 'CCMCurrentLimitCharge1': [path + '/CCMCurrentLimitCharge1', 5, 0, 100], - # 'CCMCurrentLimitCharge2': [path + '/CCMCurrentLimitCharge2', '', 0, 100], - # 'CCMCurrentLimitCharge3': [path + '/CCMCurrentLimitCharge3', '', 0, 100], - # 'CCMCurrentLimitDischarge1': [path + '/CCMCurrentLimitDischarge1', 5, 0, 100], - # 'CCMCurrentLimitDischarge2': [path + '/CCMCurrentLimitDischarge2', '', 0, 100], - # 'CCMCurrentLimitDischarge3': [path + '/CCMCurrentLimitDischarge3', '', 0, 100], } self.settings = SettingsDevice(get_bus(), settings, self.handle_changed_setting) @@ -123,10 +99,13 @@ def setup_vedbus(self): self._dbusservice.add_path("/HardwareVersion", self.battery.hardware_version) self._dbusservice.add_path("/Connected", 1) self._dbusservice.add_path( - "/CustomName", self.battery.custom_name(), writeable=True + "/CustomName", + self.battery.custom_name(), + writeable=True, + onchangecallback=self.battery.custom_name_callback, ) self._dbusservice.add_path( - "/Serial", self.battery.unique_identifier, writeable=True + "/Serial", self.battery.unique_identifier(), writeable=True ) self._dbusservice.add_path( "/DeviceName", self.battery.custom_field, writeable=True @@ -156,6 +135,7 @@ def setup_vedbus(self): ) self._dbusservice.add_path("/Info/ChargeMode", None, writeable=True) + self._dbusservice.add_path("/Info/ChargeModeDebug", None, writeable=True) self._dbusservice.add_path("/Info/ChargeLimitation", None, writeable=True) self._dbusservice.add_path("/Info/DischargeLimitation", None, writeable=True) @@ -230,9 +210,13 @@ def setup_vedbus(self): self._dbusservice.add_path("/System/MaxTemperatureCellId", None, writeable=True) self._dbusservice.add_path("/System/MOSTemperature", None, writeable=True) self._dbusservice.add_path("/System/Temperature1", None, writeable=True) + self._dbusservice.add_path("/System/Temperature1Name", None, writeable=True) self._dbusservice.add_path("/System/Temperature2", None, writeable=True) + self._dbusservice.add_path("/System/Temperature2Name", None, writeable=True) self._dbusservice.add_path("/System/Temperature3", None, writeable=True) + self._dbusservice.add_path("/System/Temperature3Name", None, writeable=True) self._dbusservice.add_path("/System/Temperature4", None, writeable=True) + self._dbusservice.add_path("/System/Temperature4Name", None, writeable=True) self._dbusservice.add_path( "/System/MaxCellVoltage", None, @@ -331,6 +315,12 @@ def setup_vedbus(self): # Create TimeToGo item if utils.TIME_TO_GO_ENABLE: self._dbusservice.add_path("/TimeToGo", None, writeable=True) + self._dbusservice.add_path( + "/CurrentAvg", + None, + writeable=True, + gettextcallback=lambda p, v: "{:0.2f}A".format(v), + ) # Create TimeToSoc items if len(utils.TIME_TO_SOC_POINTS) > 0: @@ -358,9 +348,10 @@ def publish_battery(self, loop): # This is called every battery.poll_interval milli second as set up per battery type to read and update the data try: # Call the battery's refresh_data function - success = self.battery.refresh_data() - if success: - self.error_count = 0 + result = self.battery.refresh_data() + if result: + # reset error variables + self.error["count"] = 0 self.battery.online = True # unblock charge/discharge, if it was blocked when battery went offline @@ -368,9 +359,18 @@ def publish_battery(self, loop): self.block_because_disconnect = False else: - self.error_count += 1 - # If the battery is offline for more than 10 polls (polled every second for most batteries) - if self.error_count >= 10: + # update error variables + if self.error["count"] == 0: + self.error["timestamp_first"] = int(time()) + self.error["timestamp_last"] = int(time()) + self.error["count"] += 1 + + time_since_first_error = ( + self.error["timestamp_last"] - self.error["timestamp_first"] + ) + + # if the battery did not update in 10 second, it's assumed to be offline + if time_since_first_error >= 10: self.battery.online = False self.battery.init_values() @@ -378,8 +378,8 @@ def publish_battery(self, loop): if utils.BLOCK_ON_DISCONNECT: self.block_because_disconnect = True - # Has it completely failed - if self.error_count >= 60: + # if the battery did not update in 60 second, it's assumed to be completely failed + if time_since_first_error >= 60: loop.quit() # This is to mannage CVCL @@ -473,12 +473,20 @@ def publish_dbus(self): ] = self.battery.get_max_temp_id() self._dbusservice["/System/MOSTemperature"] = self.battery.get_mos_temp() self._dbusservice["/System/Temperature1"] = self.battery.temp1 + self._dbusservice["/System/Temperature1Name"] = utils.TEMP_1_NAME self._dbusservice["/System/Temperature2"] = self.battery.temp2 + self._dbusservice["/System/Temperature2Name"] = utils.TEMP_2_NAME self._dbusservice["/System/Temperature3"] = self.battery.temp3 + self._dbusservice["/System/Temperature3Name"] = utils.TEMP_3_NAME self._dbusservice["/System/Temperature4"] = self.battery.temp4 + self._dbusservice["/System/Temperature4Name"] = utils.TEMP_4_NAME # Voltage control - self._dbusservice["/Info/MaxChargeVoltage"] = self.battery.control_voltage + self._dbusservice["/Info/MaxChargeVoltage"] = ( + round(self.battery.control_voltage + utils.VOLTAGE_DROP, 2) + if self.battery.control_voltage is not None + else None + ) # Charge control self._dbusservice[ @@ -490,6 +498,7 @@ def publish_dbus(self): # Voltage and charge control info self._dbusservice["/Info/ChargeMode"] = self.battery.charge_mode + self._dbusservice["/Info/ChargeModeDebug"] = self.battery.charge_mode_debug self._dbusservice["/Info/ChargeLimitation"] = self.battery.charge_limitation self._dbusservice[ "/Info/DischargeLimitation" @@ -511,7 +520,15 @@ def publish_dbus(self): self._dbusservice[ "/Alarms/LowCellVoltage" ] = self.battery.protection.voltage_cell_low - self._dbusservice["/Alarms/HighVoltage"] = self.battery.protection.voltage_high + # disable high voltage warning temporarly, if loading to bulk voltage and bulk voltage reached is 30 minutes ago + self._dbusservice["/Alarms/HighVoltage"] = ( + self.battery.protection.voltage_high + if ( + self.battery.soc_reset_requested is False + and self.battery.soc_reset_last_reached < int(time()) - (60 * 30) + ) + else 0 + ) self._dbusservice["/Alarms/LowSoc"] = self.battery.protection.soc_low self._dbusservice[ "/Alarms/HighChargeCurrent" @@ -575,6 +592,30 @@ def publish_dbus(self): # Update TimeToGo and/or TimeToSoC try: + # calculate current average for the last 300 cycles + # if Time-To-Go or Time-To-SoC is enabled + if utils.TIME_TO_GO_ENABLE or len(utils.TIME_TO_SOC_POINTS) > 0: + if self.battery.current is not None: + self.battery.current_avg_lst.append(self.battery.current) + + # delete oldest value + if len(self.battery.current_avg_lst) > 300: + del self.battery.current_avg_lst[0] + + """ + logger.info( + str(self.battery.capacity) + + " - " + + str(utils.TIME_TO_GO_ENABLE) + + " - " + + str(len(utils.TIME_TO_SOC_POINTS)) + + " - " + + str(int(time()) - self.battery.time_to_soc_update) + + " - " + + str(utils.TIME_TO_SOC_RECALCULATE_EVERY) + ) + """ + if ( self.battery.capacity is not None and (utils.TIME_TO_GO_ENABLE or len(utils.TIME_TO_SOC_POINTS) > 0) @@ -584,22 +625,34 @@ def publish_dbus(self): ) ): self.battery.time_to_soc_update = int(time()) + + self.battery.current_avg = round( + sum(self.battery.current_avg_lst) + / len(self.battery.current_avg_lst), + 2, + ) + + self._dbusservice["/CurrentAvg"] = self.battery.current_avg + crntPrctPerSec = ( - abs(self.battery.current / (self.battery.capacity / 100)) / 3600 + abs(self.battery.current_avg / (self.battery.capacity / 100)) / 3600 ) # Update TimeToGo item - if utils.TIME_TO_GO_ENABLE: + if utils.TIME_TO_GO_ENABLE and crntPrctPerSec is not None: # Update TimeToGo item, has to be a positive int since it's used from dbus-systemcalc-py + time_to_go = self.battery.get_timeToSoc( + # switch value depending on charging/discharging + utils.SOC_LOW_WARNING if self.battery.current_avg < 0 else 100, + crntPrctPerSec, + True, + ) + + # Check that time_to_go is not None and current is not near zero self._dbusservice["/TimeToGo"] = ( - abs( - int( - self.battery.get_timeToSoc( - utils.SOC_LOW_WARNING, crntPrctPerSec, True - ) - ) - ) - if self.battery.current + abs(int(time_to_go)) + if time_to_go is not None + and abs(self.battery.current_avg) > 0.1 else None ) @@ -608,11 +661,17 @@ def publish_dbus(self): for num in utils.TIME_TO_SOC_POINTS: self._dbusservice["/TimeToSoC/" + str(num)] = ( self.battery.get_timeToSoc(num, crntPrctPerSec) - if self.battery.current + if self.battery.current_avg else None ) except Exception: + exception_type, exception_object, exception_traceback = sys.exc_info() + file = exception_traceback.tb_frame.f_code.co_filename + line = exception_traceback.tb_lineno + logger.error( + f"Exception occurred: {repr(exception_object)} of type {exception_type} in {file} line #{line}" + ) pass if self.battery.soc is not None: diff --git a/etc/dbus-serialbattery/disable.sh b/etc/dbus-serialbattery/disable.sh index f1902881..3beacfad 100755 --- a/etc/dbus-serialbattery/disable.sh +++ b/etc/dbus-serialbattery/disable.sh @@ -8,22 +8,35 @@ bash /opt/victronenergy/swupdate-scripts/remount-rw.sh # remove driver from serial starter rm -f /data/conf/serial-starter.d/dbus-serialbattery.conf +# remove serial-starter.d if empty +rmdir /data/conf/serial-starter.d >/dev/null 2>&1 # kill serial starter, to reload changes pkill -f "/opt/victronenergy/serial-starter/serial-starter.sh" # remove services rm -rf /service/dbus-serialbattery.* rm -rf /service/dbus-blebattery.* +rm -rf /service/dbus-canbattery.* # kill driver, if running -pkill -f "python .*/dbus-serialbattery.py" -pkill -f "blebattery" +# serial +pkill -f "supervise dbus-serialbattery.*" +pkill -f "multilog .* /var/log/dbus-serialbattery.*" +pkill -f "python .*/dbus-serialbattery.py /dev/tty.*" +# bluetooth +pkill -f "supervise dbus-blebattery.*" +pkill -f "multilog .* /var/log/dbus-blebattery.*" +pkill -f "python .*/dbus-serialbattery.py .*_Ble.*" +# can +pkill -f "supervise dbus-canbattery.*" +pkill -f "multilog .* /var/log/dbus-canbattery.*" +pkill -f "python .*/dbus-serialbattery.py can.*" # remove install script from rc.local sed -i "/bash \/data\/etc\/dbus-serialbattery\/reinstall-local.sh/d" /data/rc.local # remove cronjob -sed -i "/5 0,12 \* \* \* \/etc\/init.d\/bluetooth restart/d" /var/spool/cron/root +sed -i "/5 0,12 \* \* \* \/etc\/init.d\/bluetooth restart/d" /var/spool/cron/root >/dev/null 2>&1 ### needed for upgrading from older versions | start ### diff --git a/etc/dbus-serialbattery/qml/PageBattery.qml b/etc/dbus-serialbattery/qml/PageBattery.qml index 286ce74c..ea7b44f5 100644 --- a/etc/dbus-serialbattery/qml/PageBattery.qml +++ b/etc/dbus-serialbattery/qml/PageBattery.qml @@ -94,6 +94,12 @@ MbPage { ] } + MbItemValue { + description: qsTr("Current (last 5 minutes avg.)") + item.bind: service.path("/CurrentAvg") + show: item.seen + } + MbItemValue { id: soc diff --git a/etc/dbus-serialbattery/qml/PageBatteryParameters.qml b/etc/dbus-serialbattery/qml/PageBatteryParameters.qml index b95161a3..c402e446 100644 --- a/etc/dbus-serialbattery/qml/PageBatteryParameters.qml +++ b/etc/dbus-serialbattery/qml/PageBatteryParameters.qml @@ -6,6 +6,8 @@ MbPage { property variant service + property VBusItem chargeModeDebug: VBusItem { bind: service.path("/Info/ChargeModeDebug") } + model: VisibleItemModel { MbItemValue { @@ -14,6 +16,13 @@ MbPage { show: item.valid } + // show debug informations + MbItemText { + text: chargeModeDebug.value + wrapMode: Text.WordWrap + show: chargeModeDebug.value != "" + } + MbItemValue { description: qsTr("Charge Voltage Limit (CVL)") item.bind: service.path("/Info/MaxChargeVoltage") diff --git a/etc/dbus-serialbattery/reinstall-local.sh b/etc/dbus-serialbattery/reinstall-local.sh index a518a100..ffb4b25a 100755 --- a/etc/dbus-serialbattery/reinstall-local.sh +++ b/etc/dbus-serialbattery/reinstall-local.sh @@ -3,8 +3,6 @@ # remove comment for easier troubleshooting #set -x -DRIVERNAME=dbus-serialbattery - # check if minimum required Venus OS is installed | start versionRequired="v2.90" @@ -66,15 +64,15 @@ fi bash /opt/victronenergy/swupdate-scripts/remount-rw.sh # install -rm -rf /opt/victronenergy/service/$DRIVERNAME -rm -rf /opt/victronenergy/service-templates/$DRIVERNAME -rm -rf /opt/victronenergy/$DRIVERNAME -mkdir /opt/victronenergy/$DRIVERNAME -mkdir /opt/victronenergy/$DRIVERNAME/bms -cp -f /data/etc/$DRIVERNAME/* /opt/victronenergy/$DRIVERNAME &>/dev/null -cp -f /data/etc/$DRIVERNAME/bms/* /opt/victronenergy/$DRIVERNAME/bms &>/dev/null -cp -rf /data/etc/$DRIVERNAME/service /opt/victronenergy/service-templates/$DRIVERNAME -bash /data/etc/$DRIVERNAME/install-qml.sh +rm -rf /opt/victronenergy/service/dbus-serialbattery +rm -rf /opt/victronenergy/service-templates/dbus-serialbattery +rm -rf /opt/victronenergy/dbus-serialbattery +mkdir /opt/victronenergy/dbus-serialbattery +mkdir /opt/victronenergy/dbus-serialbattery/bms +cp -f /data/etc/dbus-serialbattery/* /opt/victronenergy/dbus-serialbattery &>/dev/null +cp -f /data/etc/dbus-serialbattery/bms/* /opt/victronenergy/dbus-serialbattery/bms &>/dev/null +cp -rf /data/etc/dbus-serialbattery/service /opt/victronenergy/service-templates/dbus-serialbattery +bash /data/etc/dbus-serialbattery/install-qml.sh # check if serial-starter.d was deleted serialstarter_path="/data/conf/serial-starter.d" @@ -105,10 +103,10 @@ if [ ! -f "$filename" ]; then echo "#!/bin/bash" > "$filename" chmod 755 "$filename" fi -grep -qxF "bash /data/etc/$DRIVERNAME/reinstall-local.sh" $filename || echo "bash /data/etc/$DRIVERNAME/reinstall-local.sh" >> $filename +grep -qxF "bash /data/etc/dbus-serialbattery/reinstall-local.sh" $filename || echo "bash /data/etc/dbus-serialbattery/reinstall-local.sh" >> $filename # add empty config.ini, if it does not exist to make it easier for users to add custom settings -filename="/data/etc/$DRIVERNAME/config.ini" +filename="/data/etc/dbus-serialbattery/config.ini" if [ ! -f "$filename" ]; then { echo "[DEFAULT]" @@ -117,13 +115,18 @@ if [ ! -f "$filename" ]; then echo "; and insert them below to persist future driver updates." echo echo "; Example (remove the semicolon \";\" to uncomment and activate the value/setting):" - echo "; MAX_BATTERY_CURRENT = 50.0" + echo "; MAX_BATTERY_CHARGE_CURRENT = 50.0" echo "; MAX_BATTERY_DISCHARGE_CURRENT = 60.0" echo echo } > $filename fi +# kill driver, if running. It gets restarted by the service daemon +pkill -f "supervise dbus-serialbattery.*" +pkill -f "multilog .* /var/log/dbus-serialbattery.*" +pkill -f "python .*/dbus-serialbattery.py /dev/tty.*" + ### BLUETOOTH PART | START ### @@ -141,32 +144,93 @@ IFS="," read -r -a bms_array <<< "$bluetooth_bms_clean" #declare -p bms_array # readarray -td, bms_array <<< "$bluetooth_bms_clean,"; unset 'bms_array[-1]'; declare -p bms_array; -length=${#bms_array[@]} -# echo $length +bluetooth_length=${#bms_array[@]} +# echo $bluetooth_length + +# stop all dbus-blebattery services, if at least one exists +if [ -d "/service/dbus-blebattery.0" ]; then + svc -u /service/dbus-blebattery.* + + # always remove existing blebattery services to cleanup + rm -rf /service/dbus-blebattery.* + + # kill all blebattery processes that remain + pkill -f "supervise dbus-blebattery.*" + pkill -f "multilog .* /var/log/dbus-blebattery.*" + pkill -f "python .*/dbus-serialbattery.py .*_Ble" + + # kill opened bluetoothctl processes + pkill -f "^bluetoothctl " +fi -# always remove existing blebattery services to cleanup -rm -rf /service/dbus-blebattery.* -# kill all blebattery processes -pkill -f "blebattery" +if [ "$bluetooth_length" -gt 0 ]; then -if [ "$length" -gt 0 ]; then + echo + echo "Found $bluetooth_length Bluetooth BMS in the config file!" + echo - echo "Found $length Bluetooth BMS in the config file!" - echo "" + /etc/init.d/bluetooth stop + echo # install required packages # TO DO: Check first if packages are already installed - echo "Installing required packages..." + echo "Installing required packages to use Bluetooth connection..." + + # dbus-fast: skip compiling/building the wheel + # else weak system crash and are not able to install it, + # see https://github.com/Bluetooth-Devices/dbus-fast/issues/237 + # and https://github.com/Louisvdw/dbus-serialbattery/issues/785 + export SKIP_CYTHON=false + opkg update opkg install python3-misc python3-pip + + echo pip3 install bleak - # setup cronjob to restart Bluetooth - grep -qxF "5 0,12 * * * /etc/init.d/bluetooth restart" /var/spool/cron/root || echo "5 0,12 * * * /etc/init.d/bluetooth restart" >> /var/spool/cron/root + # # ONLY FOR TESTING if there are version issues + # echo + # echo "Available bleak versions:" + # curl --silent https://api.github.com/repos/hbldh/bleak/releases | grep '"name": "v' | sed "s/ \"name\": \"v//g" | sed "s/\",//g" + # echo + # read -r -p "Specify the bleak version to install: " bleak_version + # pip3 install bleak=="$bleak_version" + # echo + # echo + # echo "Available dbus-fast versions:" + # curl --silent https://api.github.com/repos/Bluetooth-Devices/dbus-fast/releases | grep '"name": "v' | sed "s/ \"name\": \"v//g" | sed "s/\",//g" + # echo + # read -r -p "Specify the dbus-fast version to install: " dbus_fast_version + # pip3 install dbus-fast=="$dbus_fast_version" + # echo + + echo "done." + echo + + /etc/init.d/bluetooth start + echo # function to install ble battery install_blebattery_service() { + if [ -z "$1" ]; then + echo "ERROR: BMS unique number is empty. Aborting installation." + echo + exit 1 + fi + if [ -z "$2" ]; then + echo "ERROR: BMS type for battery $1 is empty. Aborting installation." + echo + exit 1 + fi + if [ -z "$3" ]; then + echo "ERROR: BMS MAC address for battery $1 with BMS type $2 is empty. Aborting installation." + echo + exit 1 + fi + + echo "Installing \"$2\" with MAC address \"$3\" as dbus-blebattery.$1" + mkdir -p "/service/dbus-blebattery.$1/log" { echo "#!/bin/sh" @@ -177,36 +241,175 @@ if [ "$length" -gt 0 ]; then { echo "#!/bin/sh" echo "exec 2>&1" + echo "echo" + echo "echo \"INFO:Bluetooth details\"" + # close all open connections, else the driver can't connect echo "bluetoothctl disconnect $3" + + # enable bluetoothctl scan in background to display signal strength (RSSI), else it's missing + echo "bluetoothctl scan on | grep \"$3\" | grep \"RSSI\" &" + # with multiple Bluetooth BMS one scan for all should be enough. Check if that can be changed globally, maybe with a cronjob after reboot? + # echo "bluetoothctl scan on > /dev/null &" + + # wait 5 seconds to finish the scan + echo "sleep 5" + # display some Bluetooth device details + echo "bluetoothctl info $3 | grep -E \"Device|Alias|Pair|Trusted|Blocked|Connected|RSSI|Power\"" + echo "echo" echo "python /opt/victronenergy/dbus-serialbattery/dbus-serialbattery.py $2 $3" + echo "pkill -f \"bluetoothctl scan on\"" } > "/service/dbus-blebattery.$1/run" chmod 755 "/service/dbus-blebattery.$1/run" } - echo "Packages installed." - echo "" - + # Example # install_blebattery_service 0 Jkbms_Ble C8:47:8C:00:00:00 # install_blebattery_service 1 Jkbms_Ble C8:47:8C:00:00:11 - for (( i=0; i seems that it's not needed anymore + # grep -qxF "5 0,12 * * * /etc/init.d/bluetooth restart" /var/spool/cron/root || echo "5 0,12 * * * /etc/init.d/bluetooth restart" >> /var/spool/cron/root + + # remove cronjob + sed -i "/5 0,12 \* \* \* \/etc\/init.d\/bluetooth restart/d" /var/spool/cron/root >/dev/null 2>&1 + else # remove cronjob - sed -i "/5 0,12 \* \* \* \/etc\/init.d\/bluetooth restart/d" /var/spool/cron/root + sed -i "/5 0,12 \* \* \* \/etc\/init.d\/bluetooth restart/d" /var/spool/cron/root >/dev/null 2>&1 + echo echo "No Bluetooth battery configuration found in \"/data/etc/dbus-serialbattery/config.ini\"." echo "You can ignore this, if you are using only a serial connection." + echo fi ### BLUETOOTH PART | END ### + +### CAN PART | START ### + +# get CAN port(s) from config file +can_port=$(awk -F "=" '/^CAN_PORT/ {print $2}' /data/etc/dbus-serialbattery/config.ini) +#echo $can_port + +# clear whitespaces +can_port_clean="$(echo $can_port | sed 's/\s*,\s*/,/g')" +#echo $can_port_clean + +# split into array +IFS="," read -r -a can_array <<< "$can_port_clean" +#declare -p can_array +# readarray -td, can_array <<< "$can_port_clean,"; unset 'can_array[-1]'; declare -p can_array; + +can_lenght=${#can_array[@]} +# echo $can_lenght + +# stop all dbus-canbattery services, if at least one exists +if [ -d "/service/dbus-canbattery.0" ]; then + svc -u /service/dbus-canbattery.* + + # always remove existing canbattery services to cleanup + rm -rf /service/dbus-canbattery.* + + # kill all canbattery processes that remain + pkill -f "supervise dbus-canbattery.*" + pkill -f "multilog .* /var/log/dbus-canbattery.*" + pkill -f "python .*/dbus-serialbattery.py .*_Ble" + + # kill opened bluetoothctl processes + pkill -f "^bluetoothctl " +fi + + +if [ "$can_lenght" -gt 0 ]; then + + echo + echo "Found $can_lenght CAN port(s) in the config file!" + echo + + # install required packages + # TO DO: Check first if packages are already installed + echo "Installing required packages to use CAN connection..." + + opkg update + opkg install python3-misc python3-pip + + echo + pip3 install python-can + + echo "done." + echo + + # function to install can battery + install_canbattery_service() { + if [ -z "$1" ]; then + echo "ERROR: CAN port is empty. Aborting installation." + echo + exit 1 + fi + #if [ -z "$2" ]; then + # echo "ERROR: BMS type for can port $1 is empty. Aborting installation." + # echo + # exit 1 + #fi + + echo "Installing CAN port \"$1\" as dbus-canbattery.$1" + + mkdir -p "/service/dbus-canbattery.$1/log" + { + echo "#!/bin/sh" + echo "exec multilog t s25000 n4 /var/log/dbus-canbattery.$1" + } > "/service/dbus-canbattery.$1/log/run" + chmod 755 "/service/dbus-canbattery.$1/log/run" + + { + echo "#!/bin/sh" + echo "exec 2>&1" + echo "echo" + echo "python /opt/victronenergy/dbus-serialbattery/dbus-serialbattery.py $1" + } > "/service/dbus-canbattery.$1/run" + chmod 755 "/service/dbus-canbattery.$1/run" + } + + # Example + # install_canbattery_service can0 + # install_canbattery_service can9 + + for (( i=0; i Bluetooth in the remote console/GUI to prevent reconnects every minute." +echo " 2. Make sure to disable Bluetooth in \"Settings -> Bluetooth\" in the remote console/GUI to prevent reconnects every minute." echo echo " 3. Re-run \"/data/etc/dbus-serialbattery/reinstall-local.sh\", if the Bluetooth BMS were not added to the \"config.ini\" before." echo @@ -247,6 +445,15 @@ echo " ATTENTION!" echo " If you changed the default connection PIN of your BMS, then you have to pair the BMS first using OS tools like the \"bluetoothctl\"." echo " See https://wiki.debian.org/BluetoothUser#Using_bluetoothctl for more details." echo +echo "CAN battery connection: There are a few more steps to complete installation." +echo +echo " 1. Add your CAN port to the config file \"/data/etc/dbus-serialbattery/config.ini\"." +echo " Check the default config file \"/data/etc/dbus-serialbattery/config.default.ini\" for more informations." +echo +echo " 2. Make sure to select a profile with 250 kbit/s in \"Settings -> Services -> VE.Can port -> CAN-bus profile\" in the remote console/GUI." +echo +echo " 3. Re-run \"/data/etc/dbus-serialbattery/reinstall-local.sh\", if the CAN port was not added to the \"config.ini\" before." +echo echo "CUSTOM SETTINGS: If you want to add custom settings, then check the settings you want to change in \"/data/etc/dbus-serialbattery/config.default.ini\"" echo " and add them to \"/data/etc/dbus-serialbattery/config.ini\" to persist future driver updates." echo diff --git a/etc/dbus-serialbattery/uninstall.sh b/etc/dbus-serialbattery/uninstall.sh index 94100a9d..9bec2518 100755 --- a/etc/dbus-serialbattery/uninstall.sh +++ b/etc/dbus-serialbattery/uninstall.sh @@ -19,12 +19,13 @@ rm -rf /opt/victronenergy/dbus-serialbattery # uninstall modules -read -r -p "Do you want to uninstall bleak, python3-pip and python3-modules? If you don't know just press enter. [y/N] " response +read -r -p "Do you want to uninstall bleak, python-can, python3-pip and python3-modules? If you don't know just press enter. [y/N] " response echo response=${response,,} # tolower if [[ $response =~ ^(y) ]]; then echo "Uninstalling modules..." pip3 uninstall bleak + pip3 uninstall python-can opkg remove python3-pip python3-modules echo "done." echo diff --git a/etc/dbus-serialbattery/utils.py b/etc/dbus-serialbattery/utils.py index e5f22fa5..df4ce2ab 100644 --- a/etc/dbus-serialbattery/utils.py +++ b/etc/dbus-serialbattery/utils.py @@ -13,12 +13,14 @@ # Logging logging.basicConfig() logger = logging.getLogger("SerialBattery") -logger.setLevel(logging.INFO) + +PATH_CONFIG_DEFAULT = "config.default.ini" +PATH_CONFIG_USER = "config.ini" config = configparser.ConfigParser() path = Path(__file__).parents[0] -default_config_file_path = path.joinpath("config.default.ini").absolute().__str__() -custom_config_file_path = path.joinpath("config.ini").absolute().__str__() +default_config_file_path = path.joinpath(PATH_CONFIG_DEFAULT).absolute().__str__() +custom_config_file_path = path.joinpath(PATH_CONFIG_USER).absolute().__str__() config.read([default_config_file_path, custom_config_file_path]) @@ -27,18 +29,30 @@ def _get_list_from_config( ) -> List[Any]: rawList = config[group][option].split(",") return list( - map(mapper, [item for item in rawList if item != "" and item is not None]) + map( + mapper, + [item.strip() for item in rawList if item != "" and item is not None], + ) ) -# battery types -# if not specified: baud = 9600 - -# Constants - Need to dynamically get them in future -DRIVER_VERSION = "1.0.20230531" +# Constants +DRIVER_VERSION = "1.0.20231117dev" zero_char = chr(48) degree_sign = "\N{DEGREE SIGN}" +# get logging level from config file +if config["DEFAULT"]["LOGGING"].upper() == "ERROR": + logger.setLevel(logging.ERROR) +elif config["DEFAULT"]["LOGGING"].upper() == "WARNING": + logger.setLevel(logging.WARNING) +elif config["DEFAULT"]["LOGGING"].upper() == "DEBUG": + logger.setLevel(logging.DEBUG) +else: + logger.setLevel(logging.INFO) + +# save config values to constants + # --------- Battery Current limits --------- MAX_BATTERY_CHARGE_CURRENT = float(config["DEFAULT"]["MAX_BATTERY_CHARGE_CURRENT"]) MAX_BATTERY_DISCHARGE_CURRENT = float( @@ -46,100 +60,66 @@ def _get_list_from_config( ) # --------- Cell Voltages --------- -# Description: Cell min/max voltages which are used to calculate the min/max battery voltage -# Example: 16 cells * 3.45V/cell = 55.2V max charge voltage. 16 cells * 2.90V = 46.4V min discharge voltage MIN_CELL_VOLTAGE = float(config["DEFAULT"]["MIN_CELL_VOLTAGE"]) MAX_CELL_VOLTAGE = float(config["DEFAULT"]["MAX_CELL_VOLTAGE"]) -# Max voltage can seen as absorption voltage + FLOAT_CELL_VOLTAGE = float(config["DEFAULT"]["FLOAT_CELL_VOLTAGE"]) +if FLOAT_CELL_VOLTAGE > MAX_CELL_VOLTAGE: + FLOAT_CELL_VOLTAGE = MAX_CELL_VOLTAGE + logger.error( + ">>> ERROR: FLOAT_CELL_VOLTAGE is set to a value greater than MAX_CELL_VOLTAGE. Please check the configuration." + ) +if FLOAT_CELL_VOLTAGE < MIN_CELL_VOLTAGE: + FLOAT_CELL_VOLTAGE = MIN_CELL_VOLTAGE + logger.error( + ">>> ERROR: FLOAT_CELL_VOLTAGE is set to a value less than MAX_CELL_VOLTAGE. Please check the configuration." + ) + +SOC_RESET_VOLTAGE = float(config["DEFAULT"]["SOC_RESET_VOLTAGE"]) +if SOC_RESET_VOLTAGE < MAX_CELL_VOLTAGE: + SOC_RESET_VOLTAGE = MAX_CELL_VOLTAGE + logger.error( + ">>> ERROR: SOC_RESET_VOLTAGE is set to a value less than MAX_CELL_VOLTAGE. Please check the configuration." + ) +SOC_RESET_AFTER_DAYS = ( + int(config["DEFAULT"]["SOC_RESET_AFTER_DAYS"]) + if config["DEFAULT"]["SOC_RESET_AFTER_DAYS"] != "" + else False +) # --------- BMS disconnect behaviour --------- -# Description: Block charge and discharge when the communication to the BMS is lost. If you are removing the -# BMS on purpose, then you have to restart the driver/system to reset the block. -# False: Charge and discharge is not blocked on BMS communication loss -# True: Charge and discharge is blocked on BMS communication loss, it's unblocked when connection is established -# again or the driver/system is restarted BLOCK_ON_DISCONNECT = "True" == config["DEFAULT"]["BLOCK_ON_DISCONNECT"] # --------- Charge mode --------- -# Choose the mode for voltage / current limitations (True / False) -# False is a step mode: This is the default with limitations on hard boundary steps -# True is a linear mode: -# For CCL and DCL the values between the steps are calculated for smoother values (by WaldemarFech) -# For CVL max battery voltage is calculated dynamically in order that the max cell voltage is not exceeded LINEAR_LIMITATION_ENABLE = "True" == config["DEFAULT"]["LINEAR_LIMITATION_ENABLE"] - -# Specify in seconds how often the penalty should be recalculated LINEAR_RECALCULATION_EVERY = int(config["DEFAULT"]["LINEAR_RECALCULATION_EVERY"]) -# Specify in percent when the linear values should be recalculated immediately -# Example: 5 for a immediate change, when the value changes by more than 5% LINEAR_RECALCULATION_ON_PERC_CHANGE = int( config["DEFAULT"]["LINEAR_RECALCULATION_ON_PERC_CHANGE"] ) - # --------- Charge Voltage limitation (affecting CVL) --------- -# Description: Limit max charging voltage (MAX_CELL_VOLTAGE * cell count), switch from max voltage to float -# voltage (FLOAT_CELL_VOLTAGE * cell count) and back -# False: Max charging voltage is always kept -# True: Max charging voltage is reduced based on charge mode -# Step mode: After max voltage is reached for MAX_VOLTAGE_TIME_SEC it switches to float voltage. After -# SoC is below SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT it switches back to max voltage. -# Linear mode: After max voltage is reachend and cell voltage difference is smaller or equal to -# CELL_VOLTAGE_DIFF_KEEP_MAX_VOLTAGE_UNTIL it switches to float voltage after 300 (fixed) -# additional seconds. -# After cell voltage difference is greater or equal to CELL_VOLTAGE_DIFF_TO_RESET_VOLTAGE_LIMIT -# OR -# SoC is below SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT -# it switches back to max voltage. -# Example: The battery reached max voltage of 55.2V and hold it for 900 seconds, the the CVL is switched to -# float voltage of 53.6V to don't stress the batteries. Allow max voltage of 55.2V again, if SoC is -# once below 90% -# OR -# The battery reached max voltage of 55.2V and the max cell difference is 0.010V, then switch to float -# voltage of 53.6V after 300 additional seconds to don't stress the batteries. Allow max voltage of -# 55.2V again if max cell difference is above 0.080V or SoC below 90%. -# Charge voltage control management enable (True/False). CVCM_ENABLE = "True" == config["DEFAULT"]["CVCM_ENABLE"] - -# -- CVL reset based on cell voltage diff (linear mode) -# Specify cell voltage diff where CVL limit is kept until diff is equal or lower CELL_VOLTAGE_DIFF_KEEP_MAX_VOLTAGE_UNTIL = float( config["DEFAULT"]["CELL_VOLTAGE_DIFF_KEEP_MAX_VOLTAGE_UNTIL"] ) -# Specify cell voltage diff where CVL limit is reset to max voltage, if value get above -# the cells are considered as imbalanced, if the cell diff exceeds 5% of the nominal cell voltage -# e.g. 3.2 V * 5 / 100 = 0.160 V CELL_VOLTAGE_DIFF_TO_RESET_VOLTAGE_LIMIT = float( config["DEFAULT"]["CELL_VOLTAGE_DIFF_TO_RESET_VOLTAGE_LIMIT"] ) -# -- CVL Reset based on SoC option -# Specify how long the max voltage should be kept, if reached then switch to float voltage -MAX_VOLTAGE_TIME_SEC = float(config["DEFAULT"]["MAX_VOLTAGE_TIME_SEC"]) -# Specify SoC where CVL limit is reset to max voltage, if value gets below -SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT = float( +MAX_VOLTAGE_TIME_SEC = int(config["DEFAULT"]["MAX_VOLTAGE_TIME_SEC"]) +SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT = int( config["DEFAULT"]["SOC_LEVEL_TO_RESET_VOLTAGE_LIMIT"] ) - - -# --------- Cell Voltage Current limitation (affecting CCL/DCL) --------- -# Description: Maximal charge / discharge current will be in-/decreased depending on min and max cell voltages -# Example: 18 cells * 3.55V/cell = 63.9V max charge voltage -# 18 cells * 2.70V/cell = 48.6V min discharge voltage -# But in reality not all cells reach the same voltage at the same time. The (dis)charge current -# will be (in-/)decreased, if even ONE SINGLE BATTERY CELL reaches the limits - -# Charge current control management referring to cell-voltage enable (True/False). CCCM_CV_ENABLE = "True" == config["DEFAULT"]["CCCM_CV_ENABLE"] -# Discharge current control management referring to cell-voltage enable (True/False). DCCM_CV_ENABLE = "True" == config["DEFAULT"]["DCCM_CV_ENABLE"] -# Set steps to reduce battery current -# The current will be changed linear between those steps if LINEAR_LIMITATION_ENABLE is set to True CELL_VOLTAGES_WHILE_CHARGING = _get_list_from_config( "DEFAULT", "CELL_VOLTAGES_WHILE_CHARGING", lambda v: float(v) ) +if CELL_VOLTAGES_WHILE_CHARGING[0] < MAX_CELL_VOLTAGE: + logger.error( + ">>> ERROR: Maximum value of CELL_VOLTAGES_WHILE_CHARGING is set to a value lower than MAX_CELL_VOLTAGE. Please check the configuration." + ) MAX_CHARGE_CURRENT_CV = _get_list_from_config( "DEFAULT", "MAX_CHARGE_CURRENT_CV_FRACTION", @@ -149,24 +129,20 @@ def _get_list_from_config( CELL_VOLTAGES_WHILE_DISCHARGING = _get_list_from_config( "DEFAULT", "CELL_VOLTAGES_WHILE_DISCHARGING", lambda v: float(v) ) +if CELL_VOLTAGES_WHILE_DISCHARGING[0] > MIN_CELL_VOLTAGE: + logger.error( + ">>> ERROR: Minimum value of CELL_VOLTAGES_WHILE_DISCHARGING is set to a value greater than MIN_CELL_VOLTAGE. Please check the configuration." + ) MAX_DISCHARGE_CURRENT_CV = _get_list_from_config( "DEFAULT", "MAX_DISCHARGE_CURRENT_CV_FRACTION", lambda v: MAX_BATTERY_DISCHARGE_CURRENT * float(v), ) - # --------- Temperature limitation (affecting CCL/DCL) --------- -# Description: Maximal charge / discharge current will be in-/decreased depending on temperature -# Example: The temperature limit will be monitored to control the currents. If there are two temperature senors, -# then the worst case will be calculated and the more secure lower current will be set. -# Charge current control management referring to temperature enable (True/False). CCCM_T_ENABLE = "True" == config["DEFAULT"]["CCCM_T_ENABLE"] -# Charge current control management referring to temperature enable (True/False). DCCM_T_ENABLE = "True" == config["DEFAULT"]["DCCM_T_ENABLE"] -# Set steps to reduce battery current -# The current will be changed linear between those steps if LINEAR_LIMITATION_ENABLE is set to True TEMPERATURE_LIMITS_WHILE_CHARGING = _get_list_from_config( "DEFAULT", "TEMPERATURE_LIMITS_WHILE_CHARGING", lambda v: float(v) ) @@ -185,22 +161,14 @@ def _get_list_from_config( lambda v: MAX_BATTERY_DISCHARGE_CURRENT * float(v), ) - # --------- SOC limitation (affecting CCL/DCL) --------- -# Description: Maximal charge / discharge current will be increased / decreased depending on State of Charge, -# see CC_SOC_LIMIT1 etc. -# Example: The SoC limit will be monitored to control the currents. -# Charge current control management enable (True/False). CCCM_SOC_ENABLE = "True" == config["DEFAULT"]["CCCM_SOC_ENABLE"] -# Discharge current control management enable (True/False). DCCM_SOC_ENABLE = "True" == config["DEFAULT"]["DCCM_SOC_ENABLE"] -# Charge current soc limits CC_SOC_LIMIT1 = float(config["DEFAULT"]["CC_SOC_LIMIT1"]) CC_SOC_LIMIT2 = float(config["DEFAULT"]["CC_SOC_LIMIT2"]) CC_SOC_LIMIT3 = float(config["DEFAULT"]["CC_SOC_LIMIT3"]) -# Charge current limits CC_CURRENT_LIMIT1 = MAX_BATTERY_CHARGE_CURRENT * float( config["DEFAULT"]["CC_CURRENT_LIMIT1_FRACTION"] ) @@ -211,12 +179,10 @@ def _get_list_from_config( config["DEFAULT"]["CC_CURRENT_LIMIT3_FRACTION"] ) -# Discharge current soc limits DC_SOC_LIMIT1 = float(config["DEFAULT"]["DC_SOC_LIMIT1"]) DC_SOC_LIMIT2 = float(config["DEFAULT"]["DC_SOC_LIMIT2"]) DC_SOC_LIMIT3 = float(config["DEFAULT"]["DC_SOC_LIMIT3"]) -# Discharge current limits DC_CURRENT_LIMIT1 = MAX_BATTERY_DISCHARGE_CURRENT * float( config["DEFAULT"]["DC_CURRENT_LIMIT1_FRACTION"] ) @@ -227,96 +193,56 @@ def _get_list_from_config( config["DEFAULT"]["DC_CURRENT_LIMIT3_FRACTION"] ) - # --------- Time-To-Go --------- -# Description: Calculates the time to go shown in the GUI TIME_TO_GO_ENABLE = "True" == config["DEFAULT"]["TIME_TO_GO_ENABLE"] # --------- Time-To-Soc --------- -# Description: Calculates the time to a specific SoC -# Example: TIME_TO_SOC_POINTS = 50, 25, 15, 0 -# 6h 24m remaining until 50% SoC -# 17h 36m remaining until 25% SoC -# 22h 5m remaining until 15% SoC -# 28h 48m remaining until 0% SoC -# Set of SoC percentages to report on dbus and MQTT. The more you specify the more it will impact system performance. -# [Valid values 0-100, comma separated list. More that 20 intervals are not recommended] -# Example: TIME_TO_SOC_POINTS = 100, 95, 90, 85, 75, 50, 25, 20, 10, 0 -# Leave empty to disable TIME_TO_SOC_POINTS = _get_list_from_config( "DEFAULT", "TIME_TO_SOC_POINTS", lambda v: int(v) ) -# Specify TimeToSoc value type [Valid values 1, 2, 3] -# 1 Seconds -# 2 Time string d h m s -# 3 Both seconds and time string " [d h m s]" TIME_TO_SOC_VALUE_TYPE = int(config["DEFAULT"]["TIME_TO_SOC_VALUE_TYPE"]) -# Specify in seconds how often the TimeToSoc should be recalculated -# Minimum are 5 seconds to prevent CPU overload TIME_TO_SOC_RECALCULATE_EVERY = ( int(config["DEFAULT"]["TIME_TO_SOC_RECALCULATE_EVERY"]) if int(config["DEFAULT"]["TIME_TO_SOC_RECALCULATE_EVERY"]) > 5 else 5 ) -# Include TimeToSoC points when moving away from the SoC point [Valid values True, False] -# These will be as negative time. Disabling this improves performance slightly TIME_TO_SOC_INC_FROM = "True" == config["DEFAULT"]["TIME_TO_SOC_INC_FROM"] - # --------- Additional settings --------- -# Specify only one BMS type to load else leave empty to try to load all available -# -- Available BMS: -# Daly, Ecs, HeltecModbus, HLPdataBMS4S, Jkbms, Lifepower, LltJbd, Renogy, Seplos -# -- Available BMS, but disabled by default: -# https://louisvdw.github.io/dbus-serialbattery/general/install#how-to-enable-a-disabled-bms -# Ant, MNB, Sinowealth -BMS_TYPE = config["DEFAULT"]["BMS_TYPE"] - -# Publish the config settings to the dbus path "/Info/Config/" +BMS_TYPE = _get_list_from_config("DEFAULT", "BMS_TYPE", lambda v: str(v)) + +EXCLUDED_DEVICES = _get_list_from_config( + "DEFAULT", "EXCLUDED_DEVICES", lambda v: str(v) +) + +CUSTOM_BATTERY_NAMES = _get_list_from_config( + "DEFAULT", "CUSTOM_BATTERY_NAMES", lambda v: str(v) +) + +# Auto reset SoC +# If on, then SoC is reset to 100%, if the value switches from absorption to float voltage +# Currently only working for Daly BMS and JK BMS BLE +AUTO_RESET_SOC = "True" == config["DEFAULT"]["AUTO_RESET_SOC"] + PUBLISH_CONFIG_VALUES = int(config["DEFAULT"]["PUBLISH_CONFIG_VALUES"]) -# Select the format of cell data presented on dbus [Valid values 0,1,2,3] -# 0 Do not publish all the cells (only the min/max cell data as used by the default GX) -# 1 Format: /Voltages/Cell (also available for display on Remote Console) -# 2 Format: /Cell/#/Volts -# 3 Both formats 1 and 2 BATTERY_CELL_DATA_FORMAT = int(config["DEFAULT"]["BATTERY_CELL_DATA_FORMAT"]) -# Simulate Midpoint graph (True/False). MIDPOINT_ENABLE = "True" == config["DEFAULT"]["MIDPOINT_ENABLE"] -# Battery temperature -# Specifiy how the battery temperature is assembled -# 0 Get mean of temp sensor 1 and temp sensor 2 -# 1 Get only temp from temp sensor 1 -# 2 Get only temp from temp sensor 2 TEMP_BATTERY = int(config["DEFAULT"]["TEMP_BATTERY"]) -# Temperature sensor 1 name TEMP_1_NAME = config["DEFAULT"]["TEMP_1_NAME"] - -# Temperature sensor 2 name TEMP_2_NAME = config["DEFAULT"]["TEMP_2_NAME"] - -# Temperature sensor 3 name TEMP_3_NAME = config["DEFAULT"]["TEMP_3_NAME"] - -# Temperature sensor 2 name TEMP_4_NAME = config["DEFAULT"]["TEMP_4_NAME"] - # --------- BMS specific settings --------- - -# -- LltJbd settings -# SoC low levels -# NOTE: SOC_LOW_WARNING is also used to calculate the Time-To-Go even if you are not using a LltJbd BMS SOC_LOW_WARNING = float(config["DEFAULT"]["SOC_LOW_WARNING"]) SOC_LOW_ALARM = float(config["DEFAULT"]["SOC_LOW_ALARM"]) # -- Daly settings -# Battery capacity (amps) if the BMS does not support reading it BATTERY_CAPACITY = float(config["DEFAULT"]["BATTERY_CAPACITY"]) -# Invert Battery Current. Default non-inverted. Set to -1 to invert INVERT_CURRENT_MEASUREMENT = int(config["DEFAULT"]["INVERT_CURRENT_MEASUREMENT"]) # -- ESC GreenMeter and Lipro device settings @@ -330,19 +256,7 @@ def _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 -# from the BMS and SmartShunt could differ. This causes, that the driver never goapplies the float voltage, -# since max voltage is never reached. -# Example: -# cell count: 16 -# MAX_CELL_VOLTAGE = 3.45 -# max voltage calculated = 16 * 3.45 = 55.20 -# CVL is set to 55.20 and the battery is now charged until the SmartShunt measures 55.20 V. The BMS -# now measures 55.05 V since there is a voltage drop of 0.15 V. Since the dbus-serialbattery measures -# 55.05 V the max voltage is never reached for the driver and max voltage is kept forever. -# Set VOLTAGE_DROP to 0.15 VOLTAGE_DROP = float(config["DEFAULT"]["VOLTAGE_DROP"]) @@ -404,6 +318,10 @@ def kelvin_to_celsius(kelvin_temp): return kelvin_temp - 273.1 +def bytearray_to_string(data): + return "".join("\\x" + format(byte, "02x") for byte in data) + + def format_value(value, prefix, suffix): return ( None @@ -519,6 +437,7 @@ def read_serialport_data( locals_copy = locals().copy() +# Publish config variables to dbus def publish_config_variables(dbusservice): for variable, value in locals_copy.items(): if variable.startswith("__"): diff --git a/requirements.txt b/requirements.txt index 6de24eea..7a984a6f 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ pyserial==3.5 minimalmodbus==2.0.1 -bleak==0.20.0 \ No newline at end of file +bleak==0.21.0 +dbus-fast==1.94.1