diff --git a/README.md b/README.md index 2e39ac9..9fb401c 100644 --- a/README.md +++ b/README.md @@ -27,7 +27,7 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management - JBD BMS, Jiabaida (show up as `SP..S`…) - accurat batteries (show up as `GJ-`…) - Supervolt v3 batteries (show up as `SX1*`…) -- JK BMS, Jikong, (HW version >=11 required) +- JK BMS, Jikong, (HW version >=6 required) - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…) - LiTime, Power Queen, and Redodo batteries - Seplos v3 (show up as `SP0`… or `SP1`…) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 63f21f3..5fcf152 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -78,5 +78,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/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index af1e2c0..5f2f2ef 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -41,8 +41,6 @@ class BMS(BaseBMS): INFO_LEN: Final[int] = 300 _FIELDS: Final[list[tuple[str, int, int, bool, Callable[[int], int | float]]]] = ( [ # Protocol: JK02_32S; JK02_24S has offset -32 - (KEY_CELL_COUNT, 70, 4, False, lambda x: x.bit_count()), - (ATTR_DELTA_VOLTAGE, 76, 2, False, lambda x: float(x / 1000)), (ATTR_VOLTAGE, 150, 4, False, lambda x: float(x / 1000)), (ATTR_CURRENT, 158, 4, True, lambda x: float(x / 1000)), (ATTR_BATTERY_LEVEL, 173, 1, False, lambda x: x), @@ -55,9 +53,11 @@ 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._data_final: bytearray | None = None + self._data_final: bytearray = bytearray() self._char_write_handle: int | None = None - self._valid_replies: list[int] = [0x2] # BMS ready confirmation + self._bms_info: dict[str, str] = {} + self._prot_offset: int = 0 + self._valid_reply: int = 0x02 @staticmethod def matcher_dict_list() -> list[dict[str, Any]]: @@ -131,7 +131,7 @@ def _notification_handler(self, _sender, data: bytearray) -> None: return # check that message type is expected - if self._data[BMS.TYPE_POS] not in self._valid_replies: + if self._data[BMS.TYPE_POS] != self._valid_reply: LOGGER.debug( "%s: unexpected message type 0x%X (length %i): %s", self.name, @@ -159,10 +159,9 @@ def _notification_handler(self, _sender, data: bytearray) -> None: self._data[-1], crc, ) - self._data_final = None # reset invalid data - else: - self._data_final = self._data + return + self._data_final = self._data self._data_event.set() async def _init_characteristics(self) -> None: @@ -202,13 +201,20 @@ async def _init_characteristics(self) -> None: char_notify_handle or 0, self._notification_handler ) - # query device info frame and wait for BMS ready (0xC8) - self._valid_replies.append(0xC8) + # query device info frame (0x03) and wait for BMS ready (0xC8) + self._valid_reply = 0x03 await self._client.write_gatt_char( self._char_write_handle or 0, data=self._cmd(b"\x97") ) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - self._valid_replies.remove(0xC8) + self._bms_info = BMS._dec_devinfo(self._data_final or bytearray()) + LOGGER.debug("%s: device information: %s", self.name, self._bms_info) + self._prot_offset = ( + -32 if int(self._bms_info.get("sw_version", "")[:2]) < 11 else 0 + ) + self._valid_reply = 0xC8 # BMS ready confirmation + await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + self._valid_reply = 0x02 # cell information @staticmethod def _cmd(cmd: bytes, value: list[int] | None = None) -> bytes: @@ -221,6 +227,10 @@ def _cmd(cmd: bytes, value: list[int] | None = None) -> bytes: frame += bytes([crc_sum(frame)]) return frame + @staticmethod + def _dec_devinfo(data: bytearray) -> dict[str, str]: + return {"hw_version": data[22:27].decode(), "sw_version": data[30:35].decode()} + @staticmethod def _cell_voltages(data: bytearray, cells: int) -> dict[str, float]: """Return cell voltages from status message.""" @@ -235,29 +245,53 @@ def _cell_voltages(data: bytearray, cells: int) -> dict[str, float]: } @staticmethod - def _temp_sensors(data: bytearray) -> dict[str, float]: + def _temp_sensors(data: bytearray, offs: int) -> dict[str, float]: + temp_pos: Final[list[tuple[int, int]]] = ( + [(0, 130), (1, 132), (2, 134)] + if offs + else [(0, 144), (1, 162), (2, 164), (3, 256), (4, 258)] + ) return { f"{KEY_TEMP_VALUE}{idx}": int.from_bytes( data[pos : pos + 2], byteorder="little", signed=False ) / 10 - for idx, pos in [(0, 144), (1, 162), (2, 164), (3, 256), (4, 258)] + for idx, pos in temp_pos if int.from_bytes(data[pos : pos + 2], byteorder="little", signed=False) } @staticmethod - def _decode_data(data: bytearray) -> BMSsample: + def _decode_data(data: bytearray, offs: int) -> BMSsample: """Return BMS data from status message.""" - return { - key: func( - int.from_bytes(data[idx : idx + size], byteorder="little", signed=sign) - ) - for key, idx, size, sign, func in BMS._FIELDS - } + return ( + { + KEY_CELL_COUNT: int.from_bytes( + data[70 + (offs >> 1) : 74 + (offs >> 1)], + byteorder="little", + ).bit_count() + } + | { + ATTR_DELTA_VOLTAGE: int.from_bytes( + data[76 + (offs >> 1) : 78 + (offs >> 1)], + byteorder="little", + ) + / 1000 + } + | { + key: func( + int.from_bytes( + data[idx + offs : idx + offs + size], + byteorder="little", + signed=sign, + ) + ) + for key, idx, size, sign, func in BMS._FIELDS + } + ) async def _async_update(self) -> BMSsample: """Update battery status information.""" - if not self._data_event.is_set(): + if not self._data_event.is_set() or self._data_final[4] != 0x02: # request cell info (only if data is not constantly published) LOGGER.debug("%s: request cell info", self.name) await self._client.write_gatt_char( @@ -265,11 +299,8 @@ async def _async_update(self) -> BMSsample: ) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - if self._data_final is None: - return {} - - data = self._decode_data(self._data_final) - data.update(BMS._temp_sensors(self._data_final)) + data: BMSsample = self._decode_data(self._data_final, self._prot_offset) + data.update(BMS._temp_sensors(self._data_final, self._prot_offset)) data.update(BMS._cell_voltages(self._data_final, int(data[KEY_CELL_COUNT]))) return data diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index bcf26d0..a645a90 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -1,6 +1,8 @@ """Test the Jikong BMS implementation.""" +import asyncio from collections.abc import Buffer +from copy import deepcopy from typing import Final from uuid import UUID @@ -11,13 +13,169 @@ from bleak.uuids import normalize_uuid_str, uuidstr_to_str import pytest -from custom_components.bms_ble.plugins.jikong_bms import BMS +from custom_components.bms_ble.plugins.jikong_bms import BMS, BMSsample, crc_sum from .bluetooth import generate_ble_device from .conftest import MockBleakClient BT_FRAME_SIZE = 29 +_PROTO_DEFS: Final[dict[str, dict[str, bytearray]]] = { + "JK02_24S": { + "dev": bytearray( # JK02_24S (SW: 10.08) + b"\x55\xaa\xeb\x90\x03\x79\x4a\x4b\x2d\x42\x32\x41\x32\x30\x53\x32\x30\x50\x00\x00\x00\x00" + b"\x31\x30\x2e\x58\x47\x00\x00\x00\x31\x30\x2e\x30\x38\x00\x00\x00\xe4\xe7\x6c\x03\x11\x00" + b"\x00\x00\x4a\x4b\x2d\x42\x4d\x53\x2d\x41\x00\x00\x00\x00\x00\x00\x00\x00\x31\x32\x33\x34" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x32\x32\x30\x37\x30\x31\x00\x00\x32\x30" + b"\x33\x32\x38\x31\x36\x30\x31\x32\x00\x30\x30\x30\x30\x00\x4d\x61\x72\x69\x6f\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x61\x00\x00\x31\x32\x33\x34\x35\x36\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x93" + ), + "ack": bytearray( + b"\xaa\x55\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x44" + ), + "cell": bytearray( # JK02_24S (SW: 10.08) + b"\x55\xaa\xeb\x90\x02\xc8\xee\x0c\xf2\x0c\xf1\x0c\xf0\x0c\xf0\x0c\xec\x0c\xf0\x0c\xed\x0c" + b"\xed\x0c\xed\x0c\xed\x0c\xf0\x0c\xf1\x0c\xed\x0c\xee\x0c\xed\x0c\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\xff\x00\x00\xef\x0c\x05\x00\x01\x09\x36\x00" + b"\x37\x00\x39\x00\x38\x00\x37\x00\x37\x00\x35\x00\x41\x00\x42\x00\x36\x00\x37\x00\x3a\x00" + b"\x38\x00\x34\x00\x36\x00\x37\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\xeb\xce\x00\x00\xc7\x0d\x02\x00\x19\x09\x00\x00\xb5\x00" + b"\xba\x00\xe4\x00\x00\x00\x00\x00\x00\x38\x5d\xba\x01\x00\x10\x15\x03\x00\x3c\x00\x00\x00" + b"\xa4\x65\xb9\x00\x64\x00\xd9\x02\x8b\xe8\x6c\x03\x01\x01\xb3\x06\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x07\x00\x01\x00\x00\x00\x23\x04\x0b\x00\x00\x00\x9f\x19\x40\x40" + b"\x00\x00\x00\x00\xe2\x04\x00\x00\x00\x00\x00\x01\x00\x03\x00\x00\x83\xd5\x37\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xbb" + ), + }, + "JK02_32S": { # JK02_32 (SW: V11.48) + "dev": bytearray( + b"\x55\xaa\xeb\x90\x03\xa3\x4a\x4b\x5f\x42\x32\x41\x38\x53\x32\x30\x50\x00\x00\x00\x00\x00" + b"\x31\x31\x2e\x58\x41\x00\x00\x00\x31\x31\x2e\x34\x38\x00\x00\x00\xe4\xa7\x46\x00\x07\x00" + b"\x00\x00\x31\x32\x76\x34\x32\x30\x61\x00\x00\x00\x00\x00\x00\x00\x00\x00\x31\x32\x33\x34" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x32\x34\x30\x37\x30\x34\x00\x00\x34\x30" + b"\x34\x30\x39\x32\x43\x32\x32\x36\x32\x00\x30\x30\x30\x00\x49\x6e\x70\x75\x74\x20\x55\x73" + b"\x65\x72\x64\x61\x74\x61\x00\x00\x31\x34\x30\x37\x30\x33\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xf9\xff\xff" + b"\x1f\x2d\x00\x02\x00\x00\x00\x00\x90\x1f\x00\x00\x00\x00\xc0\xd8\xe7\x32\x00\x00\x00\x01" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x41\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x09\x00\x00\x00\x64\x00\x00\x00" + b"\x5f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\xfe\xbf\x21\x06\x00\x00\x00\x00\x00\x00\x00\x00\xd8" + ), # Vendor_ID: JK_B2A8S20P, SN: 404092C2262, HW: V11.XA, SW: V11.48, power-on: 7, Version: 4.28.0 + "ack": bytearray( + b"\xaa\x55\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x44" + ), + "cell": bytearray( + b"\x55\xaa\xeb\x90\x02\xc6\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c" + b"\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\xff\xff\x00\x00\xc1\x0c\x02\x00\x00\x07\x3a\x00\x3c\x00\x46\x00\x48\x00" + b"\x54\x00\x5c\x00\x69\x00\x76\x00\x7d\x00\x76\x00\x6c\x00\x69\x00\x61\x00\x4b\x00\x47\x00" + b"\x3c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb8\x00\x00\x00\x00\x00\x0a\xcc\x00\x00" + b"\xcd\x71\x08\x00\x9d\xd6\xff\xff\xb5\x00\xb6\x00\x00\x00\x00\x00\x00\x00\x00\x2a\x47\xcb" + b"\x01\x00\xc0\x45\x04\x00\x02\x00\x00\x00\x15\xb7\x08\x00\x64\x00\x00\x00\x6b\xc7\x06\x00" + b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00\x01\x00\x00\x00" + b"\xb2\x03\x00\x00\x1c\x00\x54\x29\x40\x40\x00\x00\x00\x00\x67\x14\x00\x00\x00\x01\x01\x01" + b"\x00\x06\x00\x00\xf3\x48\x2e\x00\x00\x00\x00\x00\xb8\x00\xb4\x00\xb7\x00\xb2\x03\xde\xe4" + b"\x5b\x08\x2c\x00\x00\x00\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x00\xfe\xff\x7f\xdc\x2f\x01\x01\xb0\x07\x00\x00\x00\xd0" + ), # {"temperature": 18.4, "voltage": 52.234, "current": -10.595, "battery_level": 42, "cycle_charge": 117.575, "cycles": 2} + }, +} + +_RESULT_DEFS: Final[dict[str, BMSsample]] = { + "JK02_24S": { + "cell_count": 16, + "delta_voltage": 0.005, + "temperature": 19.833, + "voltage": 52.971, + "current": 2.329, + "battery_level": 56, + "cycle_charge": 113.245, + "cycles": 60, + "cell#0": 3.310, + "cell#1": 3.314, + "cell#2": 3.313, + "cell#3": 3.312, + "cell#4": 3.312, + "cell#5": 3.308, + "cell#6": 3.312, + "cell#7": 3.309, + "cell#8": 3.309, + "cell#9": 3.309, + "cell#10": 3.309, + "cell#11": 3.312, + "cell#12": 3.313, + "cell#13": 3.309, + "cell#14": 3.310, + "cell#15": 3.309, + "cycle_capacity": 5998.701, + "power": 123.369, + "battery_charging": True, + "temp#0": 18.1, + "temp#1": 18.6, + "temp#2": 22.8, + }, + "JK02_32S": { + "cell_count": 16, + "delta_voltage": 0.002, + "temperature": 18.2, + "voltage": 52.234, + "current": -10.595, + "battery_level": 42, + "cycle_charge": 117.575, + "cycles": 2, + "cell#0": 3.265, + "cell#1": 3.265, + "cell#2": 3.265, + "cell#3": 3.265, + "cell#4": 3.265, + "cell#5": 3.265, + "cell#6": 3.265, + "cell#7": 3.265, + "cell#8": 3.265, + "cell#9": 3.265, + "cell#10": 3.265, + "cell#11": 3.265, + "cell#12": 3.265, + "cell#13": 3.265, + "cell#14": 3.265, + "cell#15": 3.265, + "cycle_capacity": 6141.413, + "power": -553.419, + "battery_charging": False, + "runtime": 39949, + "temp#0": 18.4, + "temp#1": 18.1, + "temp#2": 18.2, + "temp#3": 18.0, + "temp#4": 18.3, + }, +} + + +@pytest.fixture( + name="protocol_type", + params=["JK02_24S", "JK02_32S"], +) +def proto(request: pytest.FixtureRequest) -> dict[str, bytearray]: + """Protocol fixture.""" + return request.param + class MockJikongBleakClient(MockBleakClient): """Emulate a Jikong BMS BleakClient.""" @@ -25,42 +183,9 @@ class MockJikongBleakClient(MockBleakClient): HEAD_CMD: Final = bytearray(b"\xAA\x55\x90\xEB") CMD_INFO: Final = bytearray(b"\x96") DEV_INFO: Final = bytearray(b"\x97") + _FRAME: dict[str, bytearray] = {} - CEL_FRAME: Final = bytearray( - b"\x55\xaa\xeb\x90\x02\xc6\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c" - b"\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\xff\xff\x00\x00\xc1\x0c\x02\x00\x00\x07\x3a\x00\x3c\x00\x46\x00\x48\x00" - b"\x54\x00\x5c\x00\x69\x00\x76\x00\x7d\x00\x76\x00\x6c\x00\x69\x00\x61\x00\x4b\x00\x47\x00" - b"\x3c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb8\x00\x00\x00\x00\x00\x0a\xcc\x00\x00" - b"\xcd\x71\x08\x00\x9d\xd6\xff\xff\xb5\x00\xb6\x00\x00\x00\x00\x00\x00\x00\x00\x2a\x47\xcb" - b"\x01\x00\xc0\x45\x04\x00\x02\x00\x00\x00\x15\xb7\x08\x00\x64\x00\x00\x00\x6b\xc7\x06\x00" - b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00\x01\x00\x00\x00" - b"\xb2\x03\x00\x00\x1c\x00\x54\x29\x40\x40\x00\x00\x00\x00\x67\x14\x00\x00\x00\x01\x01\x01" - b"\x00\x06\x00\x00\xf3\x48\x2e\x00\x00\x00\x00\x00\xb8\x00\xb4\x00\xb7\x00\xb2\x03\xde\xe4" - b"\x5b\x08\x2c\x00\x00\x00\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\xfe\xff\x7f\xdc\x2f\x01\x01\xb0\x07\x00\x00\x00\xd0" - ) # {"temperature": 18.4, "voltage": 52.234, "current": -10.595, "battery_level": 42, "cycle_charge": 117.575, "cycles": 2} - DEV_FRAME: Final = bytearray( - b"\x55\xaa\xeb\x90\x03\xa3\x4a\x4b\x5f\x42\x32\x41\x38\x53\x32\x30\x50\x00\x00\x00\x00\x00" - b"\x31\x31\x2e\x58\x41\x00\x00\x00\x31\x31\x2e\x34\x38\x00\x00\x00\xe4\xa7\x46\x00\x07\x00" - b"\x00\x00\x31\x32\x76\x34\x32\x30\x61\x00\x00\x00\x00\x00\x00\x00\x00\x00\x31\x32\x33\x34" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x32\x34\x30\x37\x30\x34\x00\x00\x34\x30" - b"\x34\x30\x39\x32\x43\x32\x32\x36\x32\x00\x30\x30\x30\x00\x49\x6e\x70\x75\x74\x20\x55\x73" - b"\x65\x72\x64\x61\x74\x61\x00\x00\x31\x34\x30\x37\x30\x33\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe\xf9\xff\xff" - b"\x1f\x2d\x00\x02\x00\x00\x00\x00\x90\x1f\x00\x00\x00\x00\xc0\xd8\xe7\x32\x00\x00\x00\x01" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x07\x04\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x41\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x09\x00\x00\x00\x64\x00\x00\x00" - b"\x5f\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\xfe\xbf\x21\x06\x00\x00\x00\x00\x00\x00\x00\x00\xd8" - ) # Vendor_ID: JK_B2A8S20P, SN: 404092C2262, HW: V11.XA, SW: V11.48, power-on: 7, Version: 4.28.0 - ACK_FRAME: Final = bytearray( - b"\xaa\x55\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x44" - ) + _task: asyncio.Task def _response( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer @@ -69,13 +194,21 @@ def _response( return bytearray() if bytearray(data)[0:5] == self.HEAD_CMD + self.CMD_INFO: return ( - bytearray(b"\x41\x54\x0d\x0a") + self.CEL_FRAME # added AT\r\n command - ) + bytearray(b"\x41\x54\x0d\x0a") + self._FRAME["cell"] + ) # added AT\r\n command if bytearray(data)[0:5] == self.HEAD_CMD + self.DEV_INFO: - return self.DEV_FRAME + return self._FRAME["dev"] return bytearray() + async def _send_confirm(self): + assert self._notify_callback, "send confirm called but notification not enabled" + await asyncio.sleep(0.01) + self._notify_callback( + "MockJikongBleakClient", + b"\xaa\x55\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x44", + ) + async def write_gatt_char( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, @@ -98,10 +231,13 @@ async def write_gatt_char( if ( bytearray(data)[0:5] == self.HEAD_CMD + self.DEV_INFO ): # JK BMS confirms commands with a command in reply - self._notify_callback( - "MockJikongBleakClient", - b"\xaa\x55\x90\xeb\xc8\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x44", - ) + self._task = asyncio.create_task(self._send_confirm()) + + async def disconnect(self) -> bool: + """Mock disconnect and wait for send task.""" + await asyncio.wait_for(self._task, 0.1) + assert self._task.result, "send task still running!" + return await super().disconnect() class JKservice(BleakGATTService): """Mock the main battery info service from JiKong BMS.""" @@ -227,6 +363,14 @@ def services(self) -> BleakGATTServiceCollection: class MockStreamBleakClient(MockJikongBleakClient): """Mock JiKong BMS that already sends battery data (no request required).""" + async def _send_all(self) -> None: + assert ( + self._notify_callback + ), "send_all frames called but notification not enabled" + for resp in self._FRAME.values(): + self._notify_callback("MockJikongBleakClient", resp) + await asyncio.sleep(0.01) + async def write_gatt_char( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, @@ -244,8 +388,7 @@ async def write_gatt_char( if bytearray(data).startswith( self.HEAD_CMD + self.DEV_INFO ): # send all responses as a series - for resp in [self.DEV_FRAME, self.ACK_FRAME, self.CEL_FRAME]: - self._notify_callback("MockJikongBleakClient", resp) + self._task = asyncio.create_task(self._send_all()) class MockWrongBleakClient(MockBleakClient): @@ -272,24 +415,28 @@ class MockOversizedBleakClient(MockJikongBleakClient): def _response( self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer ) -> bytearray: - if char_specifier == 3: - return self.CEL_FRAME + bytearray( - b"\00\00\00\00\00\00" - ) # oversized response + if char_specifier != 3: + return bytearray() + if bytearray(data)[0:5] == self.HEAD_CMD + self.CMD_INFO: + return ( # added AT\r\n command and oversized + bytearray(b"\x41\x54\x0d\x0a") + self._FRAME["cell"] + bytearray(6) + ) + if bytearray(data)[0:5] == self.HEAD_CMD + self.DEV_INFO: + return self._FRAME["dev"] + bytearray(6) # oversized return bytearray() - async def disconnect(self) -> bool: - """Mock disconnect to raise BleakError.""" - raise BleakError - -async def test_update(monkeypatch, reconnect_fixture) -> None: +@pytest.mark.asyncio +async def test_update(monkeypatch, protocol_type, reconnect_fixture) -> None: """Test Jikong BMS data update.""" monkeypatch.setattr( - "custom_components.bms_ble.plugins.basebms.BleakClient", - MockJikongBleakClient, + "tests.test_jikong_bms.MockJikongBleakClient._FRAME", _PROTO_DEFS[protocol_type] + ) + + monkeypatch.setattr( + "custom_components.bms_ble.plugins.basebms.BleakClient", MockJikongBleakClient ) bms = BMS( @@ -297,46 +444,10 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: reconnect_fixture, ) - result = await bms.async_update() - - assert result == { - "cell_count": 16, - "delta_voltage": 0.002, - "temperature": 18.2, - "voltage": 52.234, - "current": -10.595, - "battery_level": 42, - "cycle_charge": 117.575, - "cycles": 2, - "cell#0": 3.265, - "cell#1": 3.265, - "cell#2": 3.265, - "cell#3": 3.265, - "cell#4": 3.265, - "cell#5": 3.265, - "cell#6": 3.265, - "cell#7": 3.265, - "cell#8": 3.265, - "cell#9": 3.265, - "cell#10": 3.265, - "cell#11": 3.265, - "cell#12": 3.265, - "cell#13": 3.265, - "cell#14": 3.265, - "cell#15": 3.265, - "cycle_capacity": 6141.413, - "power": -553.419, - "battery_charging": False, - "runtime": 39949, - "temp#0": 18.4, - "temp#1": 18.1, - "temp#2": 18.2, - "temp#3": 18.0, - "temp#4": 18.3, - } + assert await bms.async_update() == _RESULT_DEFS[protocol_type] # query again to check already connected state - result = await bms.async_update() + assert await bms.async_update() == _RESULT_DEFS[protocol_type] assert ( bms._client and bms._client.is_connected is not reconnect_fixture ) # noqa: SLF001 @@ -344,28 +455,21 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: await bms.disconnect() -async def test_hide_temp_sensors(monkeypatch) -> None: +async def test_hide_temp_sensors(monkeypatch, protocol_type) -> None: """Test Jikong BMS data update with not connected temperature sensors.""" - temp2_zero: Final = bytearray( - b"\x55\xaa\xeb\x90\x02\xc6\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c" - b"\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\xc1\x0c\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\xff\xff\x00\x00\xc1\x0c\x02\x00\x00\x07\x3a\x00\x3c\x00\x46\x00\x48\x00" - b"\x54\x00\x5c\x00\x69\x00\x76\x00\x7d\x00\x76\x00\x6c\x00\x69\x00\x61\x00\x4b\x00\x47\x00" - b"\x3c\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xb8\x00\x00\x00\x00\x00\x0a\xcc\x00\x00" - b"\xcd\x71\x08\x00\x9d\xd6\xff\xff\xb5\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x2a\x47\xcb" - b"\x01\x00\xc0\x45\x04\x00\x02\x00\x00\x00\x15\xb7\x08\x00\x64\x00\x00\x00\x6b\xc7\x06\x00" - b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xff\x00\x01\x00\x00\x00" - b"\xb2\x03\x00\x00\x1c\x00\x54\x29\x40\x40\x00\x00\x00\x00\x67\x14\x00\x00\x00\x01\x01\x01" - b"\x00\x06\x00\x00\xf3\x48\x2e\x00\x00\x00\x00\x00\xb8\x00\xb4\x00\xb7\x00\xb2\x03\xde\xe4" - b"\x5b\x08\x2c\x00\x00\x00\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\x00\xfe\xff\x7f\xdc\x2f\x01\x01\xb0\x07\x00\x00\x00\x1a" - ) # {"temperature": 18.4, "voltage": 52.234, "current": -10.595, "battery_level": 42, "cycle_charge": 117.575, "cycles": 2} + temp2_zero: dict[str, bytearray] = deepcopy(_PROTO_DEFS[protocol_type]) + + # clear temp sensor #2 + if protocol_type == "JK02_24S": + temp2_zero["cell"][134:136] = bytearray(2) + else: + temp2_zero["cell"][164:166] = bytearray(2) + # recalculate CRC + temp2_zero["cell"][-1] = crc_sum(temp2_zero["cell"][:-1]) monkeypatch.setattr( - "tests.test_jikong_bms.MockJikongBleakClient.CEL_FRAME", temp2_zero + "tests.test_jikong_bms.MockJikongBleakClient._FRAME", temp2_zero ) monkeypatch.setattr( @@ -374,57 +478,30 @@ async def test_hide_temp_sensors(monkeypatch) -> None: bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) - result = await bms.async_update() + # modify result dict to match removed temp#2 + ref_result = deepcopy(_RESULT_DEFS[protocol_type]) + if protocol_type == "JK02_24S": + ref_result["temperature"] = 18.35 + del ref_result["temp#2"] - assert result == { - "cell_count": 16, - "delta_voltage": 0.002, - "temperature": 18.2, - "voltage": 52.234, - "current": -10.595, - "battery_level": 42, - "cycle_charge": 117.575, - "cycles": 2, - "cell#0": 3.265, - "cell#1": 3.265, - "cell#2": 3.265, - "cell#3": 3.265, - "cell#4": 3.265, - "cell#5": 3.265, - "cell#6": 3.265, - "cell#7": 3.265, - "cell#8": 3.265, - "cell#9": 3.265, - "cell#10": 3.265, - "cell#11": 3.265, - "cell#12": 3.265, - "cell#13": 3.265, - "cell#14": 3.265, - "cell#15": 3.265, - "cycle_capacity": 6141.413, - "power": -553.419, - "battery_charging": False, - "runtime": 39949, - "temp#0": 18.4, - "temp#1": 18.1, - "temp#3": 18.0, - "temp#4": 18.3, - } + assert await bms.async_update() == ref_result await bms.disconnect() -async def test_stream_update(monkeypatch, reconnect_fixture) -> None: +async def test_stream_update(monkeypatch, protocol_type, reconnect_fixture) -> None: """Test Jikong BMS data update.""" monkeypatch.setattr( - "custom_components.bms_ble.plugins.basebms.BleakClient", - MockStreamBleakClient, + "tests.test_jikong_bms.MockStreamBleakClient._FRAME", _PROTO_DEFS[protocol_type] + ) + + monkeypatch.setattr( + "custom_components.bms_ble.plugins.basebms.BleakClient", MockStreamBleakClient ) monkeypatch.setattr( # mock that response has already been received - "custom_components.bms_ble.plugins.basebms.asyncio.Event.is_set", - lambda _: True, + "custom_components.bms_ble.plugins.basebms.asyncio.Event.is_set", lambda _: True ) bms = BMS( @@ -432,46 +509,10 @@ async def test_stream_update(monkeypatch, reconnect_fixture) -> None: reconnect_fixture, ) - result = await bms.async_update() - - assert result == { - "cell_count": 16, - "delta_voltage": 0.002, - "temperature": 18.2, - "voltage": 52.234, - "current": -10.595, - "battery_level": 42, - "cycle_charge": 117.575, - "cycles": 2, - "cell#0": 3.265, - "cell#1": 3.265, - "cell#2": 3.265, - "cell#3": 3.265, - "cell#4": 3.265, - "cell#5": 3.265, - "cell#6": 3.265, - "cell#7": 3.265, - "cell#8": 3.265, - "cell#9": 3.265, - "cell#10": 3.265, - "cell#11": 3.265, - "cell#12": 3.265, - "cell#13": 3.265, - "cell#14": 3.265, - "cell#15": 3.265, - "cycle_capacity": 6141.413, - "power": -553.419, - "battery_charging": False, - "runtime": 39949, - "temp#0": 18.4, - "temp#1": 18.1, - "temp#2": 18.2, - "temp#3": 18.0, - "temp#4": 18.3, - } + assert await bms.async_update() == _RESULT_DEFS[protocol_type] # query again to check already connected state - result = await bms.async_update() + assert await bms.async_update() == _RESULT_DEFS[protocol_type] assert ( bms._client and bms._client.is_connected is not reconnect_fixture ) # noqa: SLF001 @@ -482,21 +523,23 @@ async def test_stream_update(monkeypatch, reconnect_fixture) -> None: async def test_invalid_response(monkeypatch) -> None: """Test data update with BMS returning invalid data.""" + monkeypatch.setattr("custom_components.bms_ble.plugins.jikong_bms.BAT_TIMEOUT", 0.1) + + # return type 0x03 (first requested message) with incorrect CRC monkeypatch.setattr( "tests.test_jikong_bms.MockInvalidBleakClient._response", - lambda _s, _c_, d: bytearray(b"\x55\xaa\xeb\x90\x02") - + bytearray(295), # incorrect CRC, + lambda _s, _c_, d: bytearray(b"\x55\xaa\xeb\x90\x03") + bytearray(295), ) monkeypatch.setattr( - "custom_components.bms_ble.plugins.basebms.BleakClient", - MockInvalidBleakClient, + "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() - + result: BMSsample = {} + with pytest.raises(TimeoutError): + result = await bms.async_update() assert result == {} await bms.disconnect() @@ -517,13 +560,12 @@ async def test_invalid_frame_type(monkeypatch) -> None: ) monkeypatch.setattr( - "custom_components.bms_ble.plugins.basebms.BleakClient", - MockInvalidBleakClient, + "custom_components.bms_ble.plugins.basebms.BleakClient", MockInvalidBleakClient ) bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) - result = {} + result: BMSsample = {} with pytest.raises(TimeoutError): result = await bms.async_update() assert result == {} @@ -531,9 +573,14 @@ async def test_invalid_frame_type(monkeypatch) -> None: await bms.disconnect() -async def test_oversized_response(monkeypatch) -> None: +async def test_oversized_response(monkeypatch, protocol_type) -> None: """Test data update with BMS returning oversized data, result shall still be ok.""" + monkeypatch.setattr( + "tests.test_jikong_bms.MockOversizedBleakClient._FRAME", + _PROTO_DEFS[protocol_type], + ) + monkeypatch.setattr( "custom_components.bms_ble.plugins.basebms.BleakClient", MockOversizedBleakClient, @@ -541,43 +588,7 @@ async def test_oversized_response(monkeypatch) -> None: bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) - result = await bms.async_update() - - assert result == { - "cell_count": 16, - "delta_voltage": 0.002, - "temperature": 18.2, - "voltage": 52.234, - "current": -10.595, - "battery_level": 42, - "cycle_charge": 117.575, - "cycles": 2, - "cell#0": 3.265, - "cell#1": 3.265, - "cell#2": 3.265, - "cell#3": 3.265, - "cell#4": 3.265, - "cell#5": 3.265, - "cell#6": 3.265, - "cell#7": 3.265, - "cell#8": 3.265, - "cell#9": 3.265, - "cell#10": 3.265, - "cell#11": 3.265, - "cell#12": 3.265, - "cell#13": 3.265, - "cell#14": 3.265, - "cell#15": 3.265, - "cycle_capacity": 6141.413, - "power": -553.419, - "battery_charging": False, - "runtime": 39949, - "temp#0": 18.4, - "temp#1": 18.1, - "temp#2": 18.2, - "temp#3": 18.0, - "temp#4": 18.3, - } + assert await bms.async_update() == _RESULT_DEFS[protocol_type] await bms.disconnect() @@ -586,13 +597,12 @@ async def test_invalid_device(monkeypatch) -> None: """Test data update with BMS returning invalid data.""" monkeypatch.setattr( - "custom_components.bms_ble.plugins.basebms.BleakClient", - MockWrongBleakClient, + "custom_components.bms_ble.plugins.basebms.BleakClient", MockWrongBleakClient ) bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) - result = {} + result: BMSsample = {} with pytest.raises( ConnectionError, match=r"^Failed to detect characteristics from.*"