diff --git a/CHANGELOG.md b/CHANGELOG.md index 2c08d348..30822f96 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -15,6 +15,7 @@ * Added: Choose how battery temperature is assembled (mean temp 1 & 2, only temp 1 or only temp 2) by @mr-manuel * Added: Config file by @ppuetsch * Added: Create empty `config.ini` for easier user usage by @mr-manuel +* Added: Cronjob to restart Bluetooth service every 12 hours by @mr-manuel * Added: Daly BMS - Read capacity https://github.com/Louisvdw/dbus-serialbattery/pull/594 by @transistorgit * Added: Daly BMS - Read production date and build unique identifier by @transistorgit * Added: Daly BMS - Set SoC by @transistorgit @@ -24,8 +25,18 @@ * Added: Fix for Venus OS >= v3.00~14 showing unused items https://github.com/Louisvdw/dbus-serialbattery/issues/469 by @mr-manuel * Added: HighInternalTemperature alarm (MOSFET) for JKBMS by @mr-manuel * Added: Improved maintainability (flake8, black lint), introduced code checks and automate release build https://github.com/Louisvdw/dbus-serialbattery/pull/386 by @ppuetsch +* Added: Install needed Bluetooth components automatically after a Venus OS upgrade by @mr-manuel * Added: JKBMS - MOS temperature https://github.com/Louisvdw/dbus-serialbattery/pull/440 by @baphomett * Added: JKBMS - Uniqie identifier and show "User Private Data" field that can be set in the JKBMS App to identify the BMS in a multi battery environment by @mr-manuel +* Added: JKBMS BLE - Balancing switch status by @mr-manuel +* Added: JKBMS BLE - Capacity by @mr-manuel +* Added: JKBMS BLE - Cell imbalance alert by @mr-manuel +* Added: JKBMS BLE - Charging switch status by @mr-manuel +* Added: JKBMS BLE - Discharging switch status by @mr-manuel +* Added: JKBMS BLE - MOS temperature by @mr-manuel +* Added: JKBMS BLE - Show if balancing is active and which cells are balancing by @mr-manuel +* Added: JKBMS BLE - Show serial number and "User Private Data" field that can be set in the JKBMS App to identify the BMS in a multi battery environment by @mr-manuel +* Added: JKBMS BLE driver by @baranator * Added: Possibility to add `config.ini` to the root of a USB flash drive on install via the USB method by @mr-manuel * Added: Post install notes by @mr-manuel * Added: Read charge/discharge limits from JKBMS by @mr-manuel @@ -55,6 +66,7 @@ * Changed: Default FLOAT_CELL_VOLTAGE from 3.350 V to 3.375 V by @mr-manuel * Changed: Default LINEAR_LIMITATION_ENABLE from False to True by @mr-manuel * Changed: Disabled ANT BMS by default https://github.com/Louisvdw/dbus-serialbattery/issues/479 by @mr-manuel +* Changed: Driver can now also start without serial adapter attached for Bluetooth BMS by @seidler2547 * Changed: Fix for https://github.com/Louisvdw/dbus-serialbattery/issues/239 by @mr-manuel * Changed: Fix for https://github.com/Louisvdw/dbus-serialbattery/issues/311 by @mr-manuel * Changed: Fix for https://github.com/Louisvdw/dbus-serialbattery/issues/351 by @mr-manuel @@ -68,6 +80,7 @@ * Changed: Improved JBD BMS soc calculation https://github.com/Louisvdw/dbus-serialbattery/pull/439 by @aaronreek * Changed: Logging to get relevant data by @mr-manuel * Changed: Many code improvements https://github.com/Louisvdw/dbus-serialbattery/pull/393 by @ppuetsch +* Changed: Moved Bluetooth part to `reinstall-local.sh` by @mr-manuel * Changed: Moved BMS scripts to subfolder by @mr-manuel * Changed: Removed all wildcard imports and fixed black lint errors by @mr-manuel * Changed: Removed cell voltage penalty. Replaced by automatic voltage calculation. Max voltage is kept until cells are balanced and reset when cells are inbalanced by @mr-manuel @@ -78,3 +91,4 @@ * Changed: Temperature alarm changed in order to not trigger all in the same condition for JKBMS by @mr-manuel * Changed: Time-To-Soc repetition from cycles to seconds. Minimum value is every 5 seconds. This prevents CPU overload and ensures system stability. Renamed `TIME_TO_SOC_LOOP_CYCLES` to `TIME_TO_SOC_RECALCULATE_EVERY` by @mr-manuel * Changed: Time-To-Soc string from `days, HR:MN:SC` to `d h m s` (same as Time-To-Go) by @mr-manuel +* Changed: Uninstall also installed Bluetooth modules on uninstall by @mr-manuel diff --git a/etc/dbus-serialbattery/bms/jkbms_ble.py b/etc/dbus-serialbattery/bms/jkbms_ble.py new file mode 100644 index 00000000..03a6e47e --- /dev/null +++ b/etc/dbus-serialbattery/bms/jkbms_ble.py @@ -0,0 +1,255 @@ +# -*- coding: utf-8 -*- +from battery import Battery, Cell +from utils import logger +from bms.jkbms_brn import Jkbms_Brn +from bleak import BleakScanner, BleakError +import asyncio +import time +import os + + +class Jkbms_Ble(Battery): + BATTERYTYPE = "Jkbms_Ble" + resetting = False + + def __init__(self, port, baud, address): + super(Jkbms_Ble, self).__init__(address.replace(":", "").lower(), baud, address) + self.type = self.BATTERYTYPE + self.jk = Jkbms_Brn(address) + + logger.info("Init of Jkbms_Ble at " + address) + + def connection_name(self) -> str: + return "BLE " + self.address + + 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) + 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 + + 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 + """ + + # 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: + 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() + if status is None: + self.jk.stop_scraping() + return False + + if not status["device_info"]["vendor_id"].startswith(("JK-", "JK_")): + self.jk.stop_scraping() + return False + + logger.info("Jkbms_Ble found!") + + # get first data to show in startup log + self.get_settings() + self.refresh_data() + + return True + + 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 + st = self.jk.get_status()["settings"] + self.cell_count = st["cell_count"] + self.max_battery_charge_current = st["max_charge_current"] + self.max_battery_discharge_current = st["max_discharge_current"] + self.max_battery_voltage = st["cell_ovp"] * self.cell_count + self.min_battery_voltage = st["cell_uvp"] * self.cell_count + + # "User Private Data" field in APP + tmp = self.jk.get_status()["device_info"]["production"] + self.custom_field = tmp if tmp != "Input Us" else None + + 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"] + + for c in range(self.cell_count): + self.cells.append(Cell(False)) + + self.capacity = self.jk.get_status()["cell_info"]["capacity_nominal"] + + self.hardware_version = ( + "JKBMS " + + self.jk.get_status()["device_info"]["hw_rev"] + + " " + + str(self.cell_count) + + " cells" + + (" (" + self.production + ")" if self.production else "") + ) + logger.info("BAT: " + self.hardware_version) + 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_soc_data() + # TODO: check for errors + 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 + # wrong with the bt-connection; restart whole stack + if not self.resetting: + self.reset_bluetooth() + self.jk.start_scraping() + time.sleep(2) + + return False + else: + self.resetting = False + + for c in range(self.cell_count): + self.cells[c].voltage = st["cell_info"]["voltages"][c] + + self.to_temp(0, st["cell_info"]["temperature_mos"]) + self.to_temp(1, st["cell_info"]["temperature_sensor_1"]) + self.to_temp(2, st["cell_info"]["temperature_sensor_2"]) + self.current = round(st["cell_info"]["current"], 1) + self.voltage = round(st["cell_info"]["total_voltage"], 2) + + self.soc = st["cell_info"]["battery_soc"] + self.cycles = st["cell_info"]["cycle_count"] + + self.charge_fet = st["settings"]["charging_switch"] + self.discharge_fet = st["settings"]["discharging_switch"] + self.balance_fet = st["settings"]["balancing_switch"] + + self.balancing = False if st["cell_info"]["balancing_action"] == 0.000 else True + self.balancing_current = ( + st["cell_info"]["balancing_current"] + if st["cell_info"]["balancing_current"] < 32768 + else (65536 / 1000 - st["cell_info"]["balancing_current"]) * -1 + ) + self.balancing_action = st["cell_info"]["balancing_action"] + + # show wich cells are balancing + for c in range(self.cell_count): + if self.balancing and ( + st["cell_info"]["min_voltage_cell"] == c + or st["cell_info"]["max_voltage_cell"] == c + ): + self.cells[c].balance = True + else: + self.cells[c].balance = False + + # protection bits + # self.protection.soc_low = 2 if status["cell_info"]["battery_soc"] < 10.0 else 0 + + # trigger cell imbalance warning when delta is to great + if st["cell_info"]["delta_cell_voltage"] > min( + st["settings"]["cell_ovp"] * 0.05, 0.200 + ): + self.protection.cell_imbalance = 2 + elif st["cell_info"]["delta_cell_voltage"] > min( + st["settings"]["cell_ovp"] * 0.03, 0.120 + ): + self.protection.cell_imbalance = 1 + else: + self.protection.cell_imbalance = 0 + + self.protection.voltage_high = 2 if st["warnings"]["cell_overvoltage"] else 0 + self.protection.voltage_low = 2 if st["warnings"]["cell_undervoltage"] else 0 + + self.protection.current_over = ( + 2 + if ( + st["warnings"]["charge_overcurrent"] + or st["warnings"]["discharge_overcurrent"] + ) + else 0 + ) + self.protection.set_IC_inspection = ( + 2 if st["cell_info"]["temperature_mos"] > 80 else 0 + ) + self.protection.temp_high_charge = 2 if st["warnings"]["charge_overtemp"] else 0 + self.protection.temp_low_charge = 2 if st["warnings"]["charge_undertemp"] else 0 + self.protection.temp_high_discharge = ( + 2 if st["warnings"]["discharge_overtemp"] else 0 + ) + return True + + def reset_bluetooth(self): + logger.info("Reset of Bluetooth triggered") + self.resetting = True + # if self.jk.is_running(): + # self.jk.stop_scraping() + logger.info("Scraping ended, 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) + os.system("rfkill block bluetooth") + os.system("rfkill unblock bluetooth") + os.system("/etc/init.d/bluetooth start") + logger.info("Bluetooth should have been restarted") + + def get_balancing(self): + return 1 if self.balancing else 0 diff --git a/etc/dbus-serialbattery/bms/jkbms_brn.py b/etc/dbus-serialbattery/bms/jkbms_brn.py new file mode 100644 index 00000000..917f291f --- /dev/null +++ b/etc/dbus-serialbattery/bms/jkbms_brn.py @@ -0,0 +1,404 @@ +import asyncio +from bleak import BleakScanner, BleakClient +import time +from logging import info, debug +import logging +from struct import unpack_from, calcsize +import threading + +logging.basicConfig(level=logging.INFO) + + +# zero means parse all incoming data (every second) +CELL_INFO_REFRESH_S = 0 +CHAR_HANDLE = "0000ffe1-0000-1000-8000-00805f9b34fb" +MODEL_NBR_UUID = "00002a24-0000-1000-8000-00805f9b34fb" + +COMMAND_CELL_INFO = 0x96 +COMMAND_DEVICE_INFO = 0x97 + +FRAME_VERSION_JK04 = 0x01 +FRAME_VERSION_JK02 = 0x02 +FRAME_VERSION_JK02_32S = 0x03 +PROTOCOL_VERSION_JK02 = 0x02 + + +protocol_version = PROTOCOL_VERSION_JK02 + + +MIN_RESPONSE_SIZE = 300 +MAX_RESPONSE_SIZE = 320 + +TRANSLATE_DEVICE_INFO = [ + [["device_info", "hw_rev"], 22, "8s"], + [["device_info", "sw_rev"], 30, "8s"], + [["device_info", "uptime"], 38, "= 112: + offset = 32 + elif translation[1] >= 54: + offset = 16 + i = 0 + for j in kees: + if isinstance(translation[2], int): + # handle raw bytes without unpack_from; + # 3. param gives no format but number of bytes + val = bytearray( + fb[ + translation[1] + + i + + offset : translation[1] + + i + + translation[2] + + offset + ] + ) + i += translation[2] + else: + val = unpack_from( + translation[2], bytearray(fb), translation[1] + i + offset + )[0] + # calculate stepping in case of array + i = i + calcsize(translation[2]) + + if isinstance(val, bytes): + try: + val = val.decode("utf-8").rstrip(" \t\n\r\0") + except UnicodeDecodeError: + val = "" + + elif isinstance(val, int) and len(translation) == 4: + val = val * translation[3] + o[j] = val + else: + if translation[0][i] not in o: + if len(translation[0]) == i + 2 and isinstance( + translation[0][i + 1], int + ): + o[translation[0][i]] = [None] * translation[0][i + 1] + else: + o[translation[0][i]] = {} + + self.translate(fb, translation, o[translation[0][i]], f32s=f32s, i=i + 1) + + def decode_warnings(self, fb): + val = unpack_from(" 0 + for t in TRANSLATE_CELL_INFO: + self.translate(fb, t, self.bms_status, f32s=has32s) + self.decode_warnings(fb) + 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) + + def decode(self): + # check what kind of info the frame contains + info_type = self.frame_buffer[4] + if info_type == 0x01: + info("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): + if t[0][-2] == "voltages" or t[0][-2] == "voltages": + TRANSLATE_CELL_INFO[i][0][-1] = ccount + self.bms_status["last_update"] = time.time() + + elif info_type == 0x02: + if ( + CELL_INFO_REFRESH_S == 0 + or time.time() - self.last_cell_info > CELL_INFO_REFRESH_S + ): + self.last_cell_info = time.time() + info("processing frame with battery cell info") + if protocol_version == PROTOCOL_VERSION_JK02: + self.decode_cellinfo_jk02() + self.bms_status["last_update"] = time.time() + # power is calculated from voltage x current as + # register 122 contains unsigned power-value + self.bms_status["cell_info"]["power"] = ( + self.bms_status["cell_info"]["current"] + * self.bms_status["cell_info"]["total_voltage"] + ) + if self.waiting_for_response == "cell_info": + self.waiting_for_response = "" + + elif info_type == 0x03: + info("processing frame with device info") + if protocol_version == PROTOCOL_VERSION_JK02: + self.decode_device_info_jk02() + self.bms_status["last_update"] = time.time() + else: + return + if self.waiting_for_response == "device_info": + self.waiting_for_response = "" + + def assemble_frame(self, data: bytearray): + if len(self.frame_buffer) > MAX_RESPONSE_SIZE: + info("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: + # beginning of new frame, clear buffer + self.frame_buffer = [] + + self.frame_buffer.extend(data) + + 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}") + if ccrc == rcrc: + debug("great success! frame complete and sane, lets decode") + self.decode() + self.frame_buffer = [] + + def ncallback(self, sender: int, data: bytearray): + debug(f"------> NEW PACKAGE!laenge: {len(data)}") + self.assemble_frame(data) + + def crc(self, arr: bytearray, length: int) -> int: + crc = 0 + for a in arr[:length]: + crc = crc + a + return crc.to_bytes(2, "little")[0] + + async def write_register( + self, address, vals: bytearray, length: int, bleakC: BleakClient + ): + frame = bytearray(20) + frame[0] = 0xAA # start sequence + frame[1] = 0x55 # start sequence + frame[2] = 0x90 # start sequence + frame[3] = 0xEB # start sequence + frame[4] = address # holding register + frame[5] = length # size of the value in byte + frame[6] = vals[0] + frame[7] = vals[1] + frame[8] = vals[2] + frame[9] = vals[3] + frame[10] = 0x00 + frame[11] = 0x00 + frame[12] = 0x00 + frame[13] = 0x00 + frame[14] = 0x00 + frame[15] = 0x00 + frame[16] = 0x00 + 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) + + async def request_bt(self, rtype: str, client): + timeout = time.time() + + while self.waiting_for_response != "" and time.time() - timeout < 10: + await asyncio.sleep(1) + print(self.waiting_for_response) + + if rtype == "cell_info": + cmd = COMMAND_CELL_INFO + self.waiting_for_response = "cell_info" + elif rtype == "device_info": + cmd = COMMAND_DEVICE_INFO + self.waiting_for_response = "device_info" + else: + return + + await self.write_register(cmd, b"\0\0\0\0", 0x00, client) + + def get_status(self): + if "settings" in self.bms_status and "cell_info" in self.bms_status: + return self.bms_status + else: + return None + + def connect_and_scrape(self): + asyncio.run(self.asy_connect_and_scrape()) + + async def asy_connect_and_scrape(self): + print("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") + try: + print("reconnect") + await client.connect() + self.bms_status["model_nbr"] = ( + await client.read_gatt_char(MODEL_NBR_UUID) + ).decode("utf-8") + + await client.start_notify(CHAR_HANDLE, self.ncallback) + await self.request_bt("device_info", client) + + await self.request_bt("cell_info", client) + # await self.enable_charging(client) + # last_dev_info = time.time() + while client.is_connected and self.run and self.main_thread.is_alive(): + await asyncio.sleep(0.01) + except Exception as e: + info("error while connecting to bt: " + str(e)) + self.run = False + finally: + if client.is_connected: + try: + await client.disconnect() + except Exception as e: + info("error while disconnecting: " + str(e)) + + print("Exiting bt-loop") + + def start_scraping(self): + self.main_thread = threading.current_thread() + if self.is_running(): + return + self.bt_thread.start() + info( + "scraping thread started -> main thread id: " + + str(self.main_thread.ident) + + " scraping thread: " + + str(self.bt_thread.ident) + ) + + def stop_scraping(self): + self.run = False + stop = time.time() + while self.is_running(): + time.sleep(0.1) + if time.time() - stop > 10: + return False + return True + + def is_running(self): + return self.bt_thread.is_alive() + + async def enable_charging(self, c): + # these are the registers for the control-buttons: + # 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) + + +""" +if __name__ == "__main__": + jk = Jkbms_Brn("C8:47:8C:00:00:00") + jk.start_scraping() + while True: + print(jk.get_status()) + time.sleep(5) +""" diff --git a/etc/dbus-serialbattery/bms/lltjbd_ble.py b/etc/dbus-serialbattery/bms/lltjbd_ble.py new file mode 100644 index 00000000..fa4b38da --- /dev/null +++ b/etc/dbus-serialbattery/bms/lltjbd_ble.py @@ -0,0 +1,185 @@ +# -*- coding: utf-8 -*- +import asyncio +import atexit +import functools +import threading +from typing import Union, Optional +from utils import logger +from struct import unpack_from +from bleak import BleakClient, BleakScanner, BLEDevice +from bms.lltjbd import LltJbdProtection, LltJbd + + +BLE_SERVICE_UUID = "0000ff00-0000-1000-8000-00805f9b34fb" +BLE_CHARACTERISTICS_TX_UUID = "0000ff02-0000-1000-8000-00805f9b34fb" +BLE_CHARACTERISTICS_RX_UUID = "0000ff01-0000-1000-8000-00805f9b34fb" +MIN_RESPONSE_SIZE = 6 +MAX_RESPONSE_SIZE = 256 + + +class LltJbd_Ble(LltJbd): + BATTERYTYPE = "LltJbd_Ble" + + def __init__(self, port: Optional[str], baud: Optional[int], address: str): + super(LltJbd_Ble, self).__init__(address.replace(":", "").lower(), -1, address) + + self.address = address + self.protection = LltJbdProtection() + self.type = self.BATTERYTYPE + self.main_thread = threading.current_thread() + self.data: bytearray = bytearray() + self.run = True + self.bt_thread = threading.Thread( + name="LltJbd_Ble_Loop", target=self.background_loop, daemon=True + ) + self.bt_loop: Optional[asyncio.AbstractEventLoop] = None + self.bt_client: Optional[BleakClient] = None + self.device: Optional[BLEDevice] = None + self.response_queue: Optional[asyncio.Queue] = None + self.ready_event: Optional[asyncio.Event] = None + + logger.info("Init of LltJbd_Ble at " + address) + + def connection_name(self) -> str: + return "BLE " + self.address + + def custom_name(self) -> str: + return self.device.name + + def on_disconnect(self, client): + logger.info("BLE client disconnected") + + async def bt_main_loop(self): + self.device = await BleakScanner.find_device_by_address( + self.address, cb=dict(use_bdaddr=True) + ) + + 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 + + def background_loop(self): + while self.run and self.main_thread.is_alive(): + asyncio.run(self.bt_main_loop()) + + async def async_test_connection(self): + self.ready_event = asyncio.Event() + if not self.bt_thread.is_alive(): + self.bt_thread.start() + + def shutdown_ble_atexit(thread): + self.run = False + thread.join() + + atexit.register(shutdown_ble_atexit, self.bt_thread) + try: + return await asyncio.wait_for(self.ready_event.wait(), timeout=5) + except asyncio.TimeoutError: + logger.error(">>> ERROR: Unable to connect with BLE device") + return False + + 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 + result = False + logger.info("Test of LltJbd_Ble at " + self.address) + try: + if self.address: + result = True + if result and asyncio.run(self.async_test_connection()): + result = True + if result: + 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)=}") + result = False + + return result + + async def send_command(self, command) -> Union[bytearray, bool]: + if not self.bt_client: + logger.error(">>> ERROR: No BLE client connection - returning") + return False + + fut = self.bt_loop.create_future() + + def rx_callback(future: asyncio.Future, data: bytearray, sender, rx: bytearray): + data.extend(rx) + if len(data) < (self.LENGTH_POS + 1): + return + + length = data[self.LENGTH_POS] + if len(data) <= length + self.LENGTH_POS + 1: + return + if not future.done(): + future.set_result(data) + + rx_collector = functools.partial(rx_callback, fut, bytearray()) + await self.bt_client.start_notify(BLE_CHARACTERISTICS_RX_UUID, rx_collector) + await self.bt_client.write_gatt_char( + BLE_CHARACTERISTICS_TX_UUID, command, False + ) + result = await fut + await self.bt_client.stop_notify(BLE_CHARACTERISTICS_RX_UUID) + + return result + + async def async_read_serial_data_llt(self, command): + try: + bt_task = asyncio.run_coroutine_threadsafe( + self.send_command(command), self.bt_loop + ) + result = await asyncio.wait_for(asyncio.wrap_future(bt_task), 20) + return result + except asyncio.TimeoutError: + logger.error(">>> ERROR: No reply - returning") + return False + except Exception as e: + logger.error(">>> ERROR: No reply - returning", e) + return False + + def read_serial_data_llt(self, command): + if not self.bt_loop: + return False + data = asyncio.run(self.async_read_serial_data_llt(command)) + if not data: + return False + + start, flag, command_ret, length = unpack_from("BBBB", data) + checksum, end = unpack_from("HB", data, length + 4) + + if end == 119: + return data[4 : length + 4] + else: + logger.error(">>> ERROR: Incorrect Reply") + return False + + +""" +async def test_LltJbd_Ble(): + import sys + + bat = LltJbd_Ble("Foo", -1, sys.argv[1]) + if not bat.test_connection(): + logger.error(">>> ERROR: Unable to connect") + else: + bat.refresh_data() + + +if __name__ == "__main__": + test_LltJbd_Ble() +""" diff --git a/etc/dbus-serialbattery/config.default.ini b/etc/dbus-serialbattery/config.default.ini index bff9a681..8018e3bd 100644 --- a/etc/dbus-serialbattery/config.default.ini +++ b/etc/dbus-serialbattery/config.default.ini @@ -12,6 +12,12 @@ MIN_CELL_VOLTAGE = 2.900 MAX_CELL_VOLTAGE = 3.450 FLOAT_CELL_VOLTAGE = 3.375 +; --------- Bluetooth BMS --------- +; Description: List the Bluetooth BMS here that you want to install +; Example with 1 BMS: Jkbms_Ble C8:47:8C:00:00:00 +; Example with 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 +BLUETOOTH_BMS = + ; --------- 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. diff --git a/etc/dbus-serialbattery/dbus-serialbattery.py b/etc/dbus-serialbattery/dbus-serialbattery.py index 5f7088d0..68265a07 100644 --- a/etc/dbus-serialbattery/dbus-serialbattery.py +++ b/etc/dbus-serialbattery/dbus-serialbattery.py @@ -102,7 +102,27 @@ def get_port() -> str: ) port = get_port() - battery: Battery = get_battery(port) + battery = None + if port.endswith("_Ble") and len(sys.argv) > 2: + """ + Import ble classes only, if it's a ble port, else the driver won't start due to missing python modules + This prevent problems when using the driver only with a serial connection + """ + if port == "Jkbms_Ble": + # noqa: F401 --> ignore flake "imported but unused" error + from bms.jkbms_ble import Jkbms_Ble # noqa: F401 + + if port == "LltJbd_Ble": + # noqa: F401 --> ignore flake "imported but unused" error + from bms.lltjbd_ble import LltJbd_Ble # noqa: F401 + + class_ = eval(port) + testbms = class_("", 9600, sys.argv[2]) + if testbms.test_connection() is True: + logger.info("Connection established to " + testbms.__class__.__name__) + battery = testbms + else: + battery = get_battery(port) # exit if no battery could be found if battery is None: diff --git a/etc/dbus-serialbattery/disable.sh b/etc/dbus-serialbattery/disable.sh index 866f9fc7..34304cd5 100755 --- a/etc/dbus-serialbattery/disable.sh +++ b/etc/dbus-serialbattery/disable.sh @@ -3,22 +3,32 @@ # remove comment for easier troubleshooting #set -x -DRIVERNAME=dbus-serialbattery - # handle read only mounts sh /opt/victronenergy/swupdate-scripts/remount-rw.sh -# remove files -rm -f /data/conf/serial-starter.d/$DRIVERNAME.conf - -# kill driver, if running. It gets restarted by the service daemon -pkill -f "python .*/$DRIVERNAME.py" +# remove files, don't use variables here, since on an error the whole /opt/victronenergy gets deleted +rm -f /data/conf/serial-starter.d/dbus-serialbattery.conf +rm -rf /service/dbus-serialbattery.* +rm -rf /service/dbus-blebattery.* # remove install script from rc.local -sed -i "/sh \/data\/etc\/$DRIVERNAME\/reinstall-local.sh/d" /data/rc.local +sed -i "/bash \/data\/etc\/dbus-serialbattery\/reinstall-local.sh/d" /data/rc.local ### needed for upgrading from older versions | start ### +# remove old drivers before changing from dbus-blebattery-$1 to dbus-blebattery.$1 +rm -rf /service/dbus-blebattery-* # remove old install script from rc.local -sed -i "/sh \/data\/etc\/$DRIVERNAME\/reinstalllocal.sh/d" /data/rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/reinstalllocal.sh/d" /data/rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/reinstall-local.sh/d" /data/rc.local +# remove old entry from rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/installble.sh/d" /data/rc.local ### needed for upgrading from older versions | end ### + + +# kill serial starter, to reload changes +pkill -f "/opt/victronenergy/serial-starter/serial-starter.sh" + +# kill driver, if running +pkill -f "serialbattery" +pkill -f "blebattery" diff --git a/etc/dbus-serialbattery/reinstall-local.sh b/etc/dbus-serialbattery/reinstall-local.sh index f9e527d1..f822e026 100755 --- a/etc/dbus-serialbattery/reinstall-local.sh +++ b/etc/dbus-serialbattery/reinstall-local.sh @@ -46,7 +46,7 @@ if [ ! -f $filename ]; then echo "#!/bin/bash" >> $filename chmod 755 $filename fi -grep -qxF "sh /data/etc/$DRIVERNAME/reinstall-local.sh" $filename || echo "sh /data/etc/$DRIVERNAME/reinstall-local.sh" >> $filename +grep -qxF "bash /data/etc/$DRIVERNAME/reinstall-local.sh" $filename || echo "bash /data/etc/$DRIVERNAME/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 @@ -59,20 +59,123 @@ if [ ! -f $filename ]; then fi + +### BLUETOOTH PART | START ### + +# get BMS list from config file +bluetooth_bms=$(awk -F "=" '/^BLUETOOTH_BMS/ {print $2}' /data/etc/dbus-serialbattery/config.ini) +#echo $bluetooth_bms + +# clear whitespaces +bluetooth_bms_clean="$(echo $bluetooth_bms | sed 's/\s*,\s*/,/g')" +#echo $bluetooth_bms_clean + +# split into array +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 + +# always remove existing blebattery services to cleanup +rm -rf /service/dbus-blebattery.* + +# kill all blebattery processes +pkill -f "blebattery" + +if [ $length -gt 0 ]; then + + echo "Found $length Bluetooth BMS in the config file!" + echo "" + + # install required packages + # TO DO: Check first if packages are already installed + echo "Installing required packages..." + opkg update + opkg install python3-misc python3-pip + 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 + + # function to install ble battery + install_blebattery_service() { + mkdir -p /service/dbus-blebattery.$1/log + echo "#!/bin/sh" > /service/dbus-blebattery.$1/log/run + echo "exec multilog t s25000 n4 /var/log/dbus-blebattery.$1" >> /service/dbus-blebattery.$1/log/run + chmod 755 /service/dbus-blebattery.$1/log/run + + echo "#!/bin/sh" > /service/dbus-blebattery.$1/run + echo "exec 2>&1" >> /service/dbus-blebattery.$1/run + echo "bluetoothctl disconnect $3" >> /service/dbus-blebattery.$1/run + echo "python /opt/victronenergy/dbus-serialbattery/dbus-serialbattery.py $2 $3" >> /service/dbus-blebattery.$1/run + chmod 755 /service/dbus-blebattery.$1/run + } + + echo "Packages installed." + echo "" + + # 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<${length}; i++ )); + do + echo "Installing ${bms_array[$i]} as dbus-blebattery.$i" + install_blebattery_service $i "${bms_array[$i]}" + done + +else + + # remove cronjob + sed -i "/5 0,12 \* \* \* \/etc\/init.d\/bluetooth restart/d" /var/spool/cron/root + + 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." + +fi +### BLUETOOTH PART | END ### + + ### needed for upgrading from older versions | start ### +# remove old drivers before changing from dbus-blebattery-$1 to dbus-blebattery.$1 +rm -rf /service/dbus-blebattery-* # remove old install script from rc.local sed -i "/sh \/data\/etc\/$DRIVERNAME\/reinstalllocal.sh/d" /data/rc.local +# remove old entry from rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/installble.sh/d" /data/rc.local ### needed for upgrading from older versions | end ### # kill driver, if running. It gets restarted by the service daemon pkill -f "python .*/$DRIVERNAME.py" +# restart bluetooth service, if Bluetooth BMS configured +if [ $length -gt 0 ]; then + /etc/init.d/bluetooth restart +fi + + # install notes echo echo echo "SERIAL battery connection: The installation is complete. You don't have to do anything more." echo +echo "BLUETOOTH battery connection: There are a few more steps to complete installation." +echo +echo " 1. Please add the Bluetooth BMS to the config file \"/data/etc/dbus-serialbattery/config.ini\" by adding \"BLUETOOTH_BMS\":" +echo " Example with 1 BMS: BLUETOOTH_BMS = Jkbms_Ble C8:47:8C:00:00:00" +echo " Example with 3 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" +echo " If your Bluetooth BMS are nearby you can show the MAC address with \"bluetoothctl devices\"." +echo +echo " 2. Make sure to disable 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 +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 "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 b1a1d16d..d11df02f 100755 --- a/etc/dbus-serialbattery/uninstall.sh +++ b/etc/dbus-serialbattery/uninstall.sh @@ -4,22 +4,58 @@ #set -x # handle read only mounts -sh /opt/victronenergy/swupdate-scripts/remount-rw.sh +bash /opt/victronenergy/swupdate-scripts/remount-rw.sh # remove files, don't use variables here, since on an error the whole /opt/victronenergy gets deleted rm -f /data/conf/serial-starter.d/dbus-serialbattery.conf rm -rf /opt/victronenergy/service/dbus-serialbattery rm -rf /opt/victronenergy/service-templates/dbus-serialbattery rm -rf /opt/victronenergy/dbus-serialbattery - -# kill if running -pkill -f "python .*/dbus-serialbattery.py" +rm -rf /service/dbus-serialbattery.* +rm -rf /service/dbus-blebattery.* # remove install-script from rc.local -sed -i "/sh \/data\/etc\/dbus-serialbattery\/reinstall-local.sh/d" /data/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 ### needed for upgrading from older versions | start ### +# remove old drivers before changing from dbus-blebattery-$1 to dbus-blebattery.$1 +rm -rf /service/dbus-blebattery-* # remove old install script from rc.local -sed -i "/sh \/data\/etc\/$DRIVERNAME\/reinstalllocal.sh/d" /data/rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/reinstalllocal.sh/d" /data/rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/reinstall-local.sh/d" /data/rc.local +# remove old entry from rc.local +sed -i "/sh \/data\/etc\/dbus-serialbattery\/installble.sh/d" /data/rc.local ### needed for upgrading from older versions | end ### + + +# kill serial starter, to reload changes +pkill -f "/opt/victronenergy/serial-starter/serial-starter.sh" + +# kill driver, if running +pkill -f "serialbattery" +pkill -f "blebattery" + + +# restore GUI changes +/data/etc/dbus-serialbattery/restore-gui.sh + + +# uninstall modules +read -r -p "Do you also want to uninstall bleak, python3-pip and python3-modules? If you don't know select y. [Y/n] " response +echo +response=${response,,} # tolower +if [[ $response =~ ^(y| ) ]] || [[ -z $response ]]; then + echo "Uninstalling modules..." + pip3 uninstall bleak + opkg remove python3-pip python3-modules + echo "done." + echo +fi + + +echo "The driver was uninstalled. To delete also the install files run \"rm -rf /data/etc/dbus-serialbattery\" now." +echo