From e4798eff93a0766e09eb8d902d9006a365f33444 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Thu, 6 Jun 2024 20:13:28 +0200 Subject: [PATCH 01/28] initial jikong implementation --- custom_components/bms_ble/config_flow.py | 5 +- custom_components/bms_ble/const.py | 2 +- custom_components/bms_ble/manifest.json | 32 ++- .../bms_ble/plugins/jikong_bms.py | 262 ++++++++++++++++++ 4 files changed, 293 insertions(+), 8 deletions(-) create mode 100644 custom_components/bms_ble/plugins/jikong_bms.py diff --git a/custom_components/bms_ble/config_flow.py b/custom_components/bms_ble/config_flow.py index d3bb2f6..60c9189 100644 --- a/custom_components/bms_ble/config_flow.py +++ b/custom_components/bms_ble/config_flow.py @@ -64,9 +64,8 @@ async def async_step_bluetooth( self, discovery_info: BluetoothServiceInfoBleak ) -> FlowResult: """Handle a flow initialized by Bluetooth discovery.""" - LOGGER.debug( - "Bluetooth device detected: %s", format_mac(discovery_info.address) - ) + LOGGER.debug("Bluetooth device detected: %s", discovery_info) + await self.async_set_unique_id(discovery_info.address) self._abort_if_unique_id_configured() diff --git a/custom_components/bms_ble/const.py b/custom_components/bms_ble/const.py index 21f3903..4853a9e 100644 --- a/custom_components/bms_ble/const.py +++ b/custom_components/bms_ble/const.py @@ -9,7 +9,7 @@ ATTR_VOLTAGE, ) -BMS_TYPES = ["daly_bms", "ogt_bms"] # available BMS types +BMS_TYPES = ["daly_bms", "jikong_bms", "ogt_bms"] # available BMS types DOMAIN = "bms_ble" LOGGER = logging.getLogger(__package__) UPDATE_INTERVAL = 30 # in seconds diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 8249b88..ba8a54f 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -4,15 +4,39 @@ "bluetooth": [ { "local_name": "SmartBat-A*", - "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb" + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb" }, { "local_name": "SmartBat-B*", - "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb" + "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb" }, { "local_name": "DL-*", "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb" + }, + { + "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 240 + }, + { + "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 2917 + }, + { + "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 60097 + }, + { + "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 11531 + }, + { + "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 22596 + }, + { + "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", + "manufacturer_id": 19274 } ], "codeowners": ["@patman15"], @@ -22,7 +46,7 @@ "integration_type": "device", "iot_class": "local_polling", "issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues", - "loggers": ["bms_ble", "ogt_bms", "daly_bms"], + "loggers": ["bms_ble", "ogt_bms", "daly_bms", "jikong_bms"], "requirements": [], - "version": "1.2.2" + "version": "1.3.0a1" } diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py new file mode 100644 index 0000000..e89409b --- /dev/null +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -0,0 +1,262 @@ +"""Module to support Jikong Smart BMS.""" + +import asyncio +from collections.abc import Callable +import logging +from typing import Any + +from bleak import BleakClient +from bleak.backends.device import BLEDevice +from bleak.exc import BleakError +from bleak.uuids import normalize_uuid_str + +from ..const import ( + ATTR_BATTERY_CHARGING, + ATTR_BATTERY_LEVEL, + ATTR_CURRENT, + ATTR_CYCLE_CAP, + ATTR_CYCLE_CHRG, + ATTR_CYCLES, + ATTR_POWER, + ATTR_RUNTIME, + ATTR_TEMPERATURE, + ATTR_VOLTAGE, +) +from .basebms import BaseBMS + +BAT_TIMEOUT = 10 +LOGGER = logging.getLogger(__name__) + +# setup UUIDs, e.g. for receive: '0000fff1-0000-1000-8000-00805f9b34fb' +UUID_CHAR = normalize_uuid_str("ffe1") +UUID_SERVICE = normalize_uuid_str("ffe0") + + +class BMS(BaseBMS): + """Jikong Smart BMS class implementation.""" + + HEAD = bytes([0x55, 0xAA, 0xEB, 0x90]) + HEAD_LEN = 4 + INFO_LEN = 300 + + def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: + """Intialize private BMS members.""" + self._reconnect = reconnect + self._ble_device = ble_device + assert self._ble_device.name is not None + self._client: BleakClient | None = None + self._data: bytearray | None = None + self._data_event = asyncio.Event() + self._connected = False # flag to indicate active BLE connection + self._char_write_handle: int | None = None + self._FIELDS: list[tuple[str, int, int, Callable[[int], int | float]]] = [ + (ATTR_VOLTAGE, 150, 4, lambda x: float(x / 1000)), + (ATTR_CURRENT, 170, 2, lambda x: float(x / 1000)), + (ATTR_CYCLES, 182, 4, lambda x: int(x)), + (ATTR_BATTERY_LEVEL, 173, 1, lambda x: int(x)), + (ATTR_CYCLE_CHRG, 174, 4, lambda x: float(x/1000)), + ] + # self._FIELDS: list[tuple[str, int, int, Callable[[int], int | float]]] = [ + # (ATTR_VOLTAGE, 118, 4, lambda x: float(x / 1000)), + # (ATTR_CURRENT, 138, 2, lambda x: float(x / 1000)), + # (ATTR_CYCLES, 150, 4, lambda x: int(x)), + # (ATTR_BATTERY_LEVEL, 141, 1, lambda x: int(x)), + # (ATTR_CYCLE_CHRG, 142, 4, lambda x: float(x/1000)), + # ] 24S + + @staticmethod + def matcher_dict_list() -> list[dict[str, Any]]: + """Provide BluetoothMatcher definition.""" + return [ + { + "service_uuid": UUID_SERVICE, + "connectable": True, + "manufacturer_id": 0x00F0, + }, + { + "service_uuid": UUID_SERVICE, + "connectable": True, + "manufacturer_id": 0x0B65, + }, + { + "service_uuid": UUID_SERVICE, + "connectable": True, + "manufacturer_id": 0xEAC1, + }, + { + "service_uuid": UUID_SERVICE, + "connectable": True, + "manufacturer_id": 0x2D0B, + }, + { + "service_uuid": UUID_SERVICE, + "connectable": True, + "manufacturer_id": 0x5844, + }, + { + "service_uuid": UUID_SERVICE, + "connectable": True, + "manufacturer_id": 0x4B4A, + } + ] + + @staticmethod + def device_info() -> dict[str, str]: + """Return device information for the battery management system.""" + return {"manufacturer": "Jikong", "model": "Smart BMS"} + + async def _wait_event(self) -> None: + await self._data_event.wait() + self._data_event.clear() + + def _on_disconnect(self, client: BleakClient) -> None: + """Disconnect callback function.""" + + LOGGER.debug("Disconnected from BMS (%s)", client.address) + self._connected = False + + def _notification_handler(self, sender, data: bytearray) -> None: + LOGGER.debug("Received BLE data: %s", data) + if data[0:4] == self.HEAD: + LOGGER.debug("Response start") + self._data = data + elif len(data) and self._data is not None: + self._data += data + + if self._data is None or len(self._data) < self.INFO_LEN: + return + + crc = self.crc(self._data[0:self.INFO_LEN-1]) + if self._data[self.INFO_LEN-1] != crc: + LOGGER.debug( + "Response data CRC is invalid: %i != %i", + self._data[self.INFO_LEN-1], + self.crc(self._data[0:self.INFO_LEN-1]), + ) + return + + if self._data[4] != 0x2: + return + + self._data_event.set() + + async def _connect(self) -> None: + """Connect to the BMS and setup notification if not connected.""" + + if not self._connected: + LOGGER.debug("Connecting BMS (%s)", self._ble_device.name) + self._client = BleakClient( + self._ble_device, + disconnected_callback=self._on_disconnect, + services=[UUID_SERVICE], + ) + await self._client.connect() + char_notify_handle: int | None = None + self._char_write_handle = None + for service in self._client.services: + for char in service.characteristics: + LOGGER.debug( + "Discovered characteristic %s: %s", char.uuid, char.properties + ) + if char.uuid == UUID_CHAR: + if "notify" in char.properties: + char_notify_handle = char.handle + if ( + "write" in char.properties + or "write-without-response" in char.properties + ): + self._char_write_handle = char.handle + if char_notify_handle is None or self._char_write_handle is None: + LOGGER.debug("Failed to detect characteristics") + await self._client.disconnect() + return + await self._client.start_notify( + char_notify_handle or 0, self._notification_handler + ) + self._connected = True + else: + LOGGER.debug("BMS %s already connected", self._ble_device.name) + + async def disconnect(self) -> None: + """Disconnect the BMS and includes stoping notifications.""" + + if self._client and self._connected: + LOGGER.debug("Disconnecting BMS (%s)", self._ble_device.name) + try: + self._data_event.clear() + await self._client.disconnect() + except BleakError: + LOGGER.warning("Disconnect failed!") + + self._client = None + + def crc(self, frame: bytes): + """Calculate Jikong frame CRC.""" + return sum(frame) & 0xFF + + def cmd(self, cmd: bytes, value: list[int] | None = None) -> bytes: + """Assemble a Jikong BMS command.""" + if value is None: + value = [] + assert len(value) <= 13 + frame = bytes([*self.HEAD, cmd[0]]) + frame += bytes([len(value), *value]) + frame += bytes([0] * (13 - len(value))) + frame += bytes([self.crc(frame)]) + return frame + + async def async_update(self) -> dict[str, int | float | bool]: + """Update battery status information.""" + await self._connect() + assert self._client is not None + if not self._connected: + LOGGER.debug("Update request, but device not connected") + return {} + + # query device info + await self._client.write_gatt_char( + self._char_write_handle or 0, data=self.cmd(b"\x97") + ) + + # query cell info + await self._client.write_gatt_char( + self._char_write_handle or 0, data=self.cmd(b"\x96") + ) + + await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + + if self._data is None or len(self._data) != self.INFO_LEN: + if self._data is not None: + LOGGER.debug("Input data (%i)", len(self._data)) + return {} + + data = { + key: func(int.from_bytes(self._data[idx : idx + size], byteorder="little")) + for key, idx, size, func in self._FIELDS + } + + # calculate average temperature + # if data["numTemp"] > 0: + # data[ATTR_TEMPERATURE] = ( + # fmean( + # [ + # int.from_bytes(self._data[idx : idx + 2]) + # for idx in range( + # 64 + self.HEAD_LEN, + # 64 + self.HEAD_LEN + int(data["numTemp"]) * 2, + # 2, + # ) + # ] + # ) + # - 40 + # ) + + self.calc_values( + data, {ATTR_POWER, ATTR_BATTERY_CHARGING, ATTR_CYCLE_CAP, ATTR_RUNTIME} + ) + + if self._reconnect: + # disconnect after data update to force reconnect next time (slow!) + await self.disconnect() + + return data From cb9161a87d4a606f099dc5ace8cde09f8d603138 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 9 Jun 2024 17:50:32 +0200 Subject: [PATCH 02/28] added tests for JiKong implementation --- README.md | 3 +- .../bms_ble/plugins/jikong_bms.py | 74 ++--- pyproject.toml | 4 +- tests/bluetooth.py | 3 +- tests/test_jikong_bms.py | 313 ++++++++++++++++++ 5 files changed, 348 insertions(+), 49 deletions(-) create mode 100644 tests/test_jikong_bms.py diff --git a/README.md b/README.md index b5bd707..27b1ec3 100644 --- a/README.md +++ b/README.md @@ -22,12 +22,13 @@ Platform | Description | Unit ## Supported Devices - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-Axxxxx` or `SmartBat-Bxxxxx`) - Daly BMS (show up as `DL-xxxxxxxxxxxx`) +- JiKong BMS (HW version >=11 required) :warning: untested, please provide logs New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details. ## Installation ### Automatic -Installation can be done using [HACS](https://hacs.xyz/) by [adding a custom repository](https://hacs.xyz/docs/faq/custom_repositories/). +Installation can be done using [HACS](https://hacs.xyz/) by [adding a custom repository](https://hacs.xyz/docs/faq/custom_repositories/). [![Open your Home Assistant instance and open a repository inside the Home Assistant Community Store.](https://my.home-assistant.io/badges/hacs_repository.svg)](https://my.home-assistant.io/redirect/hacs_repository/?owner=patman15&repository=BMS_BLE-HA&category=Integration) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index e89409b..8ff814a 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -49,20 +49,14 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: self._data_event = asyncio.Event() self._connected = False # flag to indicate active BLE connection self._char_write_handle: int | None = None - self._FIELDS: list[tuple[str, int, int, Callable[[int], int | float]]] = [ - (ATTR_VOLTAGE, 150, 4, lambda x: float(x / 1000)), - (ATTR_CURRENT, 170, 2, lambda x: float(x / 1000)), - (ATTR_CYCLES, 182, 4, lambda x: int(x)), - (ATTR_BATTERY_LEVEL, 173, 1, lambda x: int(x)), - (ATTR_CYCLE_CHRG, 174, 4, lambda x: float(x/1000)), - ] - # self._FIELDS: list[tuple[str, int, int, Callable[[int], int | float]]] = [ - # (ATTR_VOLTAGE, 118, 4, lambda x: float(x / 1000)), - # (ATTR_CURRENT, 138, 2, lambda x: float(x / 1000)), - # (ATTR_CYCLES, 150, 4, lambda x: int(x)), - # (ATTR_BATTERY_LEVEL, 141, 1, lambda x: int(x)), - # (ATTR_CYCLE_CHRG, 142, 4, lambda x: float(x/1000)), - # ] 24S + self._FIELDS: list[tuple[str, int, int, bool, Callable[[int], int | float]]] = [ + (ATTR_TEMPERATURE, 144, 2, True, lambda x: float(x / 10)), + (ATTR_VOLTAGE, 150, 4, False, lambda x: float(x / 1000)), + (ATTR_CURRENT, 170, 2, False, lambda x: float(x / 1000)), + (ATTR_BATTERY_LEVEL, 173, 1, False, lambda x: int(x)), + (ATTR_CYCLE_CHRG, 174, 4, False, lambda x: float(x / 1000)), + (ATTR_CYCLES, 182, 4, False, lambda x: int(x)), + ] # Protocol: JK02_32S; JK02_24S has offset -32 @staticmethod def matcher_dict_list() -> list[dict[str, Any]]: @@ -97,7 +91,7 @@ def matcher_dict_list() -> list[dict[str, Any]]: "service_uuid": UUID_SERVICE, "connectable": True, "manufacturer_id": 0x4B4A, - } + }, ] @staticmethod @@ -123,20 +117,22 @@ def _notification_handler(self, sender, data: bytearray) -> None: elif len(data) and self._data is not None: self._data += data - if self._data is None or len(self._data) < self.INFO_LEN: + # verify that data long enough and if answer is cell info (0x2) + if ( + self._data is None + or len(self._data) < self.INFO_LEN + or self._data[4] != 0x2 + ): return - crc = self.crc(self._data[0:self.INFO_LEN-1]) - if self._data[self.INFO_LEN-1] != crc: + crc = self.crc(self._data[0 : self.INFO_LEN - 1]) + if self._data[self.INFO_LEN - 1] != crc: LOGGER.debug( "Response data CRC is invalid: %i != %i", - self._data[self.INFO_LEN-1], - self.crc(self._data[0:self.INFO_LEN-1]), + self._data[self.INFO_LEN - 1], + self.crc(self._data[0 : self.INFO_LEN - 1]), ) - return - - if self._data[4] != 0x2: - return + self._data = None # reset invalid data self._data_event.set() @@ -225,32 +221,20 @@ async def async_update(self) -> dict[str, int | float | bool]: await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - if self._data is None or len(self._data) != self.INFO_LEN: - if self._data is not None: - LOGGER.debug("Input data (%i)", len(self._data)) + if self._data is None: return {} + if len(self._data) != self.INFO_LEN: + LOGGER.debug("Wrong data length (%i): %s", len(self._data), self._data) data = { - key: func(int.from_bytes(self._data[idx : idx + size], byteorder="little")) - for key, idx, size, func in self._FIELDS + key: func( + int.from_bytes( + self._data[idx : idx + size], byteorder="little", signed=sign + ) + ) + for key, idx, size, sign, func in self._FIELDS } - # calculate average temperature - # if data["numTemp"] > 0: - # data[ATTR_TEMPERATURE] = ( - # fmean( - # [ - # int.from_bytes(self._data[idx : idx + 2]) - # for idx in range( - # 64 + self.HEAD_LEN, - # 64 + self.HEAD_LEN + int(data["numTemp"]) * 2, - # 2, - # ) - # ] - # ) - # - 40 - # ) - self.calc_values( data, {ATTR_POWER, ATTR_BATTERY_CHARGING, ATTR_CYCLE_CAP, ATTR_RUNTIME} ) diff --git a/pyproject.toml b/pyproject.toml index 4117dd2..536528a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,9 +5,9 @@ #include = ["bms_ble"] [tool.pytest.ini_options] -minversion = "6.0" +minversion = "8.0" +addopts="--cov=custom_components.bms_ble --cov-report=term-missing --cov-fail-under=100" pythonpath = [ - ".", "custom_components.bms_ble", ] testpaths = [ diff --git a/tests/bluetooth.py b/tests/bluetooth.py index 8cceb9d..b0bd8ea 100644 --- a/tests/bluetooth.py +++ b/tests/bluetooth.py @@ -2,7 +2,8 @@ from typing import Any -from bleak import AdvertisementData, BLEDevice +from bleak.backends.device import BLEDevice +from bleak.backends.scanner import AdvertisementData from homeassistant.components.bluetooth import ( SOURCE_LOCAL, diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py new file mode 100644 index 0000000..d0cb233 --- /dev/null +++ b/tests/test_jikong_bms.py @@ -0,0 +1,313 @@ +"""Test the Jikong BMS implementation.""" + +from bleak.backends.characteristic import BleakGATTCharacteristic +from bleak.backends.descriptor import BleakGATTDescriptor +from bleak.backends.service import BleakGATTServiceCollection, BleakGATTService +from bleak.exc import BleakError +from bleak.uuids import normalize_uuid_str, uuidstr_to_str +from custom_components.bms_ble.plugins.jikong_bms import BMS +from typing_extensions import Buffer +from uuid import UUID + +from .bluetooth import generate_ble_device +from .conftest import MockBleakClient + + +class MockJikongBleakClient(MockBleakClient): + """Emulate a Jikong BMS BleakClient.""" + + HEAD_READ = bytearray(b"\x55\xAA\xEB\x90") + CMD_INFO = bytearray(b"\x96") + + def _response( + self, char_specifier: BleakGATTCharacteristic | int | str, data: Buffer + ) -> bytearray: + if ( + char_specifier == 3 + and bytearray(data)[0:5] == self.HEAD_READ + self.CMD_INFO + ): + return bytearray( + b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" + b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" + b"\xAA\x0C\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\xFF\xFF" + b"\x00\x00\x9F\x0C\x1F\x00\x00\x0A\x68\x00\x68\x00\x7A\x00\x73\x00\x72\x00" + b"\x85\x00\x70\x00\x67\x00\x82\x00\x77\x00\x65\x00\x66\x00\x7E\x00\x78\x00" + b"\x74\x00\x9C\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" + b"\xAD\x00\x00\x00\x00\x00\xE9\xC9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\xB1\x00\xB1\x00\x00\x00\x00\x00\x00\x00\x00\x34\x13\x04\x00\x00\xD0\x07" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x64\x00\x00\x00\x98\xA3\x01\x00" + b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF\x00" + b"\x01\x00\x00\x00\xE2\x04\x00\x00\x01\x00\xF6\xC7\x40\x40\x00\x00\x00\x00" + b"\x30\x14\xFE\x01\x00\x01\x01\x01\x00\x06\x00\x00\x60\x0C\x00\x00\x00\x00" + b"\x00\x00\xAD\x00\xB3\x00\xB4\x00\x90\x03\xDA\x26\x9D\x07\x18\x06\x00\x00" + b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFE" + b"\xFF\x7F\xDD\x2F\x01\x01\xB0\x07\x00\x00\x00\x16" + ) # TODO: put reference here! + + return bytearray() + + async def write_gatt_char( + self, + char_specifier: BleakGATTCharacteristic | int | str, + data: Buffer, + response: bool = None, # type: ignore[implicit-optional] # same as upstream + ) -> None: + """Issue write command to GATT.""" + await super().write_gatt_char(char_specifier, data, response) + assert self._notify_callback is not None + resp = self._response(char_specifier, data) + for notify_data in [resp[i : i + 30] for i in range(0, len(resp), 30)]: + self._notify_callback("MockJikongBleakClient", notify_data) + + class JKservice(BleakGATTService): + + class CharBase(BleakGATTCharacteristic): + """Basic characteristic for common properties.""" + + @property + def service_handle(self) -> int: + """The integer handle of the Service containing this characteristic.""" + return 0 + + @property + def handle(self) -> int: + """The handle for this characteristic.""" + return 3 + + @property + def service_uuid(self) -> str: + """The UUID of the Service containing this characteristic.""" + return normalize_uuid_str("ffe0") + + @property + def uuid(self) -> str: + """The UUID for this characteristic.""" + return normalize_uuid_str("ffe1") + + @property + def descriptors(self) -> list[BleakGATTDescriptor]: + """List of descriptors for this service.""" + return [] + + def get_descriptor( + self, specifier: int | str | UUID + ) -> BleakGATTDescriptor | None: + """Get a descriptor by handle (int) or UUID (str or uuid.UUID).""" + raise NotImplementedError + + def add_descriptor(self, descriptor: BleakGATTDescriptor) -> None: + """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. + + Should not be used by end user, but rather by `bleak` itself. + """ + raise NotImplementedError + + class CharNotify(CharBase): + """Characteristic for notifications.""" + + @property + def properties(self) -> list[str]: + """Properties of this characteristic.""" + return ["notify"] + + class CharWrite(CharBase): + """Characteristic for writing.""" + + @property + def properties(self) -> list[str]: + """Properties of this characteristic.""" + return ["write", "write-without-response"] + + @property + def handle(self) -> int: + """The handle of this service""" + return 2 + + @property + def uuid(self) -> str: + """The UUID to this service""" + return normalize_uuid_str("ffe0") + + @property + def description(self) -> str: + """String description for this service""" + return uuidstr_to_str(self.uuid) + + @property + def characteristics(self) -> list[BleakGATTCharacteristic]: + """List of characteristics for this service""" + return list([self.CharNotify(None, 350), self.CharWrite(None, 350)]) + + def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None: + """Add a :py:class:`~BleakGATTCharacteristic` to the service. + + Should not be used by end user, but rather by `bleak` itself. + """ + raise NotImplementedError + + @property + def services(self) -> BleakGATTServiceCollection: + """Emulate JiKong BT service setup.""" + + ServCol = BleakGATTServiceCollection() + ServCol.add_service(self.JKservice(None)) + + return ServCol + + +class MockWrongBleakClient(MockBleakClient): + @property + def services(self) -> BleakGATTServiceCollection: + """Emulate JiKong BT service setup.""" + + ServCol = BleakGATTServiceCollection() + + return ServCol + + +class MockInvalidBleakClient(MockJikongBleakClient): + """Emulate a Jikong BMS BleakClient returning wrong data.""" + + def _response( + self, char_specifier: BleakGATTCharacteristic | int | str, data: Buffer + ) -> bytearray: + if char_specifier == 3: + return self.HEAD_READ + bytearray(b"\x02") + bytearray(295) + + return bytearray() + + async def disconnect(self) -> bool: + """Mock disconnect to raise BleakError.""" + raise BleakError + + +class MockOversizedBleakClient(MockJikongBleakClient): + """Emulate a Jikong BMS BleakClient returning wrong data length.""" + + def _response( + self, char_specifier: BleakGATTCharacteristic | int | str, data: Buffer + ) -> bytearray: + if char_specifier == 3: + return bytearray( + b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" + b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" + b"\xAA\x0C\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\xFF\xFF" + b"\x00\x00\x9F\x0C\x1F\x00\x00\x0A\x68\x00\x68\x00\x7A\x00\x73\x00\x72\x00" + b"\x85\x00\x70\x00\x67\x00\x82\x00\x77\x00\x65\x00\x66\x00\x7E\x00\x78\x00" + b"\x74\x00\x9C\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" + b"\xAD\x00\x00\x00\x00\x00\xE9\xC9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\xB1\x00\xB1\x00\x00\x00\x00\x00\x00\x00\x00\x34\x13\x04\x00\x00\xD0\x07" + b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x64\x00\x00\x00\x98\xA3\x01\x00" + b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF\x00" + b"\x01\x00\x00\x00\xE2\x04\x00\x00\x01\x00\xF6\xC7\x40\x40\x00\x00\x00\x00" + b"\x30\x14\xFE\x01\x00\x01\x01\x01\x00\x06\x00\x00\x60\x0C\x00\x00\x00\x00" + b"\x00\x00\xAD\x00\xB3\x00\xB4\x00\x90\x03\xDA\x26\x9D\x07\x18\x06\x00\x00" + b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFE" + b"\xFF\x7F\xDD\x2F\x01\x01\xB0\x07\x00\x00\x00\x16" + b"\00\00\00\00\00\00" # oversized response + ) # TODO: put reference here! + + return bytearray() + + async def disconnect(self) -> bool: + """Mock disconnect to raise BleakError.""" + raise BleakError + + +async def test_update(monkeypatch, reconnect_fixture) -> None: + """Test Jikong BMS data update.""" + + monkeypatch.setattr( + "custom_components.bms_ble.plugins.jikong_bms.BleakClient", + MockJikongBleakClient, + ) + + bms = BMS( + generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73), + reconnect_fixture, + ) + + result = await bms.async_update() + + assert result == { + "voltage": 51.689, + "current": 0.0, + "battery_level": 52.0, + "cycles": 0, + "cycle_charge": 1.043, + "cycle_capacity": 53.911626999999996, + "power": 0.0, + "battery_charging": False, + "temperature": 17.3, + } + + # query again to check already connected state + result = await bms.async_update() + assert bms._connected is not reconnect_fixture + + await bms.disconnect() + + +async def test_invalid_response(monkeypatch) -> None: + """Test data update with BMS returning invalid data.""" + + monkeypatch.setattr( + "custom_components.bms_ble.plugins.jikong_bms.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.jikong_bms.BleakClient", + MockOversizedBleakClient, + ) + + bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) + + result = await bms.async_update() + + assert result == { + "voltage": 51.689, + "current": 0.0, + "battery_level": 52.0, + "cycles": 0, + "cycle_charge": 1.043, + "cycle_capacity": 53.911626999999996, + "power": 0.0, + "battery_charging": False, + "temperature": 17.3, + } + + await bms.disconnect() + + +async def test_invalid_device(monkeypatch) -> None: + """Test data update with BMS returning invalid data.""" + + monkeypatch.setattr( + "custom_components.bms_ble.plugins.jikong_bms.BleakClient", + MockWrongBleakClient, + ) + + bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEdevice", None, -73)) + + result = await bms.async_update() + + assert result == {} + + await bms.disconnect() From 11ae2a2db389f4aab78931f4af498d6b7bdf2fd3 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:14:17 +0200 Subject: [PATCH 03/28] enhanced debugging --- custom_components/bms_ble/plugins/jikong_bms.py | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 8ff814a..ba0f2c6 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -152,8 +152,14 @@ async def _connect(self) -> None: for service in self._client.services: for char in service.characteristics: LOGGER.debug( - "Discovered characteristic %s: %s", char.uuid, char.properties + "Discovered %s (#%i): %s", + char.uuid, + char.handle, + char.properties, ) + if "read" in char.properties: # TODO: debugging only! + value: bytearray = await self._client.read_gatt_char(char) + LOGGER.debug("value: %s", value) if char.uuid == UUID_CHAR: if "notify" in char.properties: char_notify_handle = char.handle @@ -166,6 +172,11 @@ async def _connect(self) -> None: LOGGER.debug("Failed to detect characteristics") await self._client.disconnect() return + LOGGER.debug( + "Using characteristics handle #%i (notify), #%i (write)", + char_notify_handle, + self._char_write_handle, + ) await self._client.start_notify( char_notify_handle or 0, self._notification_handler ) From ffee5549da4acd74f2da89be684d1e1512c1742f Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sun, 9 Jun 2024 19:27:46 +0200 Subject: [PATCH 04/28] Update README.md --- README.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 27b1ec3..9e740c9 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Platform | Description | Unit ## Supported Devices - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-Axxxxx` or `SmartBat-Bxxxxx`) - Daly BMS (show up as `DL-xxxxxxxxxxxx`) -- JiKong BMS (HW version >=11 required) :warning: untested, please provide logs +- JiKong BMS (HW version >=11 required)
:warning: untested, please [open an issue](https://github.com/patman15/BMS_BLE-HA/issues), if you have logs for working/non-working devices New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details. @@ -68,6 +68,7 @@ Sure, use, e.g. a [threshold sensor](https://my.home-assistant.io/redirect/confi ## References - [Home Assistant Add-on: BatMON](https://github.com/fl4p/batmon-ha) - Daly BMS: [esp32-smart-bms-simulation](https://github.com/roccotsi2/esp32-smart-bms-simulation) +- JiKong BMS: [esphome-jk-bms](https://github.com/syssi/esphome-jk-bms) [license-shield]: https://img.shields.io/github/license/patman15/BMS_BLE-HA.svg?style=for-the-badge [releases-shield]: https://img.shields.io/github/release/patman15/BMS_BLE-HA.svg?style=for-the-badge From 2c39ebfdcfa477d98a749c1ab3ea772fbf90de2f Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:11:32 +0200 Subject: [PATCH 05/28] fixed command endiness --- custom_components/bms_ble/plugins/jikong_bms.py | 8 ++++---- tests/test_jikong_bms.py | 6 +++--- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index ba0f2c6..63ec5a8 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -35,8 +35,8 @@ class BMS(BaseBMS): """Jikong Smart BMS class implementation.""" - HEAD = bytes([0x55, 0xAA, 0xEB, 0x90]) - HEAD_LEN = 4 + HEAD_RSP = bytes([0x55, 0xAA, 0xEB, 0x90]) # header for responses + HEAD_CMD = bytes([0xAA, 0x55, 0x90, 0xEB]) # header for commands (endiness!) INFO_LEN = 300 def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: @@ -111,7 +111,7 @@ def _on_disconnect(self, client: BleakClient) -> None: def _notification_handler(self, sender, data: bytearray) -> None: LOGGER.debug("Received BLE data: %s", data) - if data[0:4] == self.HEAD: + if data[0:4] == self.HEAD_RSP: LOGGER.debug("Response start") self._data = data elif len(data) and self._data is not None: @@ -206,7 +206,7 @@ def cmd(self, cmd: bytes, value: list[int] | None = None) -> bytes: if value is None: value = [] assert len(value) <= 13 - frame = bytes([*self.HEAD, cmd[0]]) + frame = bytes([*self.HEAD_CMD, cmd[0]]) frame += bytes([len(value), *value]) frame += bytes([0] * (13 - len(value))) frame += bytes([self.crc(frame)]) diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index d0cb233..2275fb2 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -16,7 +16,7 @@ class MockJikongBleakClient(MockBleakClient): """Emulate a Jikong BMS BleakClient.""" - HEAD_READ = bytearray(b"\x55\xAA\xEB\x90") + HEAD_CMD = bytearray(b"\xAA\x55\x90\xEB") CMD_INFO = bytearray(b"\x96") def _response( @@ -24,7 +24,7 @@ def _response( ) -> bytearray: if ( char_specifier == 3 - and bytearray(data)[0:5] == self.HEAD_READ + self.CMD_INFO + and bytearray(data)[0:5] == self.HEAD_CMD + self.CMD_INFO ): return bytearray( b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" @@ -174,7 +174,7 @@ def _response( self, char_specifier: BleakGATTCharacteristic | int | str, data: Buffer ) -> bytearray: if char_specifier == 3: - return self.HEAD_READ + bytearray(b"\x02") + bytearray(295) + return bytearray(b"\x55\xAA\xEB\x90\x02") + bytearray(295) return bytearray() From 8b6f83c857103ad34c424f8e06b7ac44612d6cca Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Mon, 10 Jun 2024 21:11:45 +0200 Subject: [PATCH 06/28] added debug for BLE adv --- custom_components/bms_ble/coordinator.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/bms_ble/coordinator.py b/custom_components/bms_ble/coordinator.py index f6caa91..14e674c 100644 --- a/custom_components/bms_ble/coordinator.py +++ b/custom_components/bms_ble/coordinator.py @@ -34,6 +34,7 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), always_update=False, # only update when sensor value has changed ) + self._mac = ble_device.address LOGGER.debug( "Initializing coordinator for %s (%s) as %s", @@ -41,6 +42,10 @@ def __init__( ble_device.address, bms_device.device_id(), ) + if service_info := bluetooth.async_last_service_info( + self.hass, address=self._mac, connectable=True + ): + LOGGER.debug("device data: %s", service_info.as_dict()) # retrieve BMS class and initialize it self._device: BaseBMS = bms_device From 3a473ebfb15d463cfbdc5e69c00e7711f139df16 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:18:50 +0200 Subject: [PATCH 07/28] filtering AT commands filter for BT cmds made debugging for multiple devices --- .../bms_ble/plugins/jikong_bms.py | 56 ++++++++++++++----- tests/test_jikong_bms.py | 4 +- 2 files changed, 44 insertions(+), 16 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 63ec5a8..125e768 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -35,8 +35,10 @@ class BMS(BaseBMS): """Jikong Smart BMS class implementation.""" - HEAD_RSP = bytes([0x55, 0xAA, 0xEB, 0x90]) # header for responses - HEAD_CMD = bytes([0xAA, 0x55, 0x90, 0xEB]) # header for commands (endiness!) + HEAD_RSP = bytes([0x55, 0xAA, 0xEB, 0x90]) # header for responses + HEAD_CMD = bytes([0xAA, 0x55, 0x90, 0xEB]) # header for commands (endiness!) + BT_MODULE_MSG = bytes([0x41, 0x54, 0x0D, 0x0A]) # AT\r\n from BLE module + INFO_LEN = 300 def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: @@ -106,17 +108,28 @@ async def _wait_event(self) -> None: def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" - LOGGER.debug("Disconnected from BMS (%s)", client.address) + LOGGER.debug("Disconnected from BMS (%s)", self._ble_device.name) self._connected = False def _notification_handler(self, sender, data: bytearray) -> None: - LOGGER.debug("Received BLE data: %s", data) - if data[0:4] == self.HEAD_RSP: - LOGGER.debug("Response start") + if data[0 : len(self.BT_MODULE_MSG)] == self.BT_MODULE_MSG: + if len(data) == len(self.BT_MODULE_MSG): + LOGGER.debug("(%s) filtering AT cmd.", self._ble_device.name) + return + data = data[len(self.BT_MODULE_MSG) :] + + if data[0 : len(self.HEAD_RSP)] == self.HEAD_RSP: self._data = data elif len(data) and self._data is not None: 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 and if answer is cell info (0x2) if ( self._data is None @@ -128,7 +141,8 @@ def _notification_handler(self, sender, data: bytearray) -> None: crc = self.crc(self._data[0 : self.INFO_LEN - 1]) if self._data[self.INFO_LEN - 1] != crc: LOGGER.debug( - "Response data CRC is invalid: %i != %i", + "(%s) Rx data CRC is invalid: %i != %i", + self._ble_device.name, self._data[self.INFO_LEN - 1], self.crc(self._data[0 : self.INFO_LEN - 1]), ) @@ -151,15 +165,17 @@ async def _connect(self) -> None: self._char_write_handle = None for service in self._client.services: for char in service.characteristics: + value: bytearray = bytearray() + if "read" in char.properties: # TODO: debugging only! + value = await self._client.read_gatt_char(char) LOGGER.debug( - "Discovered %s (#%i): %s", + "(%s) Discovered %s (#%i): %s%s", + self._ble_device.name, char.uuid, char.handle, char.properties, + f" value: {value}" if value else "", ) - if "read" in char.properties: # TODO: debugging only! - value: bytearray = await self._client.read_gatt_char(char) - LOGGER.debug("value: %s", value) if char.uuid == UUID_CHAR: if "notify" in char.properties: char_notify_handle = char.handle @@ -169,11 +185,14 @@ async def _connect(self) -> None: ): self._char_write_handle = char.handle if char_notify_handle is None or self._char_write_handle is None: - LOGGER.debug("Failed to detect characteristics") + LOGGER.debug( + "(%s) Failed to detect characteristics", self._ble_device.name + ) await self._client.disconnect() return LOGGER.debug( - "Using characteristics handle #%i (notify), #%i (write)", + "(%s) Using characteristics handle #%i (notify), #%i (write)", + self._ble_device.name, char_notify_handle, self._char_write_handle, ) @@ -217,7 +236,9 @@ async def async_update(self) -> dict[str, int | float | bool]: await self._connect() assert self._client is not None if not self._connected: - LOGGER.debug("Update request, but device not connected") + LOGGER.debug( + "Update request, but device (%s) not connected", self._ble_device.name + ) return {} # query device info @@ -235,7 +256,12 @@ async def async_update(self) -> dict[str, int | float | bool]: if self._data is None: return {} if len(self._data) != self.INFO_LEN: - LOGGER.debug("Wrong data length (%i): %s", len(self._data), self._data) + LOGGER.debug( + "(%s) Wrong data length (%i): %s", + self._ble_device.name, + len(self._data), + self._data, + ) data = { key: func( diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index 2275fb2..cef3a5a 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -27,6 +27,7 @@ def _response( and bytearray(data)[0:5] == self.HEAD_CMD + self.CMD_INFO ): return bytearray( + b"\x41\x54\x0d\x0a" # added AT\r\n command b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" b"\xAA\x0C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" @@ -55,8 +56,9 @@ async def write_gatt_char( response: bool = None, # type: ignore[implicit-optional] # same as upstream ) -> None: """Issue write command to GATT.""" - await super().write_gatt_char(char_specifier, data, response) + #await super().write_gatt_char(char_specifier, data, response) assert self._notify_callback is not None + self._notify_callback("MockJikongBleakClient", bytearray(b'\x41\x54\x0d\x0a')) # # interleaced AT\r\n command resp = self._response(char_specifier, data) for notify_data in [resp[i : i + 30] for i in range(0, len(resp), 30)]: self._notify_callback("MockJikongBleakClient", notify_data) From ebab3d5ce51c06041e2441fb7c8c28964b03be04 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:38:11 +0200 Subject: [PATCH 08/28] fixed current readout --- custom_components/bms_ble/plugins/jikong_bms.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 125e768..0347e31 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -54,7 +54,7 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: self._FIELDS: list[tuple[str, int, int, bool, Callable[[int], int | float]]] = [ (ATTR_TEMPERATURE, 144, 2, True, lambda x: float(x / 10)), (ATTR_VOLTAGE, 150, 4, False, lambda x: float(x / 1000)), - (ATTR_CURRENT, 170, 2, False, lambda x: float(x / 1000)), + (ATTR_CURRENT, 158, 2, False, lambda x: float(x / 1000)), (ATTR_BATTERY_LEVEL, 173, 1, False, lambda x: int(x)), (ATTR_CYCLE_CHRG, 174, 4, False, lambda x: float(x / 1000)), (ATTR_CYCLES, 182, 4, False, lambda x: int(x)), From 8008f790d4338172b63ae26cd035f63395d2bca5 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:39:40 +0200 Subject: [PATCH 09/28] updated readout for test --- tests/test_jikong_bms.py | 127 +++++++++++++++++++++++---------------- 1 file changed, 74 insertions(+), 53 deletions(-) diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index cef3a5a..25454b9 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -27,24 +27,41 @@ def _response( and bytearray(data)[0:5] == self.HEAD_CMD + self.CMD_INFO ): return bytearray( - b"\x41\x54\x0d\x0a" # added AT\r\n command - b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" - b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" - b"\xAA\x0C\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\xFF\xFF" - b"\x00\x00\x9F\x0C\x1F\x00\x00\x0A\x68\x00\x68\x00\x7A\x00\x73\x00\x72\x00" - b"\x85\x00\x70\x00\x67\x00\x82\x00\x77\x00\x65\x00\x66\x00\x7E\x00\x78\x00" - b"\x74\x00\x9C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x41\x54\x0d\x0a" # added AT\r\n command + b"\x55\xaa\xeb\x90\x02\xc6\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\xc1\x0c" + b"\xc1\x0c\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\xff\xff" + b"\x00\x00\xc1\x0c\x02\x00\x00\x07\x3a\x00\x3c\x00\x46\x00\x48\x00\x54\x00" + b"\x5c\x00\x69\x00\x76\x00\x7d\x00\x76\x00\x6c\x00\x69\x00\x61\x00\x4b\x00" + b"\x47\x00\x3c\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" - b"\xAD\x00\x00\x00\x00\x00\xE9\xC9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\xB1\x00\xB1\x00\x00\x00\x00\x00\x00\x00\x00\x34\x13\x04\x00\x00\xD0\x07" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x64\x00\x00\x00\x98\xA3\x01\x00" - b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF\x00" - b"\x01\x00\x00\x00\xE2\x04\x00\x00\x01\x00\xF6\xC7\x40\x40\x00\x00\x00\x00" - b"\x30\x14\xFE\x01\x00\x01\x01\x01\x00\x06\x00\x00\x60\x0C\x00\x00\x00\x00" - b"\x00\x00\xAD\x00\xB3\x00\xB4\x00\x90\x03\xDA\x26\x9D\x07\x18\x06\x00\x00" - b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFE" - b"\xFF\x7F\xDD\x2F\x01\x01\xB0\x07\x00\x00\x00\x16" + b"\xb8\x00\x00\x00\x00\x00\x0a\xcc\x00\x00\xcd\x71\x08\x00\x9d\xd6\xff\xff" + b"\xb5\x00\xb6\x00\x00\x00\x00\x00\x00\x00\x00\x2a\x47\xcb\x01\x00\xc0\x45" + b"\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" + b"\x01\x00\x00\x00\xb2\x03\x00\x00\x1c\x00\x54\x29\x40\x40\x00\x00\x00\x00" + b"\x67\x14\x00\x00\x00\x01\x01\x01\x00\x06\x00\x00\xf3\x48\x2e\x00\x00\x00" + b"\x00\x00\xb8\x00\xb4\x00\xb7\x00\xb2\x03\xde\xe4\x5b\x08\x2c\x00\x00\x00" + b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe" + b"\xff\x7f\xdc\x2f\x01\x01\xb0\x07\x00\x00\x00\xd0" + # b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" + # b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" + # b"\xAA\x0C\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\xFF\xFF" + # b"\x00\x00\x9F\x0C\x1F\x00\x00\x0A\x68\x00\x68\x00\x7A\x00\x73\x00\x72\x00" + # b"\x85\x00\x70\x00\x67\x00\x82\x00\x77\x00\x65\x00\x66\x00\x7E\x00\x78\x00" + # b"\x74\x00\x9C\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" + # b"\xAD\x00\x00\x00\x00\x00\xE9\xC9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + # b"\xB1\x00\xB1\x00\x00\x00\x00\x00\x00\x00\x00\x34\x13\x04\x00\x00\xD0\x07" + # b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x64\x00\x00\x00\x98\xA3\x01\x00" + # b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF\x00" + # b"\x01\x00\x00\x00\xE2\x04\x00\x00\x01\x00\xF6\xC7\x40\x40\x00\x00\x00\x00" + # b"\x30\x14\xFE\x01\x00\x01\x01\x01\x00\x06\x00\x00\x60\x0C\x00\x00\x00\x00" + # b"\x00\x00\xAD\x00\xB3\x00\xB4\x00\x90\x03\xDA\x26\x9D\x07\x18\x06\x00\x00" + # b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFE" + # b"\xFF\x7F\xDD\x2F\x01\x01\xB0\x07\x00\x00\x00\x16" ) # TODO: put reference here! return bytearray() @@ -56,9 +73,11 @@ async def write_gatt_char( response: bool = None, # type: ignore[implicit-optional] # same as upstream ) -> None: """Issue write command to GATT.""" - #await super().write_gatt_char(char_specifier, data, response) + # await super().write_gatt_char(char_specifier, data, response) assert self._notify_callback is not None - self._notify_callback("MockJikongBleakClient", bytearray(b'\x41\x54\x0d\x0a')) # # interleaced AT\r\n command + self._notify_callback( + "MockJikongBleakClient", bytearray(b"\x41\x54\x0d\x0a") + ) # # interleaced AT\r\n command resp = self._response(char_specifier, data) for notify_data in [resp[i : i + 30] for i in range(0, len(resp), 30)]: self._notify_callback("MockJikongBleakClient", notify_data) @@ -193,23 +212,23 @@ def _response( ) -> bytearray: if char_specifier == 3: return bytearray( - b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" - b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" - b"\xAA\x0C\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\xFF\xFF" - b"\x00\x00\x9F\x0C\x1F\x00\x00\x0A\x68\x00\x68\x00\x7A\x00\x73\x00\x72\x00" - b"\x85\x00\x70\x00\x67\x00\x82\x00\x77\x00\x65\x00\x66\x00\x7E\x00\x78\x00" - b"\x74\x00\x9C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" + b"\x55\xaa\xeb\x90\x02\xc6\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\xc1\x0c" + b"\xc1\x0c\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\xff\xff" + b"\x00\x00\xc1\x0c\x02\x00\x00\x07\x3a\x00\x3c\x00\x46\x00\x48\x00\x54\x00" + b"\x5c\x00\x69\x00\x76\x00\x7d\x00\x76\x00\x6c\x00\x69\x00\x61\x00\x4b\x00" + b"\x47\x00\x3c\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" - b"\xAD\x00\x00\x00\x00\x00\xE9\xC9\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" - b"\xB1\x00\xB1\x00\x00\x00\x00\x00\x00\x00\x00\x34\x13\x04\x00\x00\xD0\x07" - b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x64\x00\x00\x00\x98\xA3\x01\x00" - b"\x01\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFF\x00" - b"\x01\x00\x00\x00\xE2\x04\x00\x00\x01\x00\xF6\xC7\x40\x40\x00\x00\x00\x00" - b"\x30\x14\xFE\x01\x00\x01\x01\x01\x00\x06\x00\x00\x60\x0C\x00\x00\x00\x00" - b"\x00\x00\xAD\x00\xB3\x00\xB4\x00\x90\x03\xDA\x26\x9D\x07\x18\x06\x00\x00" - b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xFE" - b"\xFF\x7F\xDD\x2F\x01\x01\xB0\x07\x00\x00\x00\x16" + b"\xb8\x00\x00\x00\x00\x00\x0a\xcc\x00\x00\xcd\x71\x08\x00\x9d\xd6\xff\xff" + b"\xb5\x00\xb6\x00\x00\x00\x00\x00\x00\x00\x00\x2a\x47\xcb\x01\x00\xc0\x45" + b"\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" + b"\x01\x00\x00\x00\xb2\x03\x00\x00\x1c\x00\x54\x29\x40\x40\x00\x00\x00\x00" + b"\x67\x14\x00\x00\x00\x01\x01\x01\x00\x06\x00\x00\xf3\x48\x2e\x00\x00\x00" + b"\x00\x00\xb8\x00\xb4\x00\xb7\x00\xb2\x03\xde\xe4\x5b\x08\x2c\x00\x00\x00" + b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe" + b"\xff\x7f\xdc\x2f\x01\x01\xb0\x07\x00\x00\x00\xd0" b"\00\00\00\00\00\00" # oversized response ) # TODO: put reference here! @@ -236,15 +255,16 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: result = await bms.async_update() assert result == { - "voltage": 51.689, - "current": 0.0, - "battery_level": 52.0, - "cycles": 0, - "cycle_charge": 1.043, - "cycle_capacity": 53.911626999999996, - "power": 0.0, - "battery_charging": False, - "temperature": 17.3, + "temperature": 18.4, + "voltage": 52.234, + "current": 54.941, + "battery_level": 42, + "cycle_charge": 117.575, + "cycles": 2, + "cycle_capacity": 6141.41255, + "power": 2869.788194, + "battery_charging": True, + "runtime": 7704, } # query again to check already connected state @@ -284,15 +304,16 @@ async def test_oversized_response(monkeypatch) -> None: result = await bms.async_update() assert result == { - "voltage": 51.689, - "current": 0.0, - "battery_level": 52.0, - "cycles": 0, - "cycle_charge": 1.043, - "cycle_capacity": 53.911626999999996, - "power": 0.0, - "battery_charging": False, - "temperature": 17.3, + "temperature": 18.4, + "voltage": 52.234, + "current": 54.941, + "battery_level": 42, + "cycle_charge": 117.575, + "cycles": 2, + "cycle_capacity": 6141.41255, + "power": 2869.788194, + "battery_charging": True, + "runtime": 7704, } await bms.disconnect() From ee385f2e3284426c22561cd4370547e195eba0ec Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:50:02 +0200 Subject: [PATCH 10/28] fixed race condition due to continuous notifications data could have been modified before interpreted --- .../bms_ble/plugins/jikong_bms.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 0347e31..7b4961e 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -48,6 +48,7 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: assert self._ble_device.name is not None self._client: BleakClient | None = None self._data: bytearray | None = None + self._data_final: bytearray | None = None self._data_event = asyncio.Event() self._connected = False # flag to indicate active BLE connection self._char_write_handle: int | None = None @@ -146,7 +147,9 @@ def _notification_handler(self, sender, data: bytearray) -> None: self._data[self.INFO_LEN - 1], self.crc(self._data[0 : self.INFO_LEN - 1]), ) - self._data = None # reset invalid data + self._data_final = None # reset invalid data + else: + self._data_final = self._data self._data_event.set() @@ -199,6 +202,12 @@ async def _connect(self) -> None: await self._client.start_notify( char_notify_handle or 0, self._notification_handler ) + + # query device info + await self._client.write_gatt_char( + self._char_write_handle or 0, data=self.cmd(b"\x97") + ) + self._connected = True else: LOGGER.debug("BMS %s already connected", self._ble_device.name) @@ -241,11 +250,6 @@ async def async_update(self) -> dict[str, int | float | bool]: ) return {} - # query device info - await self._client.write_gatt_char( - self._char_write_handle or 0, data=self.cmd(b"\x97") - ) - # query cell info await self._client.write_gatt_char( self._char_write_handle or 0, data=self.cmd(b"\x96") @@ -253,20 +257,20 @@ async def async_update(self) -> dict[str, int | float | bool]: await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - if self._data is None: + if self._data_final is None: return {} - if len(self._data) != self.INFO_LEN: + if len(self._data_final) != self.INFO_LEN: LOGGER.debug( "(%s) Wrong data length (%i): %s", self._ble_device.name, - len(self._data), - self._data, + len(self._data_final), + self._data_final, ) data = { key: func( int.from_bytes( - self._data[idx : idx + size], byteorder="little", signed=sign + self._data_final[idx : idx + size], byteorder="little", signed=sign ) ) for key, idx, size, sign, func in self._FIELDS From 28034ffa86a16d81d8e1ed6fea1b19465529b5d7 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 11:51:38 +0200 Subject: [PATCH 11/28] changed to beta version --- custom_components/bms_ble/manifest.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index ba8a54f..c0f6c64 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -48,5 +48,5 @@ "issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues", "loggers": ["bms_ble", "ogt_bms", "daly_bms", "jikong_bms"], "requirements": [], - "version": "1.3.0a1" + "version": "1.3.0b1" } From 773577e3205846d74c134b7601a43e825166d7a1 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:23:54 +0200 Subject: [PATCH 12/28] fixed current readout --- custom_components/bms_ble/plugins/jikong_bms.py | 2 +- tests/test_jikong_bms.py | 15 +++++++-------- 2 files changed, 8 insertions(+), 9 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 7b4961e..8cd25a4 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -55,7 +55,7 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: self._FIELDS: list[tuple[str, int, int, bool, Callable[[int], int | float]]] = [ (ATTR_TEMPERATURE, 144, 2, True, lambda x: float(x / 10)), (ATTR_VOLTAGE, 150, 4, False, lambda x: float(x / 1000)), - (ATTR_CURRENT, 158, 2, False, lambda x: float(x / 1000)), + (ATTR_CURRENT, 158, 4, True, lambda x: float(x / 1000)), (ATTR_BATTERY_LEVEL, 173, 1, False, lambda x: int(x)), (ATTR_CYCLE_CHRG, 174, 4, False, lambda x: float(x / 1000)), (ATTR_CYCLES, 182, 4, False, lambda x: int(x)), diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index 25454b9..254754e 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -45,6 +45,7 @@ def _response( b"\x00\x00\xb8\x00\xb4\x00\xb7\x00\xb2\x03\xde\xe4\x5b\x08\x2c\x00\x00\x00" b"\x80\x51\x01\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\xfe" b"\xff\x7f\xdc\x2f\x01\x01\xb0\x07\x00\x00\x00\xd0" + ### # b"\x55\xAA\xEB\x90\x02\xE8\xAE\x0C\x9E\x0C\x9A\x0C\x9F\x0C\xA1\x0C\x9F\x0C" # b"\xA0\x0C\xA0\x0C\x99\x0C\xA0\x0C\x90\x0C\x99\x0C\xA5\x0C\x9F\x0C\x99\x0C" # b"\xAA\x0C\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00" @@ -257,14 +258,13 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: assert result == { "temperature": 18.4, "voltage": 52.234, - "current": 54.941, + "current": -10.595, "battery_level": 42, "cycle_charge": 117.575, "cycles": 2, "cycle_capacity": 6141.41255, - "power": 2869.788194, - "battery_charging": True, - "runtime": 7704, + "power": -553.4192300000001, + "battery_charging": False, } # query again to check already connected state @@ -306,14 +306,13 @@ async def test_oversized_response(monkeypatch) -> None: assert result == { "temperature": 18.4, "voltage": 52.234, - "current": 54.941, + "current": -10.595, "battery_level": 42, "cycle_charge": 117.575, "cycles": 2, "cycle_capacity": 6141.41255, - "power": 2869.788194, - "battery_charging": True, - "runtime": 7704, + "power": -553.4192300000001, + "battery_charging": False, } await bms.disconnect() From 7babd1dd652db6fd657016048f390a6d0fe92237 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:37:35 +0200 Subject: [PATCH 13/28] modified protocol sequence --- custom_components/bms_ble/plugins/jikong_bms.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 8cd25a4..5fff882 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -104,7 +104,7 @@ def device_info() -> dict[str, str]: async def _wait_event(self) -> None: await self._data_event.wait() - self._data_event.clear() + self._data_event.clear() # TODO: clear here or only on connect? def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" @@ -113,6 +113,9 @@ def _on_disconnect(self, client: BleakClient) -> None: self._connected = False def _notification_handler(self, sender, data: bytearray) -> None: + if self._data_event.is_set(): + return + if data[0 : len(self.BT_MODULE_MSG)] == self.BT_MODULE_MSG: if len(data) == len(self.BT_MODULE_MSG): LOGGER.debug("(%s) filtering AT cmd.", self._ble_device.name) @@ -155,6 +158,7 @@ def _notification_handler(self, sender, data: bytearray) -> None: async def _connect(self) -> None: """Connect to the BMS and setup notification if not connected.""" + self._data_event.clear() if not self._connected: LOGGER.debug("Connecting BMS (%s)", self._ble_device.name) From 4d88efdc915d9e1917d58508a9e67c54c80e2129 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:49:16 +0200 Subject: [PATCH 14/28] added response to AT cmd --- custom_components/bms_ble/plugins/jikong_bms.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 5fff882..5bd1bf2 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -207,6 +207,11 @@ async def _connect(self) -> None: char_notify_handle or 0, self._notification_handler ) + # send ok in response to AT command + await self._client.write_gatt_char( + self._char_write_handle or 0, data=self.cmd(b"OK\r\n") + ) + # query device info await self._client.write_gatt_char( self._char_write_handle or 0, data=self.cmd(b"\x97") From 64233e31d859795d3860f787ee8bf51594428316 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 16:49:27 +0200 Subject: [PATCH 15/28] fixed oversized msg test --- tests/test_jikong_bms.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index 254754e..3a2113d 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -78,9 +78,9 @@ async def write_gatt_char( assert self._notify_callback is not None self._notify_callback( "MockJikongBleakClient", bytearray(b"\x41\x54\x0d\x0a") - ) # # interleaced AT\r\n command + ) # interleaved AT\r\n command resp = self._response(char_specifier, data) - for notify_data in [resp[i : i + 30] for i in range(0, len(resp), 30)]: + for notify_data in [resp[i : i + 29] for i in range(0, len(resp), 29)]: self._notify_callback("MockJikongBleakClient", notify_data) class JKservice(BleakGATTService): From dfc5892db8d6afeef27310b4719affd39332565a Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 19:59:33 +0200 Subject: [PATCH 16/28] fixed runtime calculation --- custom_components/bms_ble/plugins/basebms.py | 4 ++-- tests/conftest.py | 10 ++++++---- tests/test_basebms.py | 18 ++++++++++++++++-- tests/test_daly_bms.py | 3 +-- 4 files changed, 25 insertions(+), 10 deletions(-) diff --git a/custom_components/bms_ble/plugins/basebms.py b/custom_components/bms_ble/plugins/basebms.py index 0bf66d1..2a2ada5 100644 --- a/custom_components/bms_ble/plugins/basebms.py +++ b/custom_components/bms_ble/plugins/basebms.py @@ -87,9 +87,9 @@ def can_calc(value: str, using: frozenset[str]) -> bool: # calculate runtime from current and cycle charge if can_calc(ATTR_RUNTIME, frozenset({ATTR_CURRENT, ATTR_CYCLE_CHRG})): - if data[ATTR_CURRENT] > 0: + if data[ATTR_CURRENT] < 0: data[ATTR_RUNTIME] = int( - data[ATTR_CYCLE_CHRG] / data[ATTR_CURRENT] * _HRS_TO_SECS + data[ATTR_CYCLE_CHRG] / abs(data[ATTR_CURRENT]) * _HRS_TO_SECS ) async def disconnect(self) -> None: diff --git a/tests/conftest.py b/tests/conftest.py index ebcc385..b088336 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,6 +17,8 @@ ATTR_CYCLE_CHRG, ATTR_CYCLES, ATTR_VOLTAGE, + ATTR_BATTERY_CHARGING, + ATTR_RUNTIME, BMS_TYPES, DOMAIN, ) @@ -121,14 +123,14 @@ def mock_config_v0_1(request, unique_id="cc:cc:cc:cc:cc:cc"): ) -@pytest.fixture -def bms_data_fixture(): +@pytest.fixture(params=[-13, 0, 21]) +def bms_data_fixture(request): """Return a fake BMS data dictionary.""" return { ATTR_VOLTAGE: 7.0, - ATTR_CURRENT: 13.0, - ATTR_CYCLE_CHRG: 21, + ATTR_CURRENT: request.param, + ATTR_CYCLE_CHRG: 34, } diff --git a/tests/test_basebms.py b/tests/test_basebms.py index 4396248..5327188 100644 --- a/tests/test_basebms.py +++ b/tests/test_basebms.py @@ -5,15 +5,29 @@ ATTR_CYCLE_CAP, ATTR_POWER, ATTR_RUNTIME, + ATTR_CURRENT, ) from custom_components.bms_ble.plugins.basebms import BaseBMS def test_calc_missing_values(bms_data_fixture) -> None: """Check if missing data is correctly calculated.""" - bms_data = reference = bms_data_fixture + bms_data = ref = bms_data_fixture BaseBMS.calc_values( bms_data, {ATTR_BATTERY_CHARGING, ATTR_CYCLE_CAP, ATTR_POWER, ATTR_RUNTIME, "invalid"}, ) - assert bms_data == reference | {ATTR_BATTERY_CHARGING: True, ATTR_CYCLE_CAP: 147, ATTR_POWER: 91, ATTR_RUNTIME: 5815} + ref = ref | { + ATTR_CYCLE_CAP: 238, + ATTR_POWER: ( + -91 + if bms_data[ATTR_CURRENT] < 0 + else 0 if bms_data[ATTR_CURRENT] == 0 else 147 + ), + ATTR_BATTERY_CHARGING: bms_data[ATTR_CURRENT] + > 0, # battery is charging if current is positive + } + if bms_data[ATTR_CURRENT] < 0: + ref |= {ATTR_RUNTIME: 9415} + + assert bms_data == ref diff --git a/tests/test_daly_bms.py b/tests/test_daly_bms.py index 0832359..78b14f5 100644 --- a/tests/test_daly_bms.py +++ b/tests/test_daly_bms.py @@ -27,7 +27,7 @@ def _response( return bytearray( b"\xd2\x03|\x10\x1f\x10)\x103\x10=\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00<\x00=\x00>\x00?\x00\x00\x00\x00\x00\x00\x00\x00\x00\x8cuN\x03\x84\x10=\x10\x1f\x00\x00\x00\x00\x00\x00\r\x80\x00\x04\x00\x04\x009\x00\x01\x00\x00\x00\x01\x10.\x0f\xa0\x00*\x00\x00\x00\x00\x00\x00\x00\x00\x1a7" ) - # {'voltage': 14.0, 'current': 3.0, 'battery_level': 90.0, 'cycles': 57, 'cycle_charge': 345.6, 'numTemp': 4, 'temperature': 21.5, 'cycle_capacity': 4838.400000000001, 'power': 42.0, 'battery_charging': True, 'runtime': 414720, 'rssi': -78} + # {'voltage': 14.0, 'current': 3.0, 'battery_level': 90.0, 'cycles': 57, 'cycle_charge': 345.6, 'numTemp': 4, 'temperature': 21.5, 'cycle_capacity': 4838.400000000001, 'power': 42.0, 'battery_charging': True, 'runtime': none!, 'rssi': -78} return bytearray() @@ -87,7 +87,6 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cycle_capacity": 4838.400000000001, "power": 42.0, "battery_charging": True, - "runtime": 414720, } # query again to check already connected state From 4e472951942b36f73b6077ceabe089c6c1e0b115 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:16:19 +0200 Subject: [PATCH 17/28] renamed private functions --- .../bms_ble/plugins/jikong_bms.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 5bd1bf2..9190c0f 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -104,7 +104,7 @@ def device_info() -> dict[str, str]: async def _wait_event(self) -> None: await self._data_event.wait() - self._data_event.clear() # TODO: clear here or only on connect? + self._data_event.clear() # TODO: clear here or only on connect? def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" @@ -115,7 +115,7 @@ def _on_disconnect(self, client: BleakClient) -> None: def _notification_handler(self, sender, data: bytearray) -> None: if self._data_event.is_set(): return - + if data[0 : len(self.BT_MODULE_MSG)] == self.BT_MODULE_MSG: if len(data) == len(self.BT_MODULE_MSG): LOGGER.debug("(%s) filtering AT cmd.", self._ble_device.name) @@ -142,13 +142,13 @@ def _notification_handler(self, sender, data: bytearray) -> None: ): return - crc = self.crc(self._data[0 : self.INFO_LEN - 1]) + crc = self._crc(self._data[0 : self.INFO_LEN - 1]) if self._data[self.INFO_LEN - 1] != crc: LOGGER.debug( "(%s) Rx data CRC is invalid: %i != %i", self._ble_device.name, self._data[self.INFO_LEN - 1], - self.crc(self._data[0 : self.INFO_LEN - 1]), + self._crc(self._data[0 : self.INFO_LEN - 1]), ) self._data_final = None # reset invalid data else: @@ -214,7 +214,7 @@ async def _connect(self) -> None: # query device info await self._client.write_gatt_char( - self._char_write_handle or 0, data=self.cmd(b"\x97") + self._char_write_handle or 0, data=self._cmd(b"\x97") ) self._connected = True @@ -234,11 +234,11 @@ async def disconnect(self) -> None: self._client = None - def crc(self, frame: bytes): + def _crc(self, frame: bytes): """Calculate Jikong frame CRC.""" return sum(frame) & 0xFF - def cmd(self, cmd: bytes, value: list[int] | None = None) -> bytes: + def _cmd(self, cmd: bytes, value: list[int] | None = None) -> bytes: """Assemble a Jikong BMS command.""" if value is None: value = [] @@ -246,7 +246,7 @@ def cmd(self, cmd: bytes, value: list[int] | None = None) -> bytes: frame = bytes([*self.HEAD_CMD, cmd[0]]) frame += bytes([len(value), *value]) frame += bytes([0] * (13 - len(value))) - frame += bytes([self.crc(frame)]) + frame += bytes([self._crc(frame)]) return frame async def async_update(self) -> dict[str, int | float | bool]: @@ -261,7 +261,7 @@ async def async_update(self) -> dict[str, int | float | bool]: # query cell info await self._client.write_gatt_char( - self._char_write_handle or 0, data=self.cmd(b"\x96") + self._char_write_handle or 0, data=self._cmd(b"\x96") ) await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) From 063ac88c99e302ed1a1a22899ebaee5d04843154 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:16:33 +0200 Subject: [PATCH 18/28] removed AT OK cmd --- custom_components/bms_ble/plugins/jikong_bms.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 9190c0f..26a9260 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -207,11 +207,6 @@ async def _connect(self) -> None: char_notify_handle or 0, self._notification_handler ) - # send ok in response to AT command - await self._client.write_gatt_char( - self._char_write_handle or 0, data=self.cmd(b"OK\r\n") - ) - # query device info await self._client.write_gatt_char( self._char_write_handle or 0, data=self._cmd(b"\x97") From 0279bba547bde990acee0e02871bc79f8bcb41fd Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:16:43 +0200 Subject: [PATCH 19/28] fixed tests --- tests/test_jikong_bms.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index 3a2113d..28c16ec 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -265,6 +265,7 @@ async def test_update(monkeypatch, reconnect_fixture) -> None: "cycle_capacity": 6141.41255, "power": -553.4192300000001, "battery_charging": False, + "runtime": 39949, } # query again to check already connected state @@ -313,6 +314,7 @@ async def test_oversized_response(monkeypatch) -> None: "cycle_capacity": 6141.41255, "power": -553.4192300000001, "battery_charging": False, + "runtime": 39949, } await bms.disconnect() From a850bbef12933545420cca406a74794c843bf242 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 20:59:53 +0200 Subject: [PATCH 20/28] start/stop notify --- custom_components/bms_ble/coordinator.py | 2 +- custom_components/bms_ble/manifest.json | 2 +- .../bms_ble/plugins/jikong_bms.py | 43 +++++++++++++------ tests/conftest.py | 8 ++++ tests/test_jikong_bms.py | 4 +- 5 files changed, 42 insertions(+), 17 deletions(-) diff --git a/custom_components/bms_ble/coordinator.py b/custom_components/bms_ble/coordinator.py index 14e674c..09ace00 100644 --- a/custom_components/bms_ble/coordinator.py +++ b/custom_components/bms_ble/coordinator.py @@ -34,7 +34,7 @@ def __init__( update_interval=timedelta(seconds=UPDATE_INTERVAL), always_update=False, # only update when sensor value has changed ) - + self._mac = ble_device.address LOGGER.debug( "Initializing coordinator for %s (%s) as %s", diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index c0f6c64..c6ddd57 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -48,5 +48,5 @@ "issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues", "loggers": ["bms_ble", "ogt_bms", "daly_bms", "jikong_bms"], "requirements": [], - "version": "1.3.0b1" + "version": "1.3.0b2" } diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 26a9260..382dfef 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -52,6 +52,7 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: self._data_event = asyncio.Event() self._connected = False # flag to indicate active BLE connection self._char_write_handle: int | None = None + self._char_notify_handle: int | None = None self._FIELDS: list[tuple[str, int, int, bool, Callable[[int], int | float]]] = [ (ATTR_TEMPERATURE, 144, 2, True, lambda x: float(x / 10)), (ATTR_VOLTAGE, 150, 4, False, lambda x: float(x / 1000)), @@ -168,8 +169,7 @@ async def _connect(self) -> None: services=[UUID_SERVICE], ) await self._client.connect() - char_notify_handle: int | None = None - self._char_write_handle = None + for service in self._client.services: for char in service.characteristics: value: bytearray = bytearray() @@ -185,27 +185,27 @@ async def _connect(self) -> None: ) if char.uuid == UUID_CHAR: if "notify" in char.properties: - char_notify_handle = char.handle + self._char_notify_handle = char.handle if ( "write" in char.properties or "write-without-response" in char.properties ): self._char_write_handle = char.handle - if char_notify_handle is None or self._char_write_handle is None: + if self._char_notify_handle is None or self._char_write_handle is None: LOGGER.debug( "(%s) Failed to detect characteristics", self._ble_device.name ) await self._client.disconnect() return + LOGGER.debug( "(%s) Using characteristics handle #%i (notify), #%i (write)", self._ble_device.name, - char_notify_handle, + self._char_notify_handle, self._char_write_handle, ) - await self._client.start_notify( - char_notify_handle or 0, self._notification_handler - ) + + await self._start_notify() # query device info await self._client.write_gatt_char( @@ -216,6 +216,24 @@ async def _connect(self) -> None: else: LOGGER.debug("BMS %s already connected", self._ble_device.name) + async def _start_notify(self) -> None: + """Start notification from BMS characteristic""" + assert self._client + + await self._client.start_notify( + self._char_notify_handle or 0, self._notification_handler + ) + + # request cell info update + await self._client.write_gatt_char( + self._char_write_handle or 0, data=self._cmd(b"\x96") + ) + + async def _stop_notify(self) -> None: + """Stop notification from BMS characteristic""" + assert self._client + await self._client.stop_notify(self._char_notify_handle or 0) + async def disconnect(self) -> None: """Disconnect the BMS and includes stoping notifications.""" @@ -228,6 +246,8 @@ async def disconnect(self) -> None: LOGGER.warning("Disconnect failed!") self._client = None + self._char_notify_handle = None + self._char_write_handle = None def _crc(self, frame: bytes): """Calculate Jikong frame CRC.""" @@ -254,12 +274,9 @@ async def async_update(self) -> dict[str, int | float | bool]: ) return {} - # query cell info - await self._client.write_gatt_char( - self._char_write_handle or 0, data=self._cmd(b"\x96") - ) - + await self._start_notify() await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) + await self._stop_notify() if self._data_final is None: return {} diff --git a/tests/conftest.py b/tests/conftest.py index b088336..a8c7f0a 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -247,6 +247,14 @@ async def start_notify( # type: ignore assert self._connected, "start_notify called, but client not connected." self._notify_callback = callback + async def stop_notify( # type: ignore + self, char_specifier: Union[BleakGATTCharacteristic, int, str] + ) -> None: + """Mock stop_notify.""" + LOGGER.debug("MockBleakClient stop_notify for %s", char_specifier) + assert self._connected, "stop_notify called, but client not connected." + self._notify_callback = None + async def write_gatt_char( # type: ignore self, char_specifier: Union[BleakGATTCharacteristic, int, str], diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index 28c16ec..5cfd17d 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -74,8 +74,8 @@ async def write_gatt_char( response: bool = None, # type: ignore[implicit-optional] # same as upstream ) -> None: """Issue write command to GATT.""" - # await super().write_gatt_char(char_specifier, data, response) - assert self._notify_callback is not None + + assert self._notify_callback, "write to characteristics but notification not enabled" self._notify_callback( "MockJikongBleakClient", bytearray(b"\x41\x54\x0d\x0a") ) # interleaved AT\r\n command From 7debe0781af819ca15d9a6e87858380f7d001f57 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Wed, 12 Jun 2024 21:06:06 +0200 Subject: [PATCH 21/28] removed unnecessary imports --- tests/conftest.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index a8c7f0a..a58915d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -17,8 +17,6 @@ ATTR_CYCLE_CHRG, ATTR_CYCLES, ATTR_VOLTAGE, - ATTR_BATTERY_CHARGING, - ATTR_RUNTIME, BMS_TYPES, DOMAIN, ) From 0bd2299e682ee72068214651f46fb9afac98cd74 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Thu, 13 Jun 2024 09:52:50 +0200 Subject: [PATCH 22/28] code cleanup --- .../bms_ble/plugins/jikong_bms.py | 11 +++--- tests/conftest.py | 24 ++++++------- tests/test_jikong_bms.py | 36 +++++++++++-------- 3 files changed, 40 insertions(+), 31 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 382dfef..963ee35 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -105,7 +105,7 @@ def device_info() -> dict[str, str]: async def _wait_event(self) -> None: await self._data_event.wait() - self._data_event.clear() # TODO: clear here or only on connect? + self._data_event.clear() def _on_disconnect(self, client: BleakClient) -> None: """Disconnect callback function.""" @@ -119,7 +119,7 @@ def _notification_handler(self, sender, data: bytearray) -> None: if data[0 : len(self.BT_MODULE_MSG)] == self.BT_MODULE_MSG: if len(data) == len(self.BT_MODULE_MSG): - LOGGER.debug("(%s) filtering AT cmd.", self._ble_device.name) + LOGGER.debug("(%s) filtering AT cmd", self._ble_device.name) return data = data[len(self.BT_MODULE_MSG) :] @@ -217,9 +217,9 @@ async def _connect(self) -> None: LOGGER.debug("BMS %s already connected", self._ble_device.name) async def _start_notify(self) -> None: - """Start notification from BMS characteristic""" + """Start notification from BMS characteristic.""" + assert self._client - await self._client.start_notify( self._char_notify_handle or 0, self._notification_handler ) @@ -230,7 +230,8 @@ async def _start_notify(self) -> None: ) async def _stop_notify(self) -> None: - """Stop notification from BMS characteristic""" + """Stop notification from BMS characteristic.""" + assert self._client await self._client.stop_notify(self._char_notify_handle or 0) diff --git a/tests/conftest.py b/tests/conftest.py index a58915d..2cc4712 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -42,7 +42,7 @@ def mock_bluetooth(enable_bluetooth): """Auto mock bluetooth.""" -@pytest.fixture(params=BMS_TYPES + ["dummy_bms"]) +@pytest.fixture(params=[*BMS_TYPES, "dummy_bms"]) def bms_fixture(request): """Return all possible BMS variants.""" return request.param @@ -138,7 +138,7 @@ def mock_coordinator_exception(request): return request.param -@pytest.fixture(params=BMS_TYPES + ["dummy_bms"]) +@pytest.fixture(params=[*BMS_TYPES, "dummy_bms"]) def plugin_fixture(request) -> BaseBMS: """Return instance of a BMS.""" return importlib.import_module( @@ -237,7 +237,7 @@ async def connect(self, *args, **kwargs): async def start_notify( # type: ignore self, - char_specifier: Union[BleakGATTCharacteristic, int, str], + char_specifier: BleakGATTCharacteristic | int | str, callback: Callable, ): """Mock start_notify.""" @@ -255,7 +255,7 @@ async def stop_notify( # type: ignore async def write_gatt_char( # type: ignore self, - char_specifier: Union[BleakGATTCharacteristic, int, str], + char_specifier: BleakGATTCharacteristic | int | str, data: Buffer, response: bool = None, # type: ignore # same as upstream ) -> None: @@ -282,17 +282,17 @@ class MockRespChar(BleakGATTCharacteristic): @property def service_uuid(self) -> str: """The UUID of the Service containing this characteristic.""" - raise NotImplementedError() + raise NotImplementedError @property def service_handle(self) -> int: """The integer handle of the Service containing this characteristic.""" - raise NotImplementedError() + raise NotImplementedError @property def handle(self) -> int: """The handle for this characteristic.""" - raise NotImplementedError() + raise NotImplementedError @property def uuid(self) -> str: @@ -307,18 +307,18 @@ def description(self) -> str: @property def properties(self) -> list[str]: """Properties of this characteristic.""" - raise NotImplementedError() + raise NotImplementedError @property def descriptors(self) -> list[BleakGATTDescriptor]: """List of descriptors for this service.""" - raise NotImplementedError() + raise NotImplementedError def get_descriptor( - self, specifier: Union[int, str, UUID] - ) -> Union[BleakGATTDescriptor, None]: + self, specifier: int | str | UUID + ) -> BleakGATTDescriptor | None: """Get a descriptor by handle (int) or UUID (str or uuid.UUID).""" - raise NotImplementedError() + raise NotImplementedError def add_descriptor(self, descriptor: BleakGATTDescriptor): """Add a :py:class:`~BleakGATTDescriptor` to the characteristic. diff --git a/tests/test_jikong_bms.py b/tests/test_jikong_bms.py index 5cfd17d..2034183 100644 --- a/tests/test_jikong_bms.py +++ b/tests/test_jikong_bms.py @@ -1,13 +1,14 @@ """Test the Jikong BMS implementation.""" +from uuid import UUID + from bleak.backends.characteristic import BleakGATTCharacteristic from bleak.backends.descriptor import BleakGATTDescriptor -from bleak.backends.service import BleakGATTServiceCollection, BleakGATTService +from bleak.backends.service import BleakGATTService, BleakGATTServiceCollection from bleak.exc import BleakError from bleak.uuids import normalize_uuid_str, uuidstr_to_str from custom_components.bms_ble.plugins.jikong_bms import BMS from typing_extensions import Buffer -from uuid import UUID from .bluetooth import generate_ble_device from .conftest import MockBleakClient @@ -74,8 +75,10 @@ async def write_gatt_char( response: bool = None, # type: ignore[implicit-optional] # same as upstream ) -> None: """Issue write command to GATT.""" - - assert self._notify_callback, "write to characteristics but notification not enabled" + + assert ( + self._notify_callback + ), "write to characteristics but notification not enabled" self._notify_callback( "MockJikongBleakClient", bytearray(b"\x41\x54\x0d\x0a") ) # interleaved AT\r\n command @@ -84,6 +87,7 @@ async def write_gatt_char( self._notify_callback("MockJikongBleakClient", notify_data) class JKservice(BleakGATTService): + """Mock the main battery info service from JiKong BMS.""" class CharBase(BleakGATTCharacteristic): """Basic characteristic for common properties.""" @@ -144,23 +148,27 @@ def properties(self) -> list[str]: @property def handle(self) -> int: - """The handle of this service""" + """The handle of this service.""" + return 2 @property def uuid(self) -> str: - """The UUID to this service""" + """The UUID to this service.""" + return normalize_uuid_str("ffe0") @property def description(self) -> str: - """String description for this service""" + """String description for this service.""" + return uuidstr_to_str(self.uuid) @property def characteristics(self) -> list[BleakGATTCharacteristic]: - """List of characteristics for this service""" - return list([self.CharNotify(None, 350), self.CharWrite(None, 350)]) + """List of characteristics for this service.""" + + return [self.CharNotify(None, 350), self.CharWrite(None, 350)] def add_characteristic(self, characteristic: BleakGATTCharacteristic) -> None: """Add a :py:class:`~BleakGATTCharacteristic` to the service. @@ -180,13 +188,13 @@ def services(self) -> BleakGATTServiceCollection: class MockWrongBleakClient(MockBleakClient): + """Mock invalid service for JiKong BMS.""" + @property def services(self) -> BleakGATTServiceCollection: """Emulate JiKong BT service setup.""" - ServCol = BleakGATTServiceCollection() - - return ServCol + return BleakGATTServiceCollection() class MockInvalidBleakClient(MockJikongBleakClient): @@ -196,7 +204,7 @@ def _response( self, char_specifier: BleakGATTCharacteristic | int | str, data: Buffer ) -> bytearray: if char_specifier == 3: - return bytearray(b"\x55\xAA\xEB\x90\x02") + bytearray(295) + return bytearray(b"\x55\xaa\xeb\x90\x02") + bytearray(295) return bytearray() @@ -314,7 +322,7 @@ async def test_oversized_response(monkeypatch) -> None: "cycle_capacity": 6141.41255, "power": -553.4192300000001, "battery_charging": False, - "runtime": 39949, + "runtime": 39949, } await bms.disconnect() From 31eb88ed855fb5a8b1c5969c9109336274d193f8 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Thu, 13 Jun 2024 10:01:10 +0200 Subject: [PATCH 23/28] disabled read char --- custom_components/bms_ble/plugins/jikong_bms.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 963ee35..92387d2 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -172,16 +172,16 @@ async def _connect(self) -> None: for service in self._client.services: for char in service.characteristics: - value: bytearray = bytearray() - if "read" in char.properties: # TODO: debugging only! - value = await self._client.read_gatt_char(char) + # value: bytearray = bytearray() + # if "read" in char.properties: + # value = await self._client.read_gatt_char(char) LOGGER.debug( "(%s) Discovered %s (#%i): %s%s", self._ble_device.name, char.uuid, char.handle, char.properties, - f" value: {value}" if value else "", + # f" value: {value}" if value else "", ) if char.uuid == UUID_CHAR: if "notify" in char.properties: From f8b8545eca8badc02ffa631a43ad7ea5e07ab01d Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Thu, 13 Jun 2024 21:45:13 +0200 Subject: [PATCH 24/28] revert start/stop notify --- custom_components/bms_ble/manifest.json | 2 +- .../bms_ble/plugins/jikong_bms.py | 44 ++++++------------- tests/conftest.py | 8 ---- 3 files changed, 14 insertions(+), 40 deletions(-) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index c6ddd57..4d6fada 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -48,5 +48,5 @@ "issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues", "loggers": ["bms_ble", "ogt_bms", "daly_bms", "jikong_bms"], "requirements": [], - "version": "1.3.0b2" + "version": "1.3.0b3" } diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index 92387d2..d9154fd 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -52,7 +52,6 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: self._data_event = asyncio.Event() self._connected = False # flag to indicate active BLE connection self._char_write_handle: int | None = None - self._char_notify_handle: int | None = None self._FIELDS: list[tuple[str, int, int, bool, Callable[[int], int | float]]] = [ (ATTR_TEMPERATURE, 144, 2, True, lambda x: float(x / 10)), (ATTR_VOLTAGE, 150, 4, False, lambda x: float(x / 1000)), @@ -169,7 +168,8 @@ async def _connect(self) -> None: services=[UUID_SERVICE], ) await self._client.connect() - + char_notify_handle: int | None = None + self._char_write_handle = None for service in self._client.services: for char in service.characteristics: # value: bytearray = bytearray() @@ -185,27 +185,27 @@ async def _connect(self) -> None: ) if char.uuid == UUID_CHAR: if "notify" in char.properties: - self._char_notify_handle = char.handle + char_notify_handle = char.handle if ( "write" in char.properties or "write-without-response" in char.properties ): self._char_write_handle = char.handle - if self._char_notify_handle is None or self._char_write_handle is None: + if char_notify_handle is None or self._char_write_handle is None: LOGGER.debug( "(%s) Failed to detect characteristics", self._ble_device.name ) await self._client.disconnect() return - LOGGER.debug( "(%s) Using characteristics handle #%i (notify), #%i (write)", self._ble_device.name, - self._char_notify_handle, + char_notify_handle, self._char_write_handle, ) - - await self._start_notify() + await self._client.start_notify( + char_notify_handle or 0, self._notification_handler + ) # query device info await self._client.write_gatt_char( @@ -216,25 +216,6 @@ async def _connect(self) -> None: else: LOGGER.debug("BMS %s already connected", self._ble_device.name) - async def _start_notify(self) -> None: - """Start notification from BMS characteristic.""" - - assert self._client - await self._client.start_notify( - self._char_notify_handle or 0, self._notification_handler - ) - - # request cell info update - await self._client.write_gatt_char( - self._char_write_handle or 0, data=self._cmd(b"\x96") - ) - - async def _stop_notify(self) -> None: - """Stop notification from BMS characteristic.""" - - assert self._client - await self._client.stop_notify(self._char_notify_handle or 0) - async def disconnect(self) -> None: """Disconnect the BMS and includes stoping notifications.""" @@ -247,8 +228,6 @@ async def disconnect(self) -> None: LOGGER.warning("Disconnect failed!") self._client = None - self._char_notify_handle = None - self._char_write_handle = None def _crc(self, frame: bytes): """Calculate Jikong frame CRC.""" @@ -275,9 +254,12 @@ async def async_update(self) -> dict[str, int | float | bool]: ) return {} - await self._start_notify() + # query cell info + await self._client.write_gatt_char( + self._char_write_handle or 0, data=self._cmd(b"\x96") + ) + await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT) - await self._stop_notify() if self._data_final is None: return {} diff --git a/tests/conftest.py b/tests/conftest.py index 2cc4712..0e5b4c1 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -245,14 +245,6 @@ async def start_notify( # type: ignore assert self._connected, "start_notify called, but client not connected." self._notify_callback = callback - async def stop_notify( # type: ignore - self, char_specifier: Union[BleakGATTCharacteristic, int, str] - ) -> None: - """Mock stop_notify.""" - LOGGER.debug("MockBleakClient stop_notify for %s", char_specifier) - assert self._connected, "stop_notify called, but client not connected." - self._notify_callback = None - async def write_gatt_char( # type: ignore self, char_specifier: BleakGATTCharacteristic | int | str, From bd8d51aecd9cb0d0d12e82ac13ed0b5b64c6ad0f Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 15 Jun 2024 17:59:54 +0200 Subject: [PATCH 25/28] removed import --- tests/conftest.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/conftest.py b/tests/conftest.py index 0e5b4c1..c0cf3d4 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -3,7 +3,7 @@ from collections.abc import Callable, Iterable import importlib import logging -from typing import Any, Union +from typing import Any from uuid import UUID from bleak import BleakClient From 11d1742fea459149c6653eeda7cf6aceb7bfae7c Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 15 Jun 2024 18:14:51 +0200 Subject: [PATCH 26/28] removed unnecessary matcher entries --- custom_components/bms_ble/manifest.json | 22 +------------ .../bms_ble/plugins/jikong_bms.py | 31 +------------------ 2 files changed, 2 insertions(+), 51 deletions(-) diff --git a/custom_components/bms_ble/manifest.json b/custom_components/bms_ble/manifest.json index 4d6fada..2e43b80 100644 --- a/custom_components/bms_ble/manifest.json +++ b/custom_components/bms_ble/manifest.json @@ -14,29 +14,9 @@ "local_name": "DL-*", "service_uuid": "0000fff0-0000-1000-8000-00805f9b34fb" }, - { - "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", - "manufacturer_id": 240 - }, { "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", "manufacturer_id": 2917 - }, - { - "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", - "manufacturer_id": 60097 - }, - { - "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", - "manufacturer_id": 11531 - }, - { - "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", - "manufacturer_id": 22596 - }, - { - "service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb", - "manufacturer_id": 19274 } ], "codeowners": ["@patman15"], @@ -48,5 +28,5 @@ "issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues", "loggers": ["bms_ble", "ogt_bms", "daly_bms", "jikong_bms"], "requirements": [], - "version": "1.3.0b3" + "version": "1.3.0" } diff --git a/custom_components/bms_ble/plugins/jikong_bms.py b/custom_components/bms_ble/plugins/jikong_bms.py index d9154fd..12e5e8a 100644 --- a/custom_components/bms_ble/plugins/jikong_bms.py +++ b/custom_components/bms_ble/plugins/jikong_bms.py @@ -65,36 +65,11 @@ def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None: def matcher_dict_list() -> list[dict[str, Any]]: """Provide BluetoothMatcher definition.""" return [ - { - "service_uuid": UUID_SERVICE, - "connectable": True, - "manufacturer_id": 0x00F0, - }, { "service_uuid": UUID_SERVICE, "connectable": True, "manufacturer_id": 0x0B65, }, - { - "service_uuid": UUID_SERVICE, - "connectable": True, - "manufacturer_id": 0xEAC1, - }, - { - "service_uuid": UUID_SERVICE, - "connectable": True, - "manufacturer_id": 0x2D0B, - }, - { - "service_uuid": UUID_SERVICE, - "connectable": True, - "manufacturer_id": 0x5844, - }, - { - "service_uuid": UUID_SERVICE, - "connectable": True, - "manufacturer_id": 0x4B4A, - }, ] @staticmethod @@ -172,16 +147,12 @@ async def _connect(self) -> None: self._char_write_handle = None for service in self._client.services: for char in service.characteristics: - # value: bytearray = bytearray() - # if "read" in char.properties: - # value = await self._client.read_gatt_char(char) LOGGER.debug( - "(%s) Discovered %s (#%i): %s%s", + "(%s) Discovered %s (#%i): %s", self._ble_device.name, char.uuid, char.handle, char.properties, - # f" value: {value}" if value else "", ) if char.uuid == UUID_CHAR: if "notify" in char.properties: From 12887e33d314dd1d123952c2045e416e6cfdf3f7 Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 15 Jun 2024 18:14:58 +0200 Subject: [PATCH 27/28] code cleanup --- tests/conftest.py | 2 +- tests/test_basebms.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/conftest.py b/tests/conftest.py index c0cf3d4..3355b9d 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -317,4 +317,4 @@ def add_descriptor(self, descriptor: BleakGATTDescriptor): Should not be used by end user, but rather by `bleak` itself. """ - raise NotImplementedError() + raise NotImplementedError diff --git a/tests/test_basebms.py b/tests/test_basebms.py index 5327188..61d9ebb 100644 --- a/tests/test_basebms.py +++ b/tests/test_basebms.py @@ -2,10 +2,10 @@ from custom_components.bms_ble.const import ( ATTR_BATTERY_CHARGING, + ATTR_CURRENT, ATTR_CYCLE_CAP, ATTR_POWER, ATTR_RUNTIME, - ATTR_CURRENT, ) from custom_components.bms_ble.plugins.basebms import BaseBMS From 2fb7b8f6cf2b729f9abf50971db625cfc2fb6abc Mon Sep 17 00:00:00 2001 From: patman15 <14628713+patman15@users.noreply.github.com> Date: Sat, 15 Jun 2024 18:19:32 +0200 Subject: [PATCH 28/28] removed untested warning --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 9e740c9..4c3f795 100644 --- a/README.md +++ b/README.md @@ -22,7 +22,7 @@ Platform | Description | Unit ## Supported Devices - Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-Axxxxx` or `SmartBat-Bxxxxx`) - Daly BMS (show up as `DL-xxxxxxxxxxxx`) -- JiKong BMS (HW version >=11 required)
:warning: untested, please [open an issue](https://github.com/patman15/BMS_BLE-HA/issues), if you have logs for working/non-working devices +- JiKong BMS (HW version >=11 required) New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details.