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/).
[](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.