From d50349f065cb8a433e831b3f991791782a53de8b Mon Sep 17 00:00:00 2001 From: Manuel Date: Sat, 25 Nov 2023 17:59:03 +0100 Subject: [PATCH] Merge dev into master (#874) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * Update reinstall-local.sh: Fixed charge current parameter Update reinstall-local.sh: Corrected charge current parameter for initial config.ini * Exclude devices from driver startup This prevents blocking the serial port * implement callback function for update * fix comments to reflect new logic * update changelog * set soc=100% when charge mode changes to float, apply exponential smoothing on current readout * remove scan for devices the scan for devices and check if the BMS to test is present doesn't add value if the device is not within range (or the MAC is wrong), then the subsequent start_scraping call will either fail or fail to produce usable data * JKBMS_BLE driver fixes * added Bluetooth signal strenght, increased debug * Optimized reinstallation procedure - Changed: Optimized restart sequence for the bluetooth installation - Changed: Run serial part first and then bluetooth part. This allows the serial driver to get operative faster - Removed: $DRIVERNAME variable for clearer paths - Removed: Bluetooth system driver restart, since the devices get disconnected by the service before starting the dbus-serialbatterydriver * Improved Jkbms_Ble error handling * optimized disable procedure * small fixes * save custom name and make it restart persistant https://github.com/Louisvdw/dbus-serialbattery/issues/100 * changed unique identifier from string to function function can be overridden by BMS battery class * fix typo * fix Sinowealth not loading https://github.com/Louisvdw/dbus-serialbattery/issues/702 * fix unique identifier function * enable BMS over config, if disabled by default Now you can also add more then one BMS for BMS_TYPE * show battery port in log * ANT BMS fixes Fixed that other devices are recognized as ANT BMS * Sinowealth BMS fixes Fixed that other devices are recognized as Sinowealth BMS * improved publish_battery error handling switched from error count to seconds * Improve Battery Voltage Handling in Linear Absorption Mode * Refactor change time() to int(time()) for consistency in max_voltage_start_time and tDiff calculation * Refactor battery voltage calculations for efficiency and clarity * Remove penalty_buffer * Reset max_voltage_start_time wenn we going to bulk(dynamic) mode * updated changelog * fix reply processing * Reduce the big inrush current, if the CVL jumps from Bulk/Absorbtion to Float fix https://github.com/Louisvdw/dbus-serialbattery/issues/659 * Check returned data lenght for Seplos BMS Be stricter about the return data we accept, might fix the problem of grid meters accidently being recognized as a Seplos * Validate current, voltage, capacity and SoC for all BMS This prevents that a device, which is no BMS, is detected as BMS * removed double check * bump version * fix validation if None * updated changelog * proposal to #659 formatted :) * bugfix proposal to #659 * refactor setting float charge_mode * fix type error, removed bluetooth cronjob * updated changelog * fix rs485 write communication errors by inserting sleeps, add debug print for charge mode and fix crash on write soc failures * fix write problem on set_soc. also changed the switch charge/discharge function, just in case * debug msg * Bluetooth optimizations * Fixes by @peterohman https://github.com/Louisvdw/dbus-serialbattery/pull/505#issuecomment-1587665083 * fix #712 * fix meaningless time to go values * fix meaningless time to go values * Duration of transition to float depends on number of cells * Float transition - Voltage drop per second * Update hlpdatabms4s.py * Validate setting of FLOAT_CELL_VOLTAGE and avoid misconfiguration * consider utils.LINEAR_RECALCULATION_EVERY to refresh CVL * cleanup * consider utils.LINEAR_RECALCULATION_EVERY to refresh CVL * small refactor, introduced set_cvl_linear function to set CVL only once every LINEAR_RECALCULATION_EVERY seconds * fix typo * updated changelog * remove debug msg * remove debug msg * undo debug change * Daly BMS make auto reset soc configurable * added debug and error information for CVL * fix proposal for #733 (#735) * Added: Tollerance to enter float voltage once the timer is triggered * Add bulk voltage Load to bulk voltage every x days to reset the SoC to 100% for some BMS * JKBMS disable high voltage warning on bulk reenable after bulk was completed * fixed error * disable high voltage warning for all BMS when charging to bulk voltage * fix error and change default value measurementToleranceVariation from 0.025 to 0.5 else in OffGrid mode max voltage is always kept * Added temperature names to dbus/mqtt * Use current avg of last 300 cycles for TTG & TTS * Calculate only positive Time-to-SoC points * added current average of last 5 minutes * make CCL and DCL more clear * fix small error * bugfix: LLTJBD BMS SOC different in Xiaoxiang app and dbus-serialbattery * black formatting * JDB BMS - Control FETs for charge, discharge and disable / enable balancer (#761) * feature: Allow to control charge / discharge FET * feature: Allow to enable / disable balancer * bugfix: Cycle Capacity is in 10 mAh Fixes SoC with factor 100 * 100% percentage * JBD BMS show balancer state in GUI page IO (#763) * Bump version * Fix typos * Smaller fixes - fixes https://github.com/Louisvdw/dbus-serialbattery/issues/792#issuecomment-1703147692 * Removed comments from utils.py This should make more clear that there are no values to change * Updated changelog * possible fix for LLT/JBS connection problems https://github.com/Louisvdw/dbus-serialbattery/issues/769 https://github.com/Louisvdw/dbus-serialbattery/issues/777 * bugfix: LLT/JBD BMS general packet data size check * improved reinstall and disable script * LLT/JBD BMS - Improved error handling and automatical driver restart in case of error. Should fix: - https://github.com/Louisvdw/dbus-serialbattery/issues/730 - https://github.com/Louisvdw/dbus-serialbattery/issues/769 - https://github.com/Louisvdw/dbus-serialbattery/issues/777 * Fixed Building wheel for dbus-fast won't finish on weak systems Fixes https://github.com/Louisvdw/dbus-serialbattery/issues/785 * Support for Daly CAN Bus (#169) * support for Daly CAN Bus * fix constructor args * revert port, needs fix * add can filters * comment logger Some changes are still needed to work with the latest version. They will follow in a next PR. --------- Co-authored-by: Samuel Brucksch Co-authored-by: Manuel * JKBMS BLE - Introduction of automatic SOC reset (HW Version 11) (#736) * Introduction of automatic SOC reset for JK BMS (HW Version 11) * Fixed value mapping * Rework of the code to make it simpler to use without additional configuration. Moved execution of SOC reset. It's now executed while changing from "Float" to "Float Transition". * Implementation of suggested changes Persist initial BMS OVP and OVPR settings Make use of max_cell_voltage to calculate trigger value for OVP alert * Added: Daly CAN and JKBMS CAN * added CAN bms to installation script optimized CAN drivers * smaller fixes * Trigger JK BLE SOC reset when using Step Mode * Moved trigger_soc_reset() * fixes LLT/JBD SOC > 100% https://github.com/Louisvdw/dbus-serialbattery/issues/769 * changed VOLTAGE_DROP behaviour * Fix JKBMS not starting if BMS manuf. date is empty * corrected bulk, absorption and soc reset terms * fix typo * add JKBMS_BLE debugging data * fix small error * added logging to config * add sleep before starting driver prevents lot of timeouts after reinstalling the driver, since the restart is now much faster than before * changed post install info * fix error * Daly BMS fixed embedded null byte https://github.com/Louisvdw/dbus-serialbattery/issues/837 * added info for SoC reset to default config file * fix for #716 https://github.com/Louisvdw/dbus-serialbattery/issues/716 * fix for #716 and JKBMS model recognition https://github.com/Louisvdw/dbus-serialbattery/issues/716 * optimized logging * fix JKBMS recognition * added debugging * fixes #716 https://github.com/Louisvdw/dbus-serialbattery/issues/716 --------- Co-authored-by: Holger Schultheiß Co-authored-by: Stefan Seidel Co-authored-by: Bernd Stahlbock <6627385+transistorgit@users.noreply.github.com> Co-authored-by: seidler2547 Co-authored-by: ogurevich <50322596+ogurevich@users.noreply.github.com> Co-authored-by: wollew Co-authored-by: Oleg Gurevich Co-authored-by: peterohman Co-authored-by: Strawder, Paul Co-authored-by: Paul Strawder Co-authored-by: Samuel Brucksch Co-authored-by: Samuel Brucksch Co-authored-by: ArendsM <136503378+ArendsM@users.noreply.github.com> Co-authored-by: Meik Arends --- CHANGELOG.md | 63 +- etc/dbus-serialbattery/battery.py | 671 ++++++++++++++---- etc/dbus-serialbattery/bms/ant.py | 9 +- .../bms/battery_template.py | 19 +- etc/dbus-serialbattery/bms/daly.py | 69 +- etc/dbus-serialbattery/bms/daly_can.py | 384 ++++++++++ etc/dbus-serialbattery/bms/heltecmodbus.py | 11 +- etc/dbus-serialbattery/bms/hlpdatabms4s.py | 35 +- etc/dbus-serialbattery/bms/jkbms.py | 31 +- etc/dbus-serialbattery/bms/jkbms_ble.py | 179 ++--- etc/dbus-serialbattery/bms/jkbms_brn.py | 276 +++++-- etc/dbus-serialbattery/bms/jkbms_can.py | 267 +++++++ etc/dbus-serialbattery/bms/lltjbd.py | 190 ++++- etc/dbus-serialbattery/bms/lltjbd_ble.py | 148 +++- etc/dbus-serialbattery/bms/mnb.py | 8 +- etc/dbus-serialbattery/bms/renogy.py | 16 +- etc/dbus-serialbattery/bms/seplos.py | 31 +- etc/dbus-serialbattery/bms/sinowealth.py | 50 +- etc/dbus-serialbattery/config.default.ini | 113 ++- etc/dbus-serialbattery/dbus-serialbattery.py | 113 ++- etc/dbus-serialbattery/dbushelper.py | 165 +++-- etc/dbus-serialbattery/disable.sh | 19 +- etc/dbus-serialbattery/qml/PageBattery.qml | 6 + .../qml/PageBatteryParameters.qml | 9 + etc/dbus-serialbattery/reinstall-local.sh | 297 ++++++-- etc/dbus-serialbattery/uninstall.sh | 3 +- etc/dbus-serialbattery/utils.py | 233 ++---- requirements.txt | 3 +- 28 files changed, 2709 insertions(+), 709 deletions(-) create mode 100644 etc/dbus-serialbattery/bms/daly_can.py create mode 100644 etc/dbus-serialbattery/bms/jkbms_can.py 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