From 838203a34b63995434e629109b50484670aeca4d Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 7 Dec 2024 19:58:58 +0100 Subject: [PATCH 01/22] renamed CRCs renamed old crc to modbus added crc for xmodem --- custom_components/bms_ble/plugins/basebms.py | 12 ++++++++++-- custom_components/bms_ble/plugins/daly_bms.py | 6 +++--- custom_components/bms_ble/plugins/seplos_bms.py | 6 +++--- 3 files changed, 16 insertions(+), 8 deletions(-) diff --git a/custom_components/bms_ble/plugins/basebms.py b/custom_components/bms_ble/plugins/basebms.py index 212725d..bded3b6 100644 --- a/custom_components/bms_ble/plugins/basebms.py +++ b/custom_components/bms_ble/plugins/basebms.py @@ -229,8 +229,8 @@ async def async_update(self) -> BMSsample: return data -def crc_xmodem(data: bytearray) -> int: - """Calculate CRC-16-CCITT XMODEM (ModBus).""" +def crc_modbus(data: bytearray) -> int: + """Calculate CRC-16-CCITT MODBUS.""" crc: int = 0xFFFF for i in data: crc ^= i & 0xFF @@ -238,6 +238,14 @@ def crc_xmodem(data: bytearray) -> int: crc = (crc >> 1) ^ 0xA001 if crc % 2 else (crc >> 1) return ((0xFF00 & crc) >> 8) | ((crc & 0xFF) << 8) +def crc_xmodem(data: bytearray) -> int: + """Calculate CRC-16-CCITT XMODEM.""" + crc: int = 0x0000 + for byte in data: + crc ^= byte << 8 + for _ in range(8): + crc = (crc << 1) ^ 0x1021 if (crc & 0x8000) else (crc << 1) + return crc & 0xFFFF def crc_sum(frame: bytes) -> int: """Calculate frame CRC.""" diff --git a/custom_components/bms_ble/plugins/daly_bms.py b/custom_components/bms_ble/plugins/daly_bms.py index c138bb0..3b4e646 100644 --- a/custom_components/bms_ble/plugins/daly_bms.py +++ b/custom_components/bms_ble/plugins/daly_bms.py @@ -26,7 +26,7 @@ KEY_TEMP_VALUE, ) -from .basebms import BaseBMS, BMSsample, crc_xmodem +from .basebms import BaseBMS, BMSsample, crc_modbus BAT_TIMEOUT: Final = 10 LOGGER: Final = logging.getLogger(__name__) @@ -108,12 +108,12 @@ def _notification_handler(self, _sender, data: bytearray) -> None: len(data) < BMS.HEAD_LEN or data[0:2] != BMS.HEAD_READ or int(data[2]) + 1 != len(data) - len(BMS.HEAD_READ) - BMS.CRC_LEN - or int.from_bytes(data[-2:], byteorder="big") != crc_xmodem(data[:-2]) + or int.from_bytes(data[-2:], byteorder="big") != crc_modbus(data[:-2]) ): LOGGER.debug( "Response data is invalid, CRC: 0x%X != 0x%X", int.from_bytes(data[-2:], byteorder="big"), - crc_xmodem(data[:-2]), + crc_modbus(data[:-2]), ) self._data = None else: diff --git a/custom_components/bms_ble/plugins/seplos_bms.py b/custom_components/bms_ble/plugins/seplos_bms.py index 562ec9a..c1408b9 100644 --- a/custom_components/bms_ble/plugins/seplos_bms.py +++ b/custom_components/bms_ble/plugins/seplos_bms.py @@ -25,7 +25,7 @@ KEY_TEMP_VALUE, ) -from .basebms import BaseBMS, BMSsample, crc_xmodem +from .basebms import BaseBMS, BMSsample, crc_modbus BAT_TIMEOUT: Final = 5 LOGGER = logging.getLogger(__name__) @@ -151,7 +151,7 @@ def _notification_handler(self, _sender, data: bytearray) -> None: if len(self._data) < self._exp_len: return - crc = crc_xmodem(self._data[: self._exp_len - 2]) + crc = crc_modbus(self._data[: self._exp_len - 2]) if int.from_bytes(self._data[self._exp_len - 2 : self._exp_len]) != crc: LOGGER.debug( "%s: RX data CRC is invalid: 0x%X != 0x%X", @@ -203,7 +203,7 @@ def _cmd(device: int, cmd: int, start: int, count: int) -> bytearray: frame = bytearray([device, cmd]) frame += bytearray(int.to_bytes(start, 2, byteorder="big")) frame += bytearray(int.to_bytes(count, 2, byteorder="big")) - frame += bytearray(int.to_bytes(crc_xmodem(frame), 2, byteorder="big")) + frame += bytearray(int.to_bytes(crc_modbus(frame), 2, byteorder="big")) return frame async def _async_update(self) -> BMSsample: From bfa2260f392886d8d25a5a76ca3a0dab3c3737a8 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 7 Dec 2024 19:59:29 +0100 Subject: [PATCH 02/22] added Seplos v2 implementation --- README.md | 3 +- custom_components/bms_ble/const.py | 5 +- custom_components/bms_ble/manifest.json | 7 +- .../bms_ble/plugins/seplos_v2_bms.py | 263 ++++++++++++++++++ tests/test_const.py | 2 +- tests/test_seplos_v2_bms.py | 251 +++++++++++++++++ 6 files changed, 526 insertions(+), 5 deletions(-) create mode 100644 custom_components/bms_ble/plugins/seplos_v2_bms.py create mode 100644 tests/test_seplos_v2_bms.py diff --git a/README.md b/README.md index f3b3198..6fd36a4 100644 --- a/README.md +++ b/README.md @@ -28,6 +28,7 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management - JK BMS, Jikong, (HW version >=11 required) - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…) - LiTime, Redodo batteries +- Seplos v2 (show up as `BP0`…) - Seplos v3 (show up as `SP0`… or `SP1`…) New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details. @@ -151,7 +152,7 @@ In case you have severe troubles, - Add further battery types on [request](https://github.com/patman15/BMS_BLE-HA/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml) ## Thanks to -> [@gkathan](https://github.com/patman15/BMS_BLE-HA/issues/2), [@downset](https://github.com/patman15/BMS_BLE-HA/issues/19), [@gerritb](https://github.com/patman15/BMS_BLE-HA/issues/22), [@Goaheadz](https://github.com/patman15/BMS_BLE-HA/issues/24), [@alros100, @majonessyltetoy](https://github.com/patman15/BMS_BLE-HA/issues/52), [@snipah, @Gruni22](https://github.com/patman15/BMS_BLE-HA/issues/59), [@azisto](https://github.com/patman15/BMS_BLE-HA/issues/78), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57), [@SkeLLLa,@romanshypovskyi](https://github.com/patman15/BMS_BLE-HA/issues/90) +> [@gkathan](https://github.com/patman15/BMS_BLE-HA/issues/2), [@downset](https://github.com/patman15/BMS_BLE-HA/issues/19), [@gerritb](https://github.com/patman15/BMS_BLE-HA/issues/22), [@Goaheadz](https://github.com/patman15/BMS_BLE-HA/issues/24), [@alros100, @majonessyltetoy](https://github.com/patman15/BMS_BLE-HA/issues/52), [@snipah, @Gruni22](https://github.com/patman15/BMS_BLE-HA/issues/59), [@azisto](https://github.com/patman15/BMS_BLE-HA/issues/78), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57), [@SkeLLLa,@romanshypovskyi](https://github.com/patman15/BMS_BLE-HA/issues/90), [@riogrande75, @ebagnoli, @andreas-bulling](https://github.com/patman15/BMS_BLE-HA/issues/101) for helping with making the integration better. diff --git a/custom_components/bms_ble/const.py b/custom_components/bms_ble/const.py index c1a3be0..62fb0df 100644 --- a/custom_components/bms_ble/const.py +++ b/custom_components/bms_ble/const.py @@ -13,14 +13,15 @@ BMS_TYPES: Final = [ "cbtpwr_bms", "daly_bms", - "dpwrcore_bms", "ective_bms", "ej_bms", - "jbd_bms", "jikong_bms", "ogt_bms", "redodo_bms", "seplos_bms", + "seplos_v2_bms", + "dpwrcore_bms", # only name filter + "jbd_bms", # only UUID filter ] # available BMS types DOMAIN: Final = "bms_ble" LOGGER: Final = logging.getLogger(__package__) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 3ccc06a..2879345 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -50,7 +50,12 @@ "local_name": "$PFLAC*", "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", "manufacturer_id": 65535 - } + }, + { + "local_name": "BP00", + "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" + + } ], "codeowners": ["@patman15"], "config_flow": true, diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py new file mode 100644 index 0000000..9047cba --- /dev/null +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -0,0 +1,263 @@ +"""Module to support Seplos V2 BMS.""" + +import asyncio +import logging +from typing import Any, Callable, Final + +from bleak.backends.device import BLEDevice +from bleak.uuids import normalize_uuid_str + +from custom_components.bms_ble.const import ( + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_CURRENT, + ATTR_CYCLE_CAP, + ATTR_CYCLE_CHRG, + ATTR_CYCLES, + ATTR_DELTA_VOLTAGE, + ATTR_POWER, + ATTR_RUNTIME, + ATTR_TEMPERATURE, + ATTR_VOLTAGE, + KEY_CELL_COUNT, + KEY_CELL_VOLTAGE, + KEY_TEMP_SENS, +) + +from .basebms import BaseBMS, BMSsample, crc_xmodem + +LOGGER = logging.getLogger(__name__) +BAT_TIMEOUT = 5 + + +class BMS(BaseBMS): + """Dummy battery class implementation.""" + + _HEAD: Final[int] = 0x7E + _TAIL: Final[int] = 0x0D + _CMD_VER: Final[int] = 0x10 + _RSP_VER: Final[int] = 0x14 + _INFO_LEN: Final[int] = 10 # minimal frame length + _CELLS_POS: Final[int] = 9 + _FIELDS: Final[ + list[tuple[str, int, int, bool, Callable[[int], int | float]]] + # ] = [ + # (ATTR_VOLTAGE, 0x61, 2, 2, False, lambda x: float(x / 100)), + # (ATTR_CURRENT, 0x61, 0, 2, True, lambda x: float(x / 100)), + # (ATTR_CYCLE_CHRG, 0x61, 4, 2, False, lambda x: float(x / 100)), + # (ATTR_CYCLES, 0x61, 13, 2, False, lambda x: x), + # (ATTR_BATTERY_LEVEL, 0x61, 9, 2, False, lambda x: float(x / 10)), + # ] # Protocol Seplos V2 (single machine data 0x61) + ] = [ + (ATTR_VOLTAGE, 25, 2, False, lambda x: float(x / 100)), + (ATTR_CURRENT, 23, 2, True, lambda x: float(x / 100)), + (ATTR_CYCLE_CHRG, 27, 2, False, lambda x: float(x / 100)), + (ATTR_CYCLES, 36, 2, False, lambda x: x), + (ATTR_BATTERY_LEVEL, 32, 2, False, lambda x: float(x / 10)), + (ATTR_TEMPERATURE, 21, 2, True, lambda x: (x-2731.5)/10), + (KEY_CELL_COUNT, 9, 1, False, lambda x: x), + (KEY_TEMP_SENS, 14, 1, False, lambda x: x), + ] # Protocol Seplos V2 (parallel data 0x62) + + def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: + """Initialize BMS.""" + LOGGER.debug("%s init(), BT address: %s", self.device_id(), ble_device.address) + super().__init__(LOGGER, self._notification_handler, ble_device, reconnect) + self._data: bytearray = bytearray() + self._exp_len: int = 0 + self._data_final: bytearray = bytearray() + + @staticmethod + def matcher_dict_list() -> list[dict[str, Any]]: + """Provide BluetoothMatcher definition.""" + return [ + { + "local_name": "BP00", + "service_uuid": BMS.uuid_services()[0], + "connectable": True, + } + ] + + @staticmethod + def device_info() -> dict[str, str]: + """Return device information for the battery management system.""" + return {"manufacturer": "Seplos", "model": "Smart BMS V2"} + + @staticmethod + def uuid_services() -> list[str]: + """Return list of 128-bit UUIDs of services required by BMS.""" + return [normalize_uuid_str("ff00")] # change service UUID here! + + @staticmethod + def uuid_rx() -> str: + """Return 16-bit UUID of characteristic that provides notification/read property.""" + return "ff01" + + @staticmethod + def uuid_tx() -> str: + """Return 16-bit UUID of characteristic that provides write property.""" + return "ff02" + + @staticmethod + def _calc_values() -> set[str]: + return { + ATTR_POWER, + ATTR_BATTERY_CHARGING, + ATTR_CYCLE_CAP, + ATTR_RUNTIME, + } # calculate further values from BMS provided set ones + + def _notification_handler(self, _sender, data: bytearray) -> None: + """Handle the RX characteristics notify event (new data arrives).""" + LOGGER.debug("%s: Received BLE data: %s", self.name, data) + + if ( + data[0] == BMS._HEAD + and len(data) > BMS._INFO_LEN + and len(self._data) >= self._exp_len + ): + self._exp_len = BMS._INFO_LEN + int.from_bytes(data[5:7]) + self._data = bytearray() + + self._data += data + LOGGER.debug( + "%s: RX BLE data (%s): %s", + self._ble_device.name, + "start" if data == self._data else "cnt.", + data, + ) + + # verify that data long enough + if len(self._data) < self._exp_len: + return + + if self._data[-1] != BMS._TAIL: + LOGGER.debug("%s: frame end incorrect: %s", self.name, self._data) + return + + if self._data[1] != BMS._RSP_VER: + LOGGER.debug( + "%s: unknown frame version: V%.1f", self.name, self._data[1] / 10 + ) + return + + if self._data[4]: + LOGGER.debug("%s: BMS reported error code: 0x%X", self.name, self._data[4]) + return + + crc = crc_xmodem(self._data[1:-3]) + if int.from_bytes(self._data[-3:-1]) != crc: + LOGGER.debug( + "%s: RX data CRC is invalid: 0x%X != 0x%X", + self._ble_device.name, + int.from_bytes(self._data[-3:-1]), + crc, + ) + return + + LOGGER.debug( + "%s: address: 0x%X, function: 0x%X, return: 0x%X", + self.name, + self._data[2], + self._data[3], + self._data[4], + ) + + self._data_final = self._data + self._data_event.set() + + @staticmethod + def _cmd(cmd: int, data: bytearray = bytearray()) -> bytearray: + """Assemble a Seplos BMS command.""" + assert cmd in (0x47, 0x51, 0x61, 0x62, 0x04) # allow only read commands + frame = bytearray([BMS._HEAD, BMS._CMD_VER, 0x0, 0x46, cmd]) # fixed version + frame += len(data).to_bytes(2, "big", signed=False) + data + frame += bytearray(int.to_bytes(crc_xmodem(frame[1:]), 2, byteorder="big")) + frame += bytearray([BMS._TAIL]) + LOGGER.debug("TX cmd: %s", frame.hex(" ")) + return frame + + # @staticmethod + # def _decode_data(data: bytearray) -> dict[str, int | float]: + # result = { + # KEY_CELL_COUNT: data[BMS._CELLS_POS], + # KEY_TEMP_SENS: data[BMS._CELLS_POS + 1 + data[BMS._CELLS_POS] * 2], + # } + + # shift: int = result[KEY_CELL_COUNT] * 2 + BMS._CELLS_POS + 2 + # # cell temperatures + # result |= { + # f"{KEY_TEMP_VALUE}{idx>>1}": ( + # ( + # int.from_bytes(data[idx + shift : idx + shift + 2], byteorder="big") + # - 2731.5 + # ) + # / 10 + # ) + # for idx in range(0, int(result[KEY_TEMP_SENS]) * 2, 2) + # } + + # shift += int(result[KEY_TEMP_SENS]) * 2 + + # result |= { + # key: func( + # int.from_bytes( + # data[idx + shift : idx + shift + size], byteorder="big", signed=sign + # ) + # ) + # for key, _, idx, size, sign, func in BMS._FIELDS + # } + + # return result + + @staticmethod + def _decode_data(data: bytearray) -> dict[str, int | float]: + return { + key: func( + int.from_bytes(data[idx : idx + size], byteorder="big", signed=sign) + ) + for key, idx, size, sign, func in BMS._FIELDS + } + + @staticmethod + def _cell_voltages(data: bytearray, offset: int = 0) -> dict[str, float]: + return { + f"{KEY_CELL_VOLTAGE}{idx+offset}": float( + int.from_bytes( + data[ + BMS._CELLS_POS + 1 + idx * 2 : BMS._CELLS_POS + 1 + idx * 2 + 2 + ], + byteorder="big", + signed=False, + ) + ) + / 1000 + for idx in range(data[BMS._CELLS_POS]) + } + + async def _async_update(self) -> BMSsample: + """Update battery status information.""" + await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(0x62)) + await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + + result = BMS._decode_data(self._data_final) + + total_cells: int = 0 + for pack in range(int(result.get(KEY_CELL_COUNT, 0)) // 16): + await self._client.write_gatt_char( + BMS.uuid_tx(), data=BMS._cmd(0x61, data=bytearray([pack])) + ) + await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + pack_cells: dict[str, float] = BMS._cell_voltages( + self._data_final, total_cells + ) + result |= pack_cells + result |= { + ATTR_DELTA_VOLTAGE: max( + float(result.get(ATTR_DELTA_VOLTAGE, 0)), + round(max(pack_cells.values()) - min(pack_cells.values()), 3), + ) + } + total_cells += self._data_final[BMS._CELLS_POS] + + return result diff --git a/tests/test_const.py b/tests/test_const.py index cc484e7..8446029 100644 --- a/tests/test_const.py +++ b/tests/test_const.py @@ -7,4 +7,4 @@ async def test_critical_constants() -> None: """Test general constants are not altered for debugging.""" assert UPDATE_INTERVAL == 30 # ensure that update interval is 30 seconds - assert len(BMS_TYPES) == 10 # check number of BMS types + assert len(BMS_TYPES) == 11 # check number of BMS types diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py new file mode 100644 index 0000000..c2960ae --- /dev/null +++ b/tests/test_seplos_v2_bms.py @@ -0,0 +1,251 @@ +"""Test the Seplos v2 implementation.""" + +from collections.abc import Buffer +from uuid import UUID + +from bleak.backends.characteristic import BleakGATTCharacteristic + +# from bleak.exc import BleakError +from bleak.uuids import normalize_uuid_str + +from custom_components.bms_ble.plugins.seplos_v2_bms import BMS + +from .bluetooth import generate_ble_device +from .conftest import MockBleakClient + +BT_FRAME_SIZE = 20 + + +class MockSeplosv2BleakClient(MockBleakClient): + """Emulate a Seplos v2 BMS BleakClient.""" + + HEAD_CMD = 0x7E + CMD_GSMD = bytearray([HEAD_CMD]) + bytearray( + b"\x10\x00\x46\x61\x00\x01" + ) # get single machine data + CMD_GPD = bytearray([HEAD_CMD]) + bytearray( + b"\x10\x00\x46\x62\x00\x00" + ) # get parallel data + + def _response( + self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer + ) -> bytearray: + + if ( + isinstance(char_specifier, str) + and normalize_uuid_str(char_specifier) == normalize_uuid_str("ff02") + and bytearray(data)[0] == self.HEAD_CMD + ): + if bytearray(data).startswith(self.CMD_GSMD): + return bytearray( + b"\x7e\x14\x02\x61\x00\x00\x6a\x00\x02\x10\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c" + b"\xf1\x0c\xf0\x0c\xf1\x0c\xf3\x0c\xef\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c\xf0" + b"\x0c\xf1\x0c\xf1\x06\x0b\x8f\x0b\x89\x0b\x8a\x0b\x93\x0b\xc0\x0b\x98\x02\xad" + b"\x14\xb4\x38\x3a\x06\x6d\x60\x02\x02\x6d\x60\x00\x80\x03\xe8\x14\xbb\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc1" + b"\xd7\x0d" + ) + # return bytearray( + # b"\x7E\x10\x00\x61\x00\x00\x6A\x00\x00\x10\x00\x17\x00\x30\x00\x4E\x00\x12\x00" + # b"\x12\x00\x12\x00\x12\x00\x12\x00\x12\x00\x15\x00\x1D\x00\x32\x00\x70\x01\x3B" + # b"\x04\x0D\x00\x0F\xD5\x06\x08\xB7\x08\xB7\x08\xB7\x08\xB7\x0B\xB8\x0B\xB5\x00" + # b"\x00\x02\x4B\x24\xF5\x06\x27\x10\x03\xB2\x27\x10\x00\x00\x03\xE8\x13\x93\x01" + # b"\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x02\x01\x01\x00\x01" + # b"\x01\x00\x00\x00\x01\x20\x00\x08\x12\x8A\x08\x00\x00\x10\x00\x00\x00\x00\x00" + # b"\x00\x44\xCF\x0D" + # ) # TODO: values + if bytearray(data).startswith(self.CMD_GPD): + return bytearray( + b"\x7e\x14\x00\x62\x00\x00\x30\x00\x00\x10\x0c\xf4\x0c\xee\x06\x0b\x93\x0b\x7f" + b"\x0b\xb6\x0b\x8d\x00\xd7\x14\xb4\x11\x14\x07\x20\xd0\x02\x08\x20\xd0\x00\x71" + b"\x03\xe8\x14\xb9\x07\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x76\x31" + b"\x0d" + ) + + return bytearray() + + async def write_gatt_char( + self, + char_specifier: BleakGATTCharacteristic | int | str | UUID, + data: Buffer, + response: bool | None = None, + ) -> None: + """Issue write command to GATT.""" + + assert ( + self._notify_callback + ), "write to characteristics but notification not enabled" + + resp = self._response(char_specifier, data) + for notify_data in [ + resp[i : i + BT_FRAME_SIZE] for i in range(0, len(resp), BT_FRAME_SIZE) + ]: + self._notify_callback("MockSeplosv2BleakClient", notify_data) + + +# class MockInvalidBleakClient(MockSeplosv2BleakClient): +# """Emulate a Seplos V2 BMS BleakClient returning wrong data.""" + +# def _response( +# self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer +# ) -> bytearray: +# if ( +# isinstance(char_specifier, str) +# and normalize_uuid_str(char_specifier) == normalize_uuid_str("ff02") +# and bytearray(data)[0] == self.HEAD_CMD +# ): +# if bytearray(data)[1:3] == self.CMD_INFO: +# return bytearray( # wrong end +# b"\xdd\x03\x00\x1D\x06\x18\xFE\xE1\x01\xF2\x01\xF4\x00\x2A\x2C\x7C\x00\x00\x00" +# b"\x00\x00\x00\x80\x64\x03\x04\x03\x0B\x8B\x0B\x8A\x0B\x84\xf8\x84\xdd" +# ) + +# return ( # wrong CRC +# bytearray(b"\xdd\x03\x00\x1d") + bytearray(31) + bytearray(b"\x77") +# ) + +# return bytearray() + +# async def disconnect(self) -> bool: +# """Mock disconnect to raise BleakError.""" +# raise BleakError + + +# class MockOversizedBleakClient(MockSeplosv2BleakClient): +# """Emulate a Seplos V2 BMS BleakClient returning wrong data length.""" + +# def _response( +# self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer +# ) -> bytearray: +# if ( +# isinstance(char_specifier, str) +# and normalize_uuid_str(char_specifier) == normalize_uuid_str("ff02") +# and bytearray(data)[0] == self.HEAD_CMD +# ): +# if bytearray(data)[1:3] == self.CMD_INFO: +# return bytearray( +# b"\xdd\x03\x00\x1D\x06\x18\xFE\xE1\x01\xF2\x01\xF4\x00\x2A\x2C\x7C\x00\x00\x00" +# b"\x00\x00\x00\x80\x64\x03\x04\x03\x0B\x8B\x0B\x8A\x0B\x84\xf8\x84\x77" +# b"\00\00\00\00\00\00" # oversized response +# ) # {'voltage': 15.6, 'current': -2.87, 'battery_level': 100, 'cycle_charge': 4.98, 'cycles': 42, 'temperature': 22.133333333333347} +# if bytearray(data)[1:3] == self.CMD_CELL: +# return bytearray( +# b"\xdd\x04\x00\x08\x0d\x66\x0d\x61\x0d\x68\x0d\x59\xfe\x3c\x77" +# b"\00\00\00\00\00\00\00\00\00\00\00\00" # oversized response +# ) # {'cell#0': 3.43, 'cell#1': 3.425, 'cell#2': 3.432, 'cell#3': 3.417} + +# return bytearray() + +# async def disconnect(self) -> bool: +# """Mock disconnect to raise BleakError.""" +# raise BleakError + + +async def test_update(monkeypatch, reconnect_fixture) -> None: + """Test Seplos V2 BMS data update.""" + + monkeypatch.setattr( + "custom_components.bms_ble.plugins.basebms.BleakClient", + MockSeplosv2BleakClient, + ) + + bms = BMS( + generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73), + reconnect_fixture, + ) + + result = await bms.async_update() + + assert result == { + "cell_count": 16, + "temp_sensors": 6, + "voltage": 53.0, + "current": 2.15, + "battery_level": 52.0, + "cycle_charge": 43.72, + "cycles": 113, + "temperature": 22.55, + "cycle_capacity": 2317.16, + "power": 113.95, + "battery_charging": True, + "cell#0": 3.312, + "cell#1": 3.313, + "cell#2": 3.313, + "cell#3": 3.313, + "cell#4": 3.313, + "cell#5": 3.312, + "cell#6": 3.313, + "cell#7": 3.315, + "cell#8": 3.311, + "cell#9": 3.312, + "cell#10": 3.313, + "cell#11": 3.313, + "cell#12": 3.313, + "cell#13": 3.312, + "cell#14": 3.313, + "cell#15": 3.313, + "delta_voltage": 0.004, + } + + # query again to check already connected state + result = await bms.async_update() + assert ( + bms._client and bms._client.is_connected is not reconnect_fixture + ) # noqa: SLF001 + + await bms.disconnect() + + +# async def test_invalid_response(monkeypatch) -> None: +# """Test data update with BMS returning invalid data (wrong CRC).""" + +# monkeypatch.setattr( +# "custom_components.bms_ble.plugins.basebms.BleakClient", +# MockInvalidBleakClient, +# ) + +# bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) + +# result = await bms.async_update() + +# assert result == {} + +# await bms.disconnect() + + +# async def test_oversized_response(monkeypatch) -> None: +# """Test data update with BMS returning oversized data, result shall still be ok.""" + +# monkeypatch.setattr( +# "custom_components.bms_ble.plugins.basebms.BleakClient", +# MockOversizedBleakClient, +# ) + +# bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) + +# result = await bms.async_update() + +# assert result == { +# "temp_sensors": 3, +# "voltage": 15.6, +# "current": -2.87, +# "battery_level": 100, +# "cycle_charge": 4.98, +# "cycles": 42, +# "temperature": 22.133, +# "cycle_capacity": 77.688, +# "power": -44.772, +# "battery_charging": False, +# "runtime": 6246, +# "cell#0": 3.43, +# "cell#1": 3.425, +# "cell#2": 3.432, +# "cell#3": 3.417, +# "temp#0": 22.4, +# "temp#1": 22.3, +# "temp#2": 21.7, +# "delta_voltage": 0.015, +# } + +# await bms.disconnect() From 3c9471d79e8b3c1d4b0f28a4e0b0af2d39332f1e Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Tue, 10 Dec 2024 21:30:53 +0100 Subject: [PATCH 03/22] detect battery packs --- .../bms_ble/plugins/seplos_v2_bms.py | 93 +++++++------------ tests/test_seplos_v2_bms.py | 8 ++ 2 files changed, 41 insertions(+), 60 deletions(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 9047cba..4e75c26 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -21,6 +21,7 @@ ATTR_VOLTAGE, KEY_CELL_COUNT, KEY_CELL_VOLTAGE, + KEY_PACK_COUNT, KEY_TEMP_SENS, ) @@ -39,25 +40,21 @@ class BMS(BaseBMS): _RSP_VER: Final[int] = 0x14 _INFO_LEN: Final[int] = 10 # minimal frame length _CELLS_POS: Final[int] = 9 + _MAX_SUBS: Final[int] = 15 _FIELDS: Final[ - list[tuple[str, int, int, bool, Callable[[int], int | float]]] - # ] = [ - # (ATTR_VOLTAGE, 0x61, 2, 2, False, lambda x: float(x / 100)), - # (ATTR_CURRENT, 0x61, 0, 2, True, lambda x: float(x / 100)), - # (ATTR_CYCLE_CHRG, 0x61, 4, 2, False, lambda x: float(x / 100)), - # (ATTR_CYCLES, 0x61, 13, 2, False, lambda x: x), - # (ATTR_BATTERY_LEVEL, 0x61, 9, 2, False, lambda x: float(x / 10)), - # ] # Protocol Seplos V2 (single machine data 0x61) + list[tuple[str, int, int, int, bool, Callable[[int], int | float]]] ] = [ - (ATTR_VOLTAGE, 25, 2, False, lambda x: float(x / 100)), - (ATTR_CURRENT, 23, 2, True, lambda x: float(x / 100)), - (ATTR_CYCLE_CHRG, 27, 2, False, lambda x: float(x / 100)), - (ATTR_CYCLES, 36, 2, False, lambda x: x), - (ATTR_BATTERY_LEVEL, 32, 2, False, lambda x: float(x / 10)), - (ATTR_TEMPERATURE, 21, 2, True, lambda x: (x-2731.5)/10), - (KEY_CELL_COUNT, 9, 1, False, lambda x: x), - (KEY_TEMP_SENS, 14, 1, False, lambda x: x), + (ATTR_VOLTAGE, 0x62, 25, 2, False, lambda x: float(x / 100)), + (ATTR_CURRENT, 0x62, 23, 2, True, lambda x: float(x / 100)), + (ATTR_CYCLE_CHRG, 0x62, 27, 2, False, lambda x: float(x / 100)), + (ATTR_CYCLES, 0x62, 36, 2, False, lambda x: x), + (ATTR_BATTERY_LEVEL, 0x62, 32, 2, False, lambda x: float(x / 10)), + (ATTR_TEMPERATURE, 0x62, 21, 2, True, lambda x: (x - 2731.5) / 10), + (KEY_CELL_COUNT, 0x62, 9, 1, False, lambda x: x), + (KEY_PACK_COUNT, 0x51, 42, 1, False, lambda x: min(x, BMS._MAX_SUBS)), + (KEY_TEMP_SENS, 0x62, 14, 1, False, lambda x: x), ] # Protocol Seplos V2 (parallel data 0x62) + _CMDS: Final[list[int]] = list({field[1] for field in _FIELDS}) def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: """Initialize BMS.""" @@ -65,7 +62,7 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: super().__init__(LOGGER, self._notification_handler, ble_device, reconnect) self._data: bytearray = bytearray() self._exp_len: int = 0 - self._data_final: bytearray = bytearray() + self._data_final: dict[int, bytearray] = {} @staticmethod def matcher_dict_list() -> list[dict[str, Any]]: @@ -86,7 +83,7 @@ def device_info() -> dict[str, str]: @staticmethod def uuid_services() -> list[str]: """Return list of 128-bit UUIDs of services required by BMS.""" - return [normalize_uuid_str("ff00")] # change service UUID here! + return [normalize_uuid_str("ff00")] @staticmethod def uuid_rx() -> str: @@ -163,7 +160,7 @@ def _notification_handler(self, _sender, data: bytearray) -> None: self._data[4], ) - self._data_final = self._data + self._data_final[self._data[3]] = self._data self._data_event.set() @staticmethod @@ -177,46 +174,15 @@ def _cmd(cmd: int, data: bytearray = bytearray()) -> bytearray: LOGGER.debug("TX cmd: %s", frame.hex(" ")) return frame - # @staticmethod - # def _decode_data(data: bytearray) -> dict[str, int | float]: - # result = { - # KEY_CELL_COUNT: data[BMS._CELLS_POS], - # KEY_TEMP_SENS: data[BMS._CELLS_POS + 1 + data[BMS._CELLS_POS] * 2], - # } - - # shift: int = result[KEY_CELL_COUNT] * 2 + BMS._CELLS_POS + 2 - # # cell temperatures - # result |= { - # f"{KEY_TEMP_VALUE}{idx>>1}": ( - # ( - # int.from_bytes(data[idx + shift : idx + shift + 2], byteorder="big") - # - 2731.5 - # ) - # / 10 - # ) - # for idx in range(0, int(result[KEY_TEMP_SENS]) * 2, 2) - # } - - # shift += int(result[KEY_TEMP_SENS]) * 2 - - # result |= { - # key: func( - # int.from_bytes( - # data[idx + shift : idx + shift + size], byteorder="big", signed=sign - # ) - # ) - # for key, _, idx, size, sign, func in BMS._FIELDS - # } - - # return result - @staticmethod - def _decode_data(data: bytearray) -> dict[str, int | float]: + def _decode_data(data: dict[int, bytearray]) -> dict[str, int | float]: return { key: func( - int.from_bytes(data[idx : idx + size], byteorder="big", signed=sign) + int.from_bytes( + data[cmd][idx : idx + size], byteorder="big", signed=sign + ) ) - for key, idx, size, sign, func in BMS._FIELDS + for key, cmd, idx, size, sign, func in BMS._FIELDS } @staticmethod @@ -237,19 +203,24 @@ def _cell_voltages(data: bytearray, offset: int = 0) -> dict[str, float]: async def _async_update(self) -> BMSsample: """Update battery status information.""" - await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(0x62)) - await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + + for cmd in BMS._CMDS: + await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(cmd)) + await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + # check if a valid frame was received otherwise terminate immediately + if cmd not in self._data_final: + return {} result = BMS._decode_data(self._data_final) total_cells: int = 0 - for pack in range(int(result.get(KEY_CELL_COUNT, 0)) // 16): + for pack in range(int(result.get(KEY_PACK_COUNT, 0) + 1)): await self._client.write_gatt_char( BMS.uuid_tx(), data=BMS._cmd(0x61, data=bytearray([pack])) ) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) pack_cells: dict[str, float] = BMS._cell_voltages( - self._data_final, total_cells + self._data_final[0x61], total_cells ) result |= pack_cells result |= { @@ -258,6 +229,8 @@ async def _async_update(self) -> BMSsample: round(max(pack_cells.values()) - min(pack_cells.values()), 3), ) } - total_cells += self._data_final[BMS._CELLS_POS] + total_cells += self._data_final[0x61][BMS._CELLS_POS] + + self._data_final.clear() return result diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index c2960ae..2d86d5b 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -26,6 +26,7 @@ class MockSeplosv2BleakClient(MockBleakClient): CMD_GPD = bytearray([HEAD_CMD]) + bytearray( b"\x10\x00\x46\x62\x00\x00" ) # get parallel data + CMD_GMI = bytearray([HEAD_CMD]) + bytearray(b"\x10\x00\x46\x51\x00\x00\x3A\x7F\x0D") def _response( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer @@ -62,6 +63,12 @@ def _response( b"\x03\xe8\x14\xb9\x07\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x76\x31" b"\x0d" ) + if bytearray(data).startswith(self.CMD_GMI): + return bytearray( + b"\x7E\x14\x00\x51\x00\x00\x24\x43\x41\x4E\x3A\x50\x4E\x47\x5F\x44\x59\x45\x5F" + b"\x4C\x75\x78\x70\x5F\x54\x42\x42\x45\x4D\x55\x31\x31\x30\x31\x31\x30\x45\x10" + b"\x04\x01\x01\x46\x02\x14\xE2\x58\x0D" + ) return bytearray() @@ -186,6 +193,7 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cell#14": 3.313, "cell#15": 3.313, "delta_voltage": 0.004, + "pack_count": 2, } # query again to check already connected state From a040feb06ffcbed8addb454bb5d819a3ebd49386 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 11 Dec 2024 14:56:13 +0100 Subject: [PATCH 04/22] disabled JBD Seplos wrongly detected as JBD --- custom_components/bms_ble/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/const.py b/custom_components/bms_ble/const.py index 62fb0df..bb4974e 100644 --- a/custom_components/bms_ble/const.py +++ b/custom_components/bms_ble/const.py @@ -21,7 +21,7 @@ "seplos_bms", "seplos_v2_bms", "dpwrcore_bms", # only name filter - "jbd_bms", # only UUID filter +# "jbd_bms", # only UUID filter ] # available BMS types DOMAIN: Final = "bms_ble" LOGGER: Final = logging.getLogger(__package__) From 04c46c90e7c7bc5585e230b80c716b5abad61a1b Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Thu, 12 Dec 2024 15:02:33 +0100 Subject: [PATCH 05/22] fixed current output --- custom_components/bms_ble/plugins/seplos_v2_bms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 4e75c26..9b0c4b7 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -45,7 +45,7 @@ class BMS(BaseBMS): list[tuple[str, int, int, int, bool, Callable[[int], int | float]]] ] = [ (ATTR_VOLTAGE, 0x62, 25, 2, False, lambda x: float(x / 100)), - (ATTR_CURRENT, 0x62, 23, 2, True, lambda x: float(x / 100)), + (ATTR_CURRENT, 0x62, 23, 2, True, lambda x: float(x / 10)), (ATTR_CYCLE_CHRG, 0x62, 27, 2, False, lambda x: float(x / 100)), (ATTR_CYCLES, 0x62, 36, 2, False, lambda x: x), (ATTR_BATTERY_LEVEL, 0x62, 32, 2, False, lambda x: float(x / 10)), From f4e3e2c7f2f41b22945b72c97a1af98e58eb5dca Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Thu, 12 Dec 2024 20:12:01 +0100 Subject: [PATCH 06/22] fixed cycle charge factor --- custom_components/bms_ble/plugins/seplos_v2_bms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 9b0c4b7..7d4b2fb 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -46,7 +46,7 @@ class BMS(BaseBMS): ] = [ (ATTR_VOLTAGE, 0x62, 25, 2, False, lambda x: float(x / 100)), (ATTR_CURRENT, 0x62, 23, 2, True, lambda x: float(x / 10)), - (ATTR_CYCLE_CHRG, 0x62, 27, 2, False, lambda x: float(x / 100)), + (ATTR_CYCLE_CHRG, 0x62, 27, 2, False, lambda x: float(x / 10)), (ATTR_CYCLES, 0x62, 36, 2, False, lambda x: x), (ATTR_BATTERY_LEVEL, 0x62, 32, 2, False, lambda x: float(x / 10)), (ATTR_TEMPERATURE, 0x62, 21, 2, True, lambda x: (x - 2731.5) / 10), From ea55f83b406a010c01a8af625830d3da9a8f8ae8 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 14 Dec 2024 16:55:23 +0100 Subject: [PATCH 07/22] fixed incorrect advertisement by BMS --- custom_components/bms_ble/manifest.json | 4 +--- custom_components/bms_ble/plugins/seplos_v2_bms.py | 3 +-- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 2879345..96a2243 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -52,9 +52,7 @@ "manufacturer_id": 65535 }, { - "local_name": "BP00", - "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" - + "local_name": "BP00*" } ], "codeowners": ["@patman15"], diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 7d4b2fb..358a257 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -69,8 +69,7 @@ def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" return [ { - "local_name": "BP00", - "service_uuid": BMS.uuid_services()[0], + "local_name": "BP00*", "connectable": True, } ] From 855d9a5062a10c2f8698dbfcf68f1bcd5c1b6992 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 14 Dec 2024 17:05:09 +0100 Subject: [PATCH 08/22] Update seplos_v2_bms.py --- custom_components/bms_ble/plugins/seplos_v2_bms.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 358a257..7290288 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -67,12 +67,9 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: @staticmethod def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" - return [ - { - "local_name": "BP00*", - "connectable": True, - } - ] + # advertisement has incorrect name length -> detection only for name + # '05 09 42 50 30 30 02 01 05 03 03 00 ff' + return [{"local_name": "BP00*", "connectable": True}] @staticmethod def device_info() -> dict[str, str]: From a4f72017ea1c1e53fba87dcbc84b5fc5e89a4fb0 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 14 Dec 2024 18:15:09 +0100 Subject: [PATCH 09/22] avoid detecting Seplos v2 as JBD --- custom_components/bms_ble/manifest.json | 1 + custom_components/bms_ble/plugins/jbd_bms.py | 1 + 2 files changed, 2 insertions(+) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 96a2243..bf5d9da 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -19,6 +19,7 @@ "manufacturer_id": 2917 }, { + "local_name": "[!B]*", "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" }, { diff --git a/custom_components/bms_ble/plugins/jbd_bms.py b/custom_components/bms_ble/plugins/jbd_bms.py index 9eb94af..af451cb 100644 --- a/custom_components/bms_ble/plugins/jbd_bms.py +++ b/custom_components/bms_ble/plugins/jbd_bms.py @@ -58,6 +58,7 @@ def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" return [ { + "local_name": "[!B]*", # avoid confusion with Seplos v2 "service_uuid": BMS.uuid_services()[0], "connectable": True, }, From 56abcf4ecbf1e5da56717d650ee182fccdf60b92 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 14 Dec 2024 18:15:38 +0100 Subject: [PATCH 10/22] fixed tests --- custom_components/bms_ble/const.py | 42 +++++++++---------- .../bms_ble/plugins/seplos_v2_bms.py | 11 ++--- tests/test_seplos_v2_bms.py | 8 ++-- 3 files changed, 29 insertions(+), 32 deletions(-) diff --git a/custom_components/bms_ble/const.py b/custom_components/bms_ble/const.py index bb4974e..38a40d9 100644 --- a/custom_components/bms_ble/const.py +++ b/custom_components/bms_ble/const.py @@ -10,7 +10,7 @@ ATTR_VOLTAGE, ) -BMS_TYPES: Final = [ +BMS_TYPES: Final[list[str]] = [ "cbtpwr_bms", "daly_bms", "ective_bms", @@ -21,29 +21,29 @@ "seplos_bms", "seplos_v2_bms", "dpwrcore_bms", # only name filter -# "jbd_bms", # only UUID filter + "jbd_bms", # only UUID filter ] # available BMS types -DOMAIN: Final = "bms_ble" +DOMAIN: Final[str] = "bms_ble" LOGGER: Final = logging.getLogger(__package__) -UPDATE_INTERVAL: Final = 30 # [s] +UPDATE_INTERVAL: Final[int] = 30 # [s] # attributes (do not change) -ATTR_CELL_VOLTAGES: Final = "cell_voltages" # [V] -ATTR_CURRENT: Final = "current" # [A] -ATTR_CYCLE_CAP: Final = "cycle_capacity" # [Wh] -ATTR_CYCLE_CHRG: Final = "cycle_charge" # [Ah] -ATTR_CYCLES: Final = "cycles" # [#] -ATTR_DELTA_VOLTAGE: Final = "delta_voltage" # [V] -ATTR_LQ: Final = "link_quality" # [%] -ATTR_POWER: Final = "power" # [W] -ATTR_RSSI: Final = "rssi" # [dBm] -ATTR_RUNTIME: Final = "runtime" # [s] -ATTR_TEMP_SENSORS: Final = "temperature_sensors" # [°C] +ATTR_CELL_VOLTAGES: Final[str] = "cell_voltages" # [V] +ATTR_CURRENT: Final[str] = "current" # [A] +ATTR_CYCLE_CAP: Final[str] = "cycle_capacity" # [Wh] +ATTR_CYCLE_CHRG: Final[str] = "cycle_charge" # [Ah] +ATTR_CYCLES: Final[str] = "cycles" # [#] +ATTR_DELTA_VOLTAGE: Final[str] = "delta_voltage" # [V] +ATTR_LQ: Final[str] = "link_quality" # [%] +ATTR_POWER: Final[str] = "power" # [W] +ATTR_RSSI: Final[str] = "rssi" # [dBm] +ATTR_RUNTIME: Final[str] = "runtime" # [s] +ATTR_TEMP_SENSORS: Final[str] = "temperature_sensors" # [°C] # temporary dictionary keys (do not change) -KEY_CELL_COUNT: Final = "cell_count" # [#] -KEY_CELL_VOLTAGE: Final = "cell#" # [V] -KEY_DESIGN_CAP: Final = "design_capacity" # [Ah] -KEY_PACK_COUNT: Final = "pack_count" # [#] -KEY_TEMP_SENS: Final = "temp_sensors" # [#] -KEY_TEMP_VALUE: Final = "temp#" # [°C] +KEY_CELL_COUNT: Final[str] = "cell_count" # [#] +KEY_CELL_VOLTAGE: Final[str] = "cell#" # [V] +KEY_DESIGN_CAP: Final[str] = "design_capacity" # [Ah] +KEY_PACK_COUNT: Final[str] = "pack_count" # [#] +KEY_TEMP_SENS: Final[str] = "temp_sensors" # [#] +KEY_TEMP_VALUE: Final[str] = "temp#" # [°C] diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 7290288..ffb5327 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -39,7 +39,6 @@ class BMS(BaseBMS): _CMD_VER: Final[int] = 0x10 _RSP_VER: Final[int] = 0x14 _INFO_LEN: Final[int] = 10 # minimal frame length - _CELLS_POS: Final[int] = 9 _MAX_SUBS: Final[int] = 15 _FIELDS: Final[ list[tuple[str, int, int, int, bool, Callable[[int], int | float]]] @@ -186,15 +185,13 @@ def _cell_voltages(data: bytearray, offset: int = 0) -> dict[str, float]: return { f"{KEY_CELL_VOLTAGE}{idx+offset}": float( int.from_bytes( - data[ - BMS._CELLS_POS + 1 + idx * 2 : BMS._CELLS_POS + 1 + idx * 2 + 2 - ], + data[10 + idx * 2 : 10 + idx * 2 + 2], byteorder="big", signed=False, ) ) / 1000 - for idx in range(data[BMS._CELLS_POS]) + for idx in range(data[9]) } async def _async_update(self) -> BMSsample: @@ -207,7 +204,7 @@ async def _async_update(self) -> BMSsample: if cmd not in self._data_final: return {} - result = BMS._decode_data(self._data_final) + result: BMSsample = BMS._decode_data(self._data_final) total_cells: int = 0 for pack in range(int(result.get(KEY_PACK_COUNT, 0) + 1)): @@ -225,7 +222,7 @@ async def _async_update(self) -> BMSsample: round(max(pack_cells.values()) - min(pack_cells.values()), 3), ) } - total_cells += self._data_final[0x61][BMS._CELLS_POS] + total_cells += self._data_final[0x61][9] self._data_final.clear() diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index 2d86d5b..08d71c0 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -168,13 +168,13 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cell_count": 16, "temp_sensors": 6, "voltage": 53.0, - "current": 2.15, + "current": 21.5, "battery_level": 52.0, - "cycle_charge": 43.72, + "cycle_charge": 437.2, "cycles": 113, "temperature": 22.55, - "cycle_capacity": 2317.16, - "power": 113.95, + "cycle_capacity": 23171.6, + "power": 1139.5, "battery_charging": True, "cell#0": 3.312, "cell#1": 3.313, From 3a68ddbaaedded8929c051794f98645075b2cbe2 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 14 Dec 2024 21:03:31 +0100 Subject: [PATCH 11/22] Revert "avoid detecting Seplos v2 as JBD" This reverts commit a4f72017ea1c1e53fba87dcbc84b5fc5e89a4fb0. --- custom_components/bms_ble/manifest.json | 1 - custom_components/bms_ble/plugins/jbd_bms.py | 1 - 2 files changed, 2 deletions(-) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index bf5d9da..96a2243 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -19,7 +19,6 @@ "manufacturer_id": 2917 }, { - "local_name": "[!B]*", "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" }, { diff --git a/custom_components/bms_ble/plugins/jbd_bms.py b/custom_components/bms_ble/plugins/jbd_bms.py index af451cb..9eb94af 100644 --- a/custom_components/bms_ble/plugins/jbd_bms.py +++ b/custom_components/bms_ble/plugins/jbd_bms.py @@ -58,7 +58,6 @@ def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" return [ { - "local_name": "[!B]*", # avoid confusion with Seplos v2 "service_uuid": BMS.uuid_services()[0], "connectable": True, }, From 6d3c8819be5abb8bf404823023abdc8d51b064a3 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 14 Dec 2024 21:24:46 +0100 Subject: [PATCH 12/22] detailed BT matcher for JBD --- custom_components/bms_ble/manifest.json | 9 +++++++++ custom_components/bms_ble/plugins/jbd_bms.py | 11 +++++++++++ 2 files changed, 20 insertions(+) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 2b6c47c..baf13b7 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -15,6 +15,15 @@ "manufacturer_id": 2917 }, { + "local_name": "SP0?S*", + "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" + }, + { + "local_name": "SP1?S*", + "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" + }, + { + "local_name": "SP2?S*", "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" }, { diff --git a/custom_components/bms_ble/plugins/jbd_bms.py b/custom_components/bms_ble/plugins/jbd_bms.py index db00864..6e49d67 100644 --- a/custom_components/bms_ble/plugins/jbd_bms.py +++ b/custom_components/bms_ble/plugins/jbd_bms.py @@ -58,6 +58,17 @@ def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" return [ { + "local_name": "SP0?S*", + "service_uuid": BMS.uuid_services()[0], + "connectable": True, + }, + { + "local_name": "SP1?S*", + "service_uuid": BMS.uuid_services()[0], + "connectable": True, + }, + { + "local_name": "SP2?S*", "service_uuid": BMS.uuid_services()[0], "connectable": True, }, From 2a1e657770e20786d91fb062ff9abd67d0979ffd Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 15 Dec 2024 09:24:00 +0100 Subject: [PATCH 13/22] completed tests --- .../bms_ble/plugins/seplos_v2_bms.py | 3 - tests/test_seplos_v2_bms.py | 182 +++++++----------- 2 files changed, 68 insertions(+), 117 deletions(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index ffb5327..ba41ad0 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -200,9 +200,6 @@ async def _async_update(self) -> BMSsample: for cmd in BMS._CMDS: await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(cmd)) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - # check if a valid frame was received otherwise terminate immediately - if cmd not in self._data_final: - return {} result: BMSsample = BMS._decode_data(self._data_final) diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index 08d71c0..5e61b1a 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -4,9 +4,8 @@ from uuid import UUID from bleak.backends.characteristic import BleakGATTCharacteristic - -# from bleak.exc import BleakError from bleak.uuids import normalize_uuid_str +import pytest from custom_components.bms_ble.plugins.seplos_v2_bms import BMS @@ -46,16 +45,7 @@ def _response( b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" b"\x00\x00\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc1" b"\xd7\x0d" - ) - # return bytearray( - # b"\x7E\x10\x00\x61\x00\x00\x6A\x00\x00\x10\x00\x17\x00\x30\x00\x4E\x00\x12\x00" - # b"\x12\x00\x12\x00\x12\x00\x12\x00\x12\x00\x15\x00\x1D\x00\x32\x00\x70\x01\x3B" - # b"\x04\x0D\x00\x0F\xD5\x06\x08\xB7\x08\xB7\x08\xB7\x08\xB7\x0B\xB8\x0B\xB5\x00" - # b"\x00\x02\x4B\x24\xF5\x06\x27\x10\x03\xB2\x27\x10\x00\x00\x03\xE8\x13\x93\x01" - # b"\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x01\x02\x01\x01\x00\x01" - # b"\x01\x00\x00\x00\x01\x20\x00\x08\x12\x8A\x08\x00\x00\x10\x00\x00\x00\x00\x00" - # b"\x00\x44\xCF\x0D" - # ) # TODO: values + ) # TODO: values if bytearray(data).startswith(self.CMD_GPD): return bytearray( b"\x7e\x14\x00\x62\x00\x00\x30\x00\x00\x10\x0c\xf4\x0c\xee\x06\x0b\x93\x0b\x7f" @@ -91,64 +81,6 @@ async def write_gatt_char( self._notify_callback("MockSeplosv2BleakClient", notify_data) -# class MockInvalidBleakClient(MockSeplosv2BleakClient): -# """Emulate a Seplos V2 BMS BleakClient returning wrong data.""" - -# def _response( -# self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer -# ) -> bytearray: -# if ( -# isinstance(char_specifier, str) -# and normalize_uuid_str(char_specifier) == normalize_uuid_str("ff02") -# and bytearray(data)[0] == self.HEAD_CMD -# ): -# if bytearray(data)[1:3] == self.CMD_INFO: -# return bytearray( # wrong end -# b"\xdd\x03\x00\x1D\x06\x18\xFE\xE1\x01\xF2\x01\xF4\x00\x2A\x2C\x7C\x00\x00\x00" -# b"\x00\x00\x00\x80\x64\x03\x04\x03\x0B\x8B\x0B\x8A\x0B\x84\xf8\x84\xdd" -# ) - -# return ( # wrong CRC -# bytearray(b"\xdd\x03\x00\x1d") + bytearray(31) + bytearray(b"\x77") -# ) - -# return bytearray() - -# async def disconnect(self) -> bool: -# """Mock disconnect to raise BleakError.""" -# raise BleakError - - -# class MockOversizedBleakClient(MockSeplosv2BleakClient): -# """Emulate a Seplos V2 BMS BleakClient returning wrong data length.""" - -# def _response( -# self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer -# ) -> bytearray: -# if ( -# isinstance(char_specifier, str) -# and normalize_uuid_str(char_specifier) == normalize_uuid_str("ff02") -# and bytearray(data)[0] == self.HEAD_CMD -# ): -# if bytearray(data)[1:3] == self.CMD_INFO: -# return bytearray( -# b"\xdd\x03\x00\x1D\x06\x18\xFE\xE1\x01\xF2\x01\xF4\x00\x2A\x2C\x7C\x00\x00\x00" -# b"\x00\x00\x00\x80\x64\x03\x04\x03\x0B\x8B\x0B\x8A\x0B\x84\xf8\x84\x77" -# b"\00\00\00\00\00\00" # oversized response -# ) # {'voltage': 15.6, 'current': -2.87, 'battery_level': 100, 'cycle_charge': 4.98, 'cycles': 42, 'temperature': 22.133333333333347} -# if bytearray(data)[1:3] == self.CMD_CELL: -# return bytearray( -# b"\xdd\x04\x00\x08\x0d\x66\x0d\x61\x0d\x68\x0d\x59\xfe\x3c\x77" -# b"\00\00\00\00\00\00\00\00\00\00\00\00" # oversized response -# ) # {'cell#0': 3.43, 'cell#1': 3.425, 'cell#2': 3.432, 'cell#3': 3.417} - -# return bytearray() - -# async def disconnect(self) -> bool: -# """Mock disconnect to raise BleakError.""" -# raise BleakError - - async def test_update(monkeypatch, reconnect_fixture) -> None: """Test Seplos V2 BMS data update.""" @@ -192,6 +124,38 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cell#13": 3.312, "cell#14": 3.313, "cell#15": 3.313, + "cell#16": 3.312, + "cell#17": 3.313, + "cell#18": 3.313, + "cell#19": 3.313, + "cell#20": 3.313, + "cell#21": 3.312, + "cell#22": 3.313, + "cell#23": 3.315, + "cell#24": 3.311, + "cell#25": 3.312, + "cell#26": 3.313, + "cell#27": 3.313, + "cell#28": 3.313, + "cell#29": 3.312, + "cell#30": 3.313, + "cell#31": 3.313, + "cell#32": 3.312, + "cell#33": 3.313, + "cell#34": 3.313, + "cell#35": 3.313, + "cell#36": 3.313, + "cell#37": 3.312, + "cell#38": 3.313, + "cell#39": 3.315, + "cell#40": 3.311, + "cell#41": 3.312, + "cell#42": 3.313, + "cell#43": 3.313, + "cell#44": 3.313, + "cell#45": 3.312, + "cell#46": 3.313, + "cell#47": 3.313, "delta_voltage": 0.004, "pack_count": 2, } @@ -205,55 +169,45 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: await bms.disconnect() -# async def test_invalid_response(monkeypatch) -> None: -# """Test data update with BMS returning invalid data (wrong CRC).""" - -# monkeypatch.setattr( -# "custom_components.bms_ble.plugins.basebms.BleakClient", -# MockInvalidBleakClient, -# ) +@pytest.fixture( + name="wrong_response", + params=[ + b"\x7E\x14\x00\x51\x00\x00\x01\x00\x7A\xEF\x00", # invalid frame end + b"\x7E\x10\x00\x51\x00\x00\x01\x00\xBB\x29\x0D", # invalid version + b"\x7E\x14\x00\x51\x80\x00\x01\x00\xA7\xD7\x0D", # error response + b"\x7E\x14\x00\x51\x00\x00\x01\x00\x7A\xEE\x0D", # invalid CRC + b"\x7E\x14\x00\x51\x00\x00\x01\x00\x7A\xEF\x0D\x00", # oversized frame + b"\x7E\x14\x00\x51\x00\x00\x02\x00\x7A\xEF\x0D", # undersized frame + ], +) +def response(request): + """Return all possible BMS variants.""" + return request.param -# bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) -# result = await bms.async_update() +async def test_invalid_response(monkeypatch, wrong_response) -> None: + """Test data up date with BMS returning invalid data.""" -# assert result == {} - -# await bms.disconnect() - - -# async def test_oversized_response(monkeypatch) -> None: -# """Test data update with BMS returning oversized data, result shall still be ok.""" + monkeypatch.setattr( + "custom_components.bms_ble.plugins.seplos_v2_bms.BAT_TIMEOUT", + 0.1, + ) -# monkeypatch.setattr( -# "custom_components.bms_ble.plugins.basebms.BleakClient", -# MockOversizedBleakClient, -# ) + monkeypatch.setattr( + "tests.test_seplos_v2_bms.MockSeplosv2BleakClient._response", + lambda _s, _c_, d: wrong_response, + ) -# bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) + monkeypatch.setattr( + "custom_components.bms_ble.plugins.basebms.BleakClient", + MockSeplosv2BleakClient, + ) -# result = await bms.async_update() + bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEDevice", None, -73)) -# assert result == { -# "temp_sensors": 3, -# "voltage": 15.6, -# "current": -2.87, -# "battery_level": 100, -# "cycle_charge": 4.98, -# "cycles": 42, -# "temperature": 22.133, -# "cycle_capacity": 77.688, -# "power": -44.772, -# "battery_charging": False, -# "runtime": 6246, -# "cell#0": 3.43, -# "cell#1": 3.425, -# "cell#2": 3.432, -# "cell#3": 3.417, -# "temp#0": 22.4, -# "temp#1": 22.3, -# "temp#2": 21.7, -# "delta_voltage": 0.015, -# } + result = {} + with pytest.raises(TimeoutError): + result = await bms.async_update() -# await bms.disconnect() + assert not result + await bms.disconnect() From 6e444da5664f7904d31484211e263fc38897cf79 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 15 Dec 2024 09:32:02 +0100 Subject: [PATCH 14/22] Update README.md --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 0e7f3c7..c9004c3 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management - JK BMS, Jikong, (HW version >=11 required) - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…) - LiTime, Redodo batteries -- Seplos v2 (show up as `BP0`…) +- Seplos v2 (show up as `BP00`…) - Seplos v3 (show up as `SP0`… or `SP1`…) New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details. From 4f11c641df81f8e9d662ace1e0148380d2695fdf Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 15 Dec 2024 09:58:11 +0100 Subject: [PATCH 15/22] corrected modbus CRC function --- custom_components/bms_ble/plugins/basebms.py | 2 +- custom_components/bms_ble/plugins/daly_bms.py | 4 ++-- .../bms_ble/plugins/seplos_bms.py | 20 +++++++++---------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/custom_components/bms_ble/plugins/basebms.py b/custom_components/bms_ble/plugins/basebms.py index bded3b6..66cd2a1 100644 --- a/custom_components/bms_ble/plugins/basebms.py +++ b/custom_components/bms_ble/plugins/basebms.py @@ -236,7 +236,7 @@ def crc_modbus(data: bytearray) -> int: crc ^= i & 0xFF for _ in range(8): crc = (crc >> 1) ^ 0xA001 if crc % 2 else (crc >> 1) - return ((0xFF00 & crc) >> 8) | ((crc & 0xFF) << 8) + return crc & 0xFFFF def crc_xmodem(data: bytearray) -> int: """Calculate CRC-16-CCITT XMODEM.""" diff --git a/custom_components/bms_ble/plugins/daly_bms.py b/custom_components/bms_ble/plugins/daly_bms.py index 3b4e646..b5ec81f 100644 --- a/custom_components/bms_ble/plugins/daly_bms.py +++ b/custom_components/bms_ble/plugins/daly_bms.py @@ -108,11 +108,11 @@ def _notification_handler(self, _sender, data: bytearray) -> None: len(data) < BMS.HEAD_LEN or data[0:2] != BMS.HEAD_READ or int(data[2]) + 1 != len(data) - len(BMS.HEAD_READ) - BMS.CRC_LEN - or int.from_bytes(data[-2:], byteorder="big") != crc_modbus(data[:-2]) + or int.from_bytes(data[-2:], byteorder="little") != crc_modbus(data[:-2]) ): LOGGER.debug( "Response data is invalid, CRC: 0x%X != 0x%X", - int.from_bytes(data[-2:], byteorder="big"), + int.from_bytes(data[-2:], byteorder="little"), crc_modbus(data[:-2]), ) self._data = None diff --git a/custom_components/bms_ble/plugins/seplos_bms.py b/custom_components/bms_ble/plugins/seplos_bms.py index c1408b9..2fd4b95 100644 --- a/custom_components/bms_ble/plugins/seplos_bms.py +++ b/custom_components/bms_ble/plugins/seplos_bms.py @@ -70,7 +70,7 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: """Intialize private BMS members.""" super().__init__(LOGGER, self._notification_handler, ble_device, reconnect) self._data: bytearray = bytearray() - self._exp_len: int = 0 # expected packet length + self._pkglen: int = 0 # expected packet length self._data_final: dict[int, bytearray] = {} self._pack_count = 0 self._char_write_handle: int | None = None @@ -129,7 +129,7 @@ def _notification_handler(self, _sender, data: bytearray) -> None: and data[2] >= BMS.HEAD_LEN + BMS.CRC_LEN ): self._data = bytearray() - self._exp_len = data[2] + BMS.HEAD_LEN + BMS.CRC_LEN + self._pkglen = data[2] + BMS.HEAD_LEN + BMS.CRC_LEN elif ( # error message len(data) == BMS.HEAD_LEN + BMS.CRC_LEN and data[0] <= self._pack_count @@ -137,7 +137,7 @@ def _notification_handler(self, _sender, data: bytearray) -> None: ): LOGGER.debug("%s: RX BLE error: %X", self._ble_device.name, int(data[2])) self._data = bytearray() - self._exp_len = BMS.HEAD_LEN + BMS.CRC_LEN + self._pkglen = BMS.HEAD_LEN + BMS.CRC_LEN self._data += data LOGGER.debug( @@ -148,15 +148,15 @@ def _notification_handler(self, _sender, data: bytearray) -> None: ) # verify that data long enough - if len(self._data) < self._exp_len: + if len(self._data) < self._pkglen: return - crc = crc_modbus(self._data[: self._exp_len - 2]) - if int.from_bytes(self._data[self._exp_len - 2 : self._exp_len]) != crc: + crc = crc_modbus(self._data[: self._pkglen - 2]) + if int.from_bytes(self._data[self._pkglen - 2 : self._pkglen], "little") != crc: LOGGER.debug( "%s: RX data CRC is invalid: 0x%X != 0x%X", self._ble_device.name, - int.from_bytes(self._data[self._exp_len - 2 : self._exp_len]), + int.from_bytes(self._data[self._pkglen - 2 : self._pkglen], "little"), crc, ) self._data_final[int(self._data[0])] = bytearray() # reset invalid data @@ -174,12 +174,12 @@ def _notification_handler(self, _sender, data: bytearray) -> None: return else: self._data_final[int(self._data[0]) << 8 | int(self._data[2])] = self._data - if len(self._data) != self._exp_len: + if len(self._data) != self._pkglen: LOGGER.debug( "%s: Wrong data length (%i!=%s): %s", self._ble_device.name, len(self._data), - self._exp_len, + self._pkglen, self._data, ) @@ -203,7 +203,7 @@ def _cmd(device: int, cmd: int, start: int, count: int) -> bytearray: frame = bytearray([device, cmd]) frame += bytearray(int.to_bytes(start, 2, byteorder="big")) frame += bytearray(int.to_bytes(count, 2, byteorder="big")) - frame += bytearray(int.to_bytes(crc_modbus(frame), 2, byteorder="big")) + frame += bytearray(int.to_bytes(crc_modbus(frame), 2, byteorder="little")) return frame async def _async_update(self) -> BMSsample: From 67151b23fd042284acba5796ee34e180d60e32a3 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 15 Dec 2024 13:06:59 +0100 Subject: [PATCH 16/22] fixed detection again --- custom_components/bms_ble/manifest.json | 5 ++-- .../bms_ble/plugins/seplos_v2_bms.py | 27 ++++++++++--------- tests/test_seplos_v2_bms.py | 2 +- 3 files changed, 18 insertions(+), 16 deletions(-) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 77aed42..2d95f3e 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -69,7 +69,8 @@ "manufacturer_id": 65535 }, { - "local_name": "BP00*" + "local_name": "BP00", + "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" } ], "codeowners": ["@patman15"], @@ -81,5 +82,5 @@ "issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues", "loggers": ["bleak_retry_connector"], "requirements": [], - "version": "1.10.0" + "version": "1.11.0" } diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index ba41ad0..a977080 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -28,7 +28,7 @@ from .basebms import BaseBMS, BMSsample, crc_xmodem LOGGER = logging.getLogger(__name__) -BAT_TIMEOUT = 5 +BAT_TIMEOUT = 10 class BMS(BaseBMS): @@ -38,8 +38,8 @@ class BMS(BaseBMS): _TAIL: Final[int] = 0x0D _CMD_VER: Final[int] = 0x10 _RSP_VER: Final[int] = 0x14 - _INFO_LEN: Final[int] = 10 # minimal frame length - _MAX_SUBS: Final[int] = 15 + _MIN_LEN: Final[int] = 10 + _MAX_SUBS: Final[int] = 0xF _FIELDS: Final[ list[tuple[str, int, int, int, bool, Callable[[int], int | float]]] ] = [ @@ -52,7 +52,7 @@ class BMS(BaseBMS): (KEY_CELL_COUNT, 0x62, 9, 1, False, lambda x: x), (KEY_PACK_COUNT, 0x51, 42, 1, False, lambda x: min(x, BMS._MAX_SUBS)), (KEY_TEMP_SENS, 0x62, 14, 1, False, lambda x: x), - ] # Protocol Seplos V2 (parallel data 0x62) + ] # Protocol Seplos V2 (parallel data 0x62, device manufacturer info 0x51) _CMDS: Final[list[int]] = list({field[1] for field in _FIELDS}) def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: @@ -66,9 +66,13 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: @staticmethod def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" - # advertisement has incorrect name length -> detection only for name - # '05 09 42 50 30 30 02 01 05 03 03 00 ff' - return [{"local_name": "BP00*", "connectable": True}] + return [ + { + "local_name": "BP00", + "service_uuid": BMS.uuid_services()[0], + "connectable": True, + } + ] @staticmethod def device_info() -> dict[str, str]: @@ -101,14 +105,12 @@ def _calc_values() -> set[str]: def _notification_handler(self, _sender, data: bytearray) -> None: """Handle the RX characteristics notify event (new data arrives).""" - LOGGER.debug("%s: Received BLE data: %s", self.name, data) - if ( data[0] == BMS._HEAD - and len(data) > BMS._INFO_LEN + and len(data) > BMS._MIN_LEN and len(self._data) >= self._exp_len ): - self._exp_len = BMS._INFO_LEN + int.from_bytes(data[5:7]) + self._exp_len = BMS._MIN_LEN + int.from_bytes(data[5:7]) self._data = bytearray() self._data += data @@ -160,13 +162,12 @@ def _notification_handler(self, _sender, data: bytearray) -> None: @staticmethod def _cmd(cmd: int, data: bytearray = bytearray()) -> bytearray: - """Assemble a Seplos BMS command.""" + """Assemble a Seplos V2 BMS command.""" assert cmd in (0x47, 0x51, 0x61, 0x62, 0x04) # allow only read commands frame = bytearray([BMS._HEAD, BMS._CMD_VER, 0x0, 0x46, cmd]) # fixed version frame += len(data).to_bytes(2, "big", signed=False) + data frame += bytearray(int.to_bytes(crc_xmodem(frame[1:]), 2, byteorder="big")) frame += bytearray([BMS._TAIL]) - LOGGER.debug("TX cmd: %s", frame.hex(" ")) return frame @staticmethod diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index 5e61b1a..8abecae 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -74,7 +74,7 @@ async def write_gatt_char( self._notify_callback ), "write to characteristics but notification not enabled" - resp = self._response(char_specifier, data) + resp: bytearray = self._response(char_specifier, data) for notify_data in [ resp[i : i + BT_FRAME_SIZE] for i in range(0, len(resp), BT_FRAME_SIZE) ]: From c4a2b2a465930a05a7f94ea9cb7afe1a2065c11b Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 15 Dec 2024 13:10:33 +0100 Subject: [PATCH 17/22] Update seplos_bms.py --- custom_components/bms_ble/plugins/seplos_bms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/plugins/seplos_bms.py b/custom_components/bms_ble/plugins/seplos_bms.py index 2fd4b95..eab90af 100644 --- a/custom_components/bms_ble/plugins/seplos_bms.py +++ b/custom_components/bms_ble/plugins/seplos_bms.py @@ -27,7 +27,7 @@ from .basebms import BaseBMS, BMSsample, crc_modbus -BAT_TIMEOUT: Final = 5 +BAT_TIMEOUT: Final = 10 LOGGER = logging.getLogger(__name__) From a7063db83c1794411583444e86ed62bf2427cbf8 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 15 Dec 2024 17:40:31 +0100 Subject: [PATCH 18/22] resorted BMS list --- custom_components/bms_ble/const.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/const.py b/custom_components/bms_ble/const.py index 38a40d9..ba90a1a 100644 --- a/custom_components/bms_ble/const.py +++ b/custom_components/bms_ble/const.py @@ -15,13 +15,13 @@ "daly_bms", "ective_bms", "ej_bms", + "jbd_bms", "jikong_bms", "ogt_bms", "redodo_bms", "seplos_bms", "seplos_v2_bms", "dpwrcore_bms", # only name filter - "jbd_bms", # only UUID filter ] # available BMS types DOMAIN: Final[str] = "bms_ble" LOGGER: Final = logging.getLogger(__package__) From 067c94f50f85258e952bc43a838a57357a30f7da Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Tue, 17 Dec 2024 09:42:56 +0100 Subject: [PATCH 19/22] query correct address --- .../bms_ble/plugins/seplos_v2_bms.py | 8 +++++--- tests/test_seplos_v2_bms.py | 17 +++++++++-------- 2 files changed, 14 insertions(+), 11 deletions(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index a977080..024e855 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -161,10 +161,12 @@ def _notification_handler(self, _sender, data: bytearray) -> None: self._data_event.set() @staticmethod - def _cmd(cmd: int, data: bytearray = bytearray()) -> bytearray: + def _cmd(cmd: int, address: int = 0, data: bytearray = bytearray()) -> bytearray: """Assemble a Seplos V2 BMS command.""" assert cmd in (0x47, 0x51, 0x61, 0x62, 0x04) # allow only read commands - frame = bytearray([BMS._HEAD, BMS._CMD_VER, 0x0, 0x46, cmd]) # fixed version + frame = bytearray( + [BMS._HEAD, BMS._CMD_VER, address, 0x46, cmd] + ) # fixed version frame += len(data).to_bytes(2, "big", signed=False) + data frame += bytearray(int.to_bytes(crc_xmodem(frame[1:]), 2, byteorder="big")) frame += bytearray([BMS._TAIL]) @@ -207,7 +209,7 @@ async def _async_update(self) -> BMSsample: total_cells: int = 0 for pack in range(int(result.get(KEY_PACK_COUNT, 0) + 1)): await self._client.write_gatt_char( - BMS.uuid_tx(), data=BMS._cmd(0x61, data=bytearray([pack])) + BMS.uuid_tx(), data=BMS._cmd(0x61, address=pack, data=bytearray(1)) ) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) pack_cells: dict[str, float] = BMS._cell_voltages( diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index 8abecae..6d67734 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -19,13 +19,12 @@ class MockSeplosv2BleakClient(MockBleakClient): """Emulate a Seplos v2 BMS BleakClient.""" HEAD_CMD = 0x7E - CMD_GSMD = bytearray([HEAD_CMD]) + bytearray( - b"\x10\x00\x46\x61\x00\x01" - ) # get single machine data - CMD_GPD = bytearray([HEAD_CMD]) + bytearray( - b"\x10\x00\x46\x62\x00\x00" + PROTOCOL = 0x10 + CMD_GSMD = bytearray(b"\x46\x61\x00\x01\x00") # get single machine data + CMD_GPD = bytearray( + bytes([HEAD_CMD, PROTOCOL]) + b"\x00\x46\x62\x00\x00" ) # get parallel data - CMD_GMI = bytearray([HEAD_CMD]) + bytearray(b"\x10\x00\x46\x51\x00\x00\x3A\x7F\x0D") + CMD_GMI = bytearray(bytes([HEAD_CMD, PROTOCOL]) + b"\00\x46\x51\x00\x00\x3A\x7F\x0D") def _response( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer @@ -36,8 +35,10 @@ def _response( and normalize_uuid_str(char_specifier) == normalize_uuid_str("ff02") and bytearray(data)[0] == self.HEAD_CMD ): - if bytearray(data).startswith(self.CMD_GSMD): - return bytearray( + if bytearray(data)[1] == self.PROTOCOL and bytearray(data)[3:].startswith( + self.CMD_GSMD + ): + return bytearray( # TODO: respond with correct address b"\x7e\x14\x02\x61\x00\x00\x6a\x00\x02\x10\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c" b"\xf1\x0c\xf0\x0c\xf1\x0c\xf3\x0c\xef\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c\xf0" b"\x0c\xf1\x0c\xf1\x06\x0b\x8f\x0b\x89\x0b\x8a\x0b\x93\x0b\xc0\x0b\x98\x02\xad" From 415ac06fe90b2db0b9c7a4f694d080837909c384 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Tue, 17 Dec 2024 18:38:35 +0100 Subject: [PATCH 20/22] present individual packs --- README.md | 2 +- custom_components/bms_ble/manifest.json | 2 +- custom_components/bms_ble/plugins/basebms.py | 2 + .../bms_ble/plugins/seplos_v2_bms.py | 41 ++++++------------- tests/test_seplos_v2_bms.py | 32 --------------- 5 files changed, 17 insertions(+), 62 deletions(-) diff --git a/README.md b/README.md index c9004c3..af04f61 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management - JK BMS, Jikong, (HW version >=11 required) - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…) - LiTime, Redodo batteries -- Seplos v2 (show up as `BP00`…) +- Seplos v2 (show up as `BP0`?) - Seplos v3 (show up as `SP0`… or `SP1`…) New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details. diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 2d95f3e..3911fa4 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -69,7 +69,7 @@ "manufacturer_id": 65535 }, { - "local_name": "BP00", + "local_name": "BP0?", "service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb" } ], diff --git a/custom_components/bms_ble/plugins/basebms.py b/custom_components/bms_ble/plugins/basebms.py index 66cd2a1..fd670a1 100644 --- a/custom_components/bms_ble/plugins/basebms.py +++ b/custom_components/bms_ble/plugins/basebms.py @@ -238,6 +238,7 @@ def crc_modbus(data: bytearray) -> int: crc = (crc >> 1) ^ 0xA001 if crc % 2 else (crc >> 1) return crc & 0xFFFF + def crc_xmodem(data: bytearray) -> int: """Calculate CRC-16-CCITT XMODEM.""" crc: int = 0x0000 @@ -247,6 +248,7 @@ def crc_xmodem(data: bytearray) -> int: crc = (crc << 1) ^ 0x1021 if (crc & 0x8000) else (crc << 1) return crc & 0xFFFF + def crc_sum(frame: bytes) -> int: """Calculate frame CRC.""" return sum(frame) & 0xFF diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index 024e855..dc83d7a 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -53,7 +53,10 @@ class BMS(BaseBMS): (KEY_PACK_COUNT, 0x51, 42, 1, False, lambda x: min(x, BMS._MAX_SUBS)), (KEY_TEMP_SENS, 0x62, 14, 1, False, lambda x: x), ] # Protocol Seplos V2 (parallel data 0x62, device manufacturer info 0x51) - _CMDS: Final[list[int]] = list({field[1] for field in _FIELDS}) + _CMDS: Final[list[tuple[int, bytes]]] = [ + *list({(field[1], b"") for field in _FIELDS}), + (0x61, b"\x00"), + ] def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: """Initialize BMS.""" @@ -68,7 +71,7 @@ def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" return [ { - "local_name": "BP00", + "local_name": "BP0?", "service_uuid": BMS.uuid_services()[0], "connectable": True, } @@ -97,9 +100,10 @@ def uuid_tx() -> str: @staticmethod def _calc_values() -> set[str]: return { - ATTR_POWER, ATTR_BATTERY_CHARGING, ATTR_CYCLE_CAP, + ATTR_DELTA_VOLTAGE, + ATTR_POWER, ATTR_RUNTIME, } # calculate further values from BMS provided set ones @@ -184,13 +188,11 @@ def _decode_data(data: dict[int, bytearray]) -> dict[str, int | float]: } @staticmethod - def _cell_voltages(data: bytearray, offset: int = 0) -> dict[str, float]: + def _cell_voltages(data: bytearray) -> dict[str, float]: return { - f"{KEY_CELL_VOLTAGE}{idx+offset}": float( + f"{KEY_CELL_VOLTAGE}{idx}": float( int.from_bytes( - data[10 + idx * 2 : 10 + idx * 2 + 2], - byteorder="big", - signed=False, + data[10 + idx * 2 : 10 + idx * 2 + 2], byteorder="big", signed=False ) ) / 1000 @@ -200,29 +202,12 @@ def _cell_voltages(data: bytearray, offset: int = 0) -> dict[str, float]: async def _async_update(self) -> BMSsample: """Update battery status information.""" - for cmd in BMS._CMDS: - await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(cmd)) + for cmd, data in BMS._CMDS: + await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(cmd, data=bytearray(data))) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) result: BMSsample = BMS._decode_data(self._data_final) - - total_cells: int = 0 - for pack in range(int(result.get(KEY_PACK_COUNT, 0) + 1)): - await self._client.write_gatt_char( - BMS.uuid_tx(), data=BMS._cmd(0x61, address=pack, data=bytearray(1)) - ) - await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - pack_cells: dict[str, float] = BMS._cell_voltages( - self._data_final[0x61], total_cells - ) - result |= pack_cells - result |= { - ATTR_DELTA_VOLTAGE: max( - float(result.get(ATTR_DELTA_VOLTAGE, 0)), - round(max(pack_cells.values()) - min(pack_cells.values()), 3), - ) - } - total_cells += self._data_final[0x61][9] + result |= BMS._cell_voltages(self._data_final[0x61]) self._data_final.clear() diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index 6d67734..e8b4836 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -125,38 +125,6 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cell#13": 3.312, "cell#14": 3.313, "cell#15": 3.313, - "cell#16": 3.312, - "cell#17": 3.313, - "cell#18": 3.313, - "cell#19": 3.313, - "cell#20": 3.313, - "cell#21": 3.312, - "cell#22": 3.313, - "cell#23": 3.315, - "cell#24": 3.311, - "cell#25": 3.312, - "cell#26": 3.313, - "cell#27": 3.313, - "cell#28": 3.313, - "cell#29": 3.312, - "cell#30": 3.313, - "cell#31": 3.313, - "cell#32": 3.312, - "cell#33": 3.313, - "cell#34": 3.313, - "cell#35": 3.313, - "cell#36": 3.313, - "cell#37": 3.312, - "cell#38": 3.313, - "cell#39": 3.315, - "cell#40": 3.311, - "cell#41": 3.312, - "cell#42": 3.313, - "cell#43": 3.313, - "cell#44": 3.313, - "cell#45": 3.312, - "cell#46": 3.313, - "cell#47": 3.313, "delta_voltage": 0.004, "pack_count": 2, } From 6c3b1bd04fe166dd743190147f861398b427575e Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 18 Dec 2024 06:03:10 +0100 Subject: [PATCH 21/22] switched to single machine data --- .../bms_ble/plugins/seplos_v2_bms.py | 71 ++++++++++++++----- tests/test_seplos_v2_bms.py | 19 +++-- 2 files changed, 64 insertions(+), 26 deletions(-) diff --git a/custom_components/bms_ble/plugins/seplos_v2_bms.py b/custom_components/bms_ble/plugins/seplos_v2_bms.py index dc83d7a..3290851 100644 --- a/custom_components/bms_ble/plugins/seplos_v2_bms.py +++ b/custom_components/bms_ble/plugins/seplos_v2_bms.py @@ -23,6 +23,7 @@ KEY_CELL_VOLTAGE, KEY_PACK_COUNT, KEY_TEMP_SENS, + KEY_TEMP_VALUE, ) from .basebms import BaseBMS, BMSsample, crc_xmodem @@ -40,23 +41,17 @@ class BMS(BaseBMS): _RSP_VER: Final[int] = 0x14 _MIN_LEN: Final[int] = 10 _MAX_SUBS: Final[int] = 0xF + _CELL_POS: Final[int] = 9 _FIELDS: Final[ list[tuple[str, int, int, int, bool, Callable[[int], int | float]]] ] = [ - (ATTR_VOLTAGE, 0x62, 25, 2, False, lambda x: float(x / 100)), - (ATTR_CURRENT, 0x62, 23, 2, True, lambda x: float(x / 10)), - (ATTR_CYCLE_CHRG, 0x62, 27, 2, False, lambda x: float(x / 10)), - (ATTR_CYCLES, 0x62, 36, 2, False, lambda x: x), - (ATTR_BATTERY_LEVEL, 0x62, 32, 2, False, lambda x: float(x / 10)), - (ATTR_TEMPERATURE, 0x62, 21, 2, True, lambda x: (x - 2731.5) / 10), - (KEY_CELL_COUNT, 0x62, 9, 1, False, lambda x: x), - (KEY_PACK_COUNT, 0x51, 42, 1, False, lambda x: min(x, BMS._MAX_SUBS)), - (KEY_TEMP_SENS, 0x62, 14, 1, False, lambda x: x), + (ATTR_VOLTAGE, 0x61, 2, 2, False, lambda x: float(x / 100)), + (ATTR_CURRENT, 0x61, 0, 2, True, lambda x: float(x / 10)), + (ATTR_CYCLE_CHRG, 0x61, 4, 2, False, lambda x: float(x / 10)), + (ATTR_CYCLES, 0x61, 13, 2, False, lambda x: x), + (ATTR_BATTERY_LEVEL, 0x61, 9, 2, False, lambda x: float(x / 10)), ] # Protocol Seplos V2 (parallel data 0x62, device manufacturer info 0x51) - _CMDS: Final[list[tuple[int, bytes]]] = [ - *list({(field[1], b"") for field in _FIELDS}), - (0x61, b"\x00"), - ] + _CMDS: Final[list[tuple[int, bytes]]] = [(0x51, b""), (0x61, b"\x00")] def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: """Initialize BMS.""" @@ -105,6 +100,7 @@ def _calc_values() -> set[str]: ATTR_DELTA_VOLTAGE, ATTR_POWER, ATTR_RUNTIME, + ATTR_TEMPERATURE, } # calculate further values from BMS provided set ones def _notification_handler(self, _sender, data: bytearray) -> None: @@ -177,16 +173,38 @@ def _cmd(cmd: int, address: int = 0, data: bytearray = bytearray()) -> bytearray return frame @staticmethod - def _decode_data(data: dict[int, bytearray]) -> dict[str, int | float]: + def _decode_data(data: dict[int, bytearray], offs: int) -> dict[str, int | float]: return { key: func( int.from_bytes( - data[cmd][idx : idx + size], byteorder="big", signed=sign + data[cmd][idx + offs : idx + offs + size], + byteorder="big", + signed=sign, ) ) for key, cmd, idx, size, sign, func in BMS._FIELDS } + @staticmethod + def _temp_sensors(data: bytearray, sensors: int, offs: int) -> dict[str, float]: + return { + f"{KEY_TEMP_VALUE}{idx}": ( + int.from_bytes( + data[offs + idx * 2 : offs + (idx + 1) * 2], + byteorder="big", + signed=False, + ) + - 2731.5 + ) + / 10 + for idx in range(sensors) + if int.from_bytes( + data[offs + idx * 2 : offs + (idx + 1) * 2], + byteorder="big", + signed=False, + ) + } + @staticmethod def _cell_voltages(data: bytearray) -> dict[str, float]: return { @@ -196,19 +214,34 @@ def _cell_voltages(data: bytearray) -> dict[str, float]: ) ) / 1000 - for idx in range(data[9]) + for idx in range(data[BMS._CELL_POS]) } async def _async_update(self) -> BMSsample: """Update battery status information.""" for cmd, data in BMS._CMDS: - await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(cmd, data=bytearray(data))) + await self._client.write_gatt_char( + BMS.uuid_tx(), data=BMS._cmd(cmd, data=bytearray(data)) + ) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - result: BMSsample = BMS._decode_data(self._data_final) - result |= BMS._cell_voltages(self._data_final[0x61]) + result: BMSsample = {KEY_CELL_COUNT: int(self._data_final[0x61][BMS._CELL_POS])} + result[KEY_TEMP_SENS] = int( + self._data_final[0x61][BMS._CELL_POS + int(result[KEY_CELL_COUNT]) * 2 + 1] + ) + result[KEY_PACK_COUNT] = min(int(self._data_final[0x51][42]), BMS._MAX_SUBS) + result |= BMS._cell_voltages(self._data_final[0x61]) + result |= BMS._temp_sensors( + self._data_final[0x61], + int(result[KEY_TEMP_SENS]), + BMS._CELL_POS + int(result[KEY_CELL_COUNT]) * 2 + 2, + ) + result |= BMS._decode_data( + self._data_final, + BMS._CELL_POS + int(result[KEY_CELL_COUNT] + result[KEY_TEMP_SENS]) * 2 + 2, + ) self._data_final.clear() return result diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index e8b4836..a6bc42d 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -39,13 +39,18 @@ def _response( self.CMD_GSMD ): return bytearray( # TODO: respond with correct address - b"\x7e\x14\x02\x61\x00\x00\x6a\x00\x02\x10\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c" - b"\xf1\x0c\xf0\x0c\xf1\x0c\xf3\x0c\xef\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c\xf0" - b"\x0c\xf1\x0c\xf1\x06\x0b\x8f\x0b\x89\x0b\x8a\x0b\x93\x0b\xc0\x0b\x98\x02\xad" - b"\x14\xb4\x38\x3a\x06\x6d\x60\x02\x02\x6d\x60\x00\x80\x03\xe8\x14\xbb\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc1" - b"\xd7\x0d" + # b"\x7e\x14\x02\x61\x00\x00\x6a\x00\x02\x10\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c" + # b"\xf1\x0c\xf0\x0c\xf1\x0c\xf3\x0c\xef\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c\xf0" + # b"\x0c\xf1\x0c\xf1\x06\x0b\x8f\x0b\x89\x0b\x8a\x0b\x93\x0b\xc0\x0b\x98\x02\xad" + # b"\x14\xb4\x38\x3a\x06\x6d\x60\x02\x02\x6d\x60\x00\x80\x03\xe8\x14\xbb\x00\x00" + # b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + # b"\x00\x00\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc1" + # b"\xd7\x0d" +b'~\x14\x00a\x00\x00j\x00\x00\x10\x0b\xda\x0c\x0b\x0b\xec\x0b\xf0\x0b\xf3\x0c&\x0c\x1b\x0c \x0c\x1d\x0c\n\x0b\xf1\x0c' +b'\x0e\x0b\xd1\x0b\xf9\x0b\xc7\x0b\xe4\x06\x0b\x86\x0b\x7f\x0b\x81\x0b\x84\x0b\xae\x0b\x89\x00\x00\x13+\x15z\x06m`' +b'\x00\xc4m`\x00J\x03\xe8\x13,\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x03\x08\x00\x00\x00' +b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01^\r' + ) # TODO: values if bytearray(data).startswith(self.CMD_GPD): return bytearray( From 072c323a9a1ab9100971d028a381497170319c9c Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 18 Dec 2024 06:16:32 +0100 Subject: [PATCH 22/22] fix tests --- tests/test_seplos_v2_bms.py | 45 ++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 21 deletions(-) diff --git a/tests/test_seplos_v2_bms.py b/tests/test_seplos_v2_bms.py index a6bc42d..5e23c65 100644 --- a/tests/test_seplos_v2_bms.py +++ b/tests/test_seplos_v2_bms.py @@ -24,7 +24,9 @@ class MockSeplosv2BleakClient(MockBleakClient): CMD_GPD = bytearray( bytes([HEAD_CMD, PROTOCOL]) + b"\x00\x46\x62\x00\x00" ) # get parallel data - CMD_GMI = bytearray(bytes([HEAD_CMD, PROTOCOL]) + b"\00\x46\x51\x00\x00\x3A\x7F\x0D") + CMD_GMI = bytearray( + bytes([HEAD_CMD, PROTOCOL]) + b"\00\x46\x51\x00\x00\x3A\x7F\x0D" + ) def _response( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer @@ -38,19 +40,14 @@ def _response( if bytearray(data)[1] == self.PROTOCOL and bytearray(data)[3:].startswith( self.CMD_GSMD ): - return bytearray( # TODO: respond with correct address - # b"\x7e\x14\x02\x61\x00\x00\x6a\x00\x02\x10\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c" - # b"\xf1\x0c\xf0\x0c\xf1\x0c\xf3\x0c\xef\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c\xf0" - # b"\x0c\xf1\x0c\xf1\x06\x0b\x8f\x0b\x89\x0b\x8a\x0b\x93\x0b\xc0\x0b\x98\x02\xad" - # b"\x14\xb4\x38\x3a\x06\x6d\x60\x02\x02\x6d\x60\x00\x80\x03\xe8\x14\xbb\x00\x00" - # b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - # b"\x00\x00\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc1" - # b"\xd7\x0d" -b'~\x14\x00a\x00\x00j\x00\x00\x10\x0b\xda\x0c\x0b\x0b\xec\x0b\xf0\x0b\xf3\x0c&\x0c\x1b\x0c \x0c\x1d\x0c\n\x0b\xf1\x0c' -b'\x0e\x0b\xd1\x0b\xf9\x0b\xc7\x0b\xe4\x06\x0b\x86\x0b\x7f\x0b\x81\x0b\x84\x0b\xae\x0b\x89\x00\x00\x13+\x15z\x06m`' -b'\x00\xc4m`\x00J\x03\xe8\x13,\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x10\x03\x08\x00\x00\x00' -b'\x00\x00\x00\x00\x00\x00\x00\x00\x00\x01^\r' - + return bytearray( # TODO: respond with correct address + b"\x7e\x14\x02\x61\x00\x00\x6a\x00\x02\x10\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c" + b"\xf1\x0c\xf0\x0c\xf1\x0c\xf3\x0c\xef\x0c\xf0\x0c\xf1\x0c\xf1\x0c\xf1\x0c\xf0" + b"\x0c\xf1\x0c\xf1\x06\x0b\x8f\x0b\x89\x0b\x8a\x0b\x93\x0b\xc0\x0b\x98\x02\xad" + b"\x14\xb4\x38\x3a\x06\x6d\x60\x02\x02\x6d\x60\x00\x80\x03\xe8\x14\xbb\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x02\x03\x08\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xc1" + b"\xd7\x0d" ) # TODO: values if bytearray(data).startswith(self.CMD_GPD): return bytearray( @@ -106,13 +103,13 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cell_count": 16, "temp_sensors": 6, "voltage": 53.0, - "current": 21.5, - "battery_level": 52.0, - "cycle_charge": 437.2, - "cycles": 113, - "temperature": 22.55, - "cycle_capacity": 23171.6, - "power": 1139.5, + "current": 68.5, + "battery_level": 51.4, + "cycle_charge": 1439.4, + "cycles": 128, + "temperature": 23.6, + "cycle_capacity": 76288.2, + "power": 3630.5, "battery_charging": True, "cell#0": 3.312, "cell#1": 3.313, @@ -130,6 +127,12 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cell#13": 3.312, "cell#14": 3.313, "cell#15": 3.313, + "temp#0": 22.75, + "temp#1": 22.15, + "temp#2": 22.25, + "temp#3": 23.15, + "temp#4": 27.65, + "temp#5": 23.65, "delta_voltage": 0.004, "pack_count": 2, }