Skip to content

Commit 8634d2d

Browse files
authored
add Electronicx battery support (#108)
* add Electronix battery detection * fixed cycle readout * added test for single frame protocol * add filter for BT module messages * update thanks
1 parent 188ae11 commit 8634d2d

File tree

4 files changed

+139
-35
lines changed

4 files changed

+139
-35
lines changed

README.md

+6-4
Original file line numberDiff line numberDiff line change
@@ -20,11 +20,13 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management
2020
- CBT Power BMS, Creabest batteries
2121
- D-powercore BMS (show up as `DXB-`…), Fliteboard batteries (show up as `TBA-`…)
2222
- Daly BMS (show up as `DL-`…)
23-
- E&J Technology BMS, Supervolt v1 batteries
23+
- E&J Technology BMS
24+
- Supervolt v1 batteries
25+
- Elektronicx batteries (show up as `LT-`…)
2426
- Ective batteries
2527
- JBD BMS, Jiabaida
26-
- accurat batteries
27-
- Supervolt v3 batteries
28+
- accurat batteries
29+
- Supervolt v3 batteries
2830
- JK BMS, Jikong, (HW version >=11 required)
2931
- Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…)
3032
- LiTime, Redodo batteries
@@ -151,7 +153,7 @@ In case you have severe troubles,
151153
- Add further battery types on [request](https://github.com/patman15/BMS_BLE-HA/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml)
152154

153155
## Thanks to
154-
> [@gkathan](https://github.com/patman15/BMS_BLE-HA/issues/2), [@downset](https://github.com/patman15/BMS_BLE-HA/issues/19), [@gerritb](https://github.com/patman15/BMS_BLE-HA/issues/22), [@Goaheadz](https://github.com/patman15/BMS_BLE-HA/issues/24), [@alros100, @majonessyltetoy](https://github.com/patman15/BMS_BLE-HA/issues/52), [@snipah, @Gruni22](https://github.com/patman15/BMS_BLE-HA/issues/59), [@azisto](https://github.com/patman15/BMS_BLE-HA/issues/78), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57), [@SkeLLLa,@romanshypovskyi](https://github.com/patman15/BMS_BLE-HA/issues/90)
156+
> [@gkathan](https://github.com/patman15/BMS_BLE-HA/issues/2), [@downset](https://github.com/patman15/BMS_BLE-HA/issues/19), [@gerritb](https://github.com/patman15/BMS_BLE-HA/issues/22), [@Goaheadz](https://github.com/patman15/BMS_BLE-HA/issues/24), [@alros100, @majonessyltetoy](https://github.com/patman15/BMS_BLE-HA/issues/52), [@snipah, @Gruni22](https://github.com/patman15/BMS_BLE-HA/issues/59), [@azisto](https://github.com/patman15/BMS_BLE-HA/issues/78), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57), [@SkeLLLa,@romanshypovskyi](https://github.com/patman15/BMS_BLE-HA/issues/90), [@hacsler](https://github.com/patman15/BMS_BLE-HA/issues/103)
155157

156158
for helping with making the integration better.
157159

custom_components/bms_ble/manifest.json

+4
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,10 @@
4646
"local_name": "libatt*",
4747
"manufacturer_id": 21320
4848
},
49+
{
50+
"local_name": "LT-*",
51+
"manufacturer_id": 33384
52+
},
4953
{
5054
"local_name": "$PFLAC*",
5155
"service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb",

custom_components/bms_ble/plugins/ej_bms.py

+60-27
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
"""Module to support Dummy BMS."""
22

33
import asyncio
4-
from enum import Enum
4+
from enum import IntEnum
55
import logging
66
from typing import Any, Callable, Final
77

@@ -28,7 +28,7 @@
2828
BAT_TIMEOUT = 10
2929

3030

31-
class Cmd(Enum):
31+
class Cmd(IntEnum):
3232
"""BMS operation codes."""
3333

3434
RT = 0x2
@@ -38,30 +38,31 @@ class Cmd(Enum):
3838
class BMS(BaseBMS):
3939
"""Dummy battery class implementation."""
4040

41+
_BT_MODULE_MSG: Final[bytes] = bytes([0x41, 0x54, 0x0D, 0x0A]) # AT\r\n from BLE module
42+
_HEAD: Final[int] = 0x3A
43+
_TAIL: Final[int] = 0x7E
4144
_MAX_CELLS: Final[int] = 16
4245
_FIELDS: Final[list[tuple[str, Cmd, int, int, Callable[[int], int | float]]]] = [
4346
(ATTR_CURRENT, Cmd.RT, 89, 8, lambda x: float((x >> 16) - (x & 0xFFFF)) / 100),
4447
(ATTR_BATTERY_LEVEL, Cmd.RT, 123, 2, lambda x: x),
4548
(ATTR_CYCLE_CHRG, Cmd.CAP, 15, 4, lambda x: float(x) / 10),
4649
(ATTR_TEMPERATURE, Cmd.RT, 97, 2, lambda x: x - 40), # only 1st sensor relevant
47-
(ATTR_CYCLES, Cmd.RT, 119, 4, lambda x: x),
50+
(ATTR_CYCLES, Cmd.RT, 115, 4, lambda x: x),
4851
]
4952

5053
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
5154
"""Initialize BMS."""
5255
LOGGER.debug("%s init(), BT address: %s", self.device_id(), ble_device.address)
5356
super().__init__(LOGGER, self._notification_handler, ble_device, reconnect)
54-
self._data = bytearray()
57+
self._data: bytearray = bytearray()
58+
self._data_final: bytearray = bytearray()
5559

5660
@staticmethod
5761
def matcher_dict_list() -> list[dict[str, Any]]:
5862
"""Provide BluetoothMatcher definition."""
59-
return [
60-
{
61-
"local_name": "libatt*",
62-
"manufacturer_id": 21320,
63-
"connectable": True,
64-
}
63+
return [ # Fliteboard, Electronix battery
64+
{"local_name": "libatt*", "manufacturer_id": 21320, "connectable": True},
65+
{"local_name": "LT-*", "manufacturer_id": 33384, "connectable": True},
6566
]
6667

6768
@staticmethod
@@ -97,39 +98,65 @@ def _calc_values() -> set[str]:
9798

9899
def _notification_handler(self, _sender, data: bytearray) -> None:
99100
"""Handle the RX characteristics notify event (new data arrives)."""
100-
LOGGER.debug("%s: Received BLE data: %s", self.name, data)
101101

102-
if data[0] != 0x3A or data[-1] != 0x7E:
103-
LOGGER.debug("%s: Incorrect SOI or EOI: %s", self.name, data)
102+
if data.startswith(BMS._BT_MODULE_MSG):
103+
LOGGER.debug("%s: filtering AT cmd", self.name)
104+
if len(data) == len(BMS._BT_MODULE_MSG):
105+
return
106+
data = data[len(BMS._BT_MODULE_MSG) :]
107+
108+
if data[0] == BMS._HEAD: # check for beginning of frame
109+
self._data.clear()
110+
111+
self._data += data
112+
113+
LOGGER.debug(
114+
"%s: RX BLE data (%s): %s",
115+
self._ble_device.name,
116+
"start" if data == self._data else "cnt.",
117+
data,
118+
)
119+
120+
if self._data[0] != BMS._HEAD or (
121+
self._data[-1] != BMS._TAIL and len(self._data) < int(self._data[7:11], 16)
122+
):
123+
return
124+
125+
if self._data[-1] != BMS._TAIL:
126+
LOGGER.debug("%s: incorrect EOF: %s", self.name, data)
127+
self._data.clear()
104128
return
105129

106-
if len(data) != int(data[7:11], 16):
130+
if len(self._data) != int(self._data[7:11], 16):
107131
LOGGER.debug(
108-
"%s: Incorrect frame length %i != %i",
132+
"%s: incorrect frame length %i != %i",
109133
self.name,
110-
len(data),
111-
int(data[7:11], 16),
134+
len(self._data),
135+
int(self._data[7:11], 16),
112136
)
137+
self._data.clear()
113138
return
114139

115-
crc: Final = BMS._crc(data[1:-3])
116-
if crc != int(data[-3:-1], 16):
140+
crc: Final = BMS._crc(self._data[1:-3])
141+
if crc != int(self._data[-3:-1], 16):
117142
LOGGER.debug(
118143
"%s: incorrect checksum 0x%X != 0x%X",
119144
self.name,
120-
int(data[-3:-1], 16),
145+
int(self._data[-3:-1], 16),
121146
crc,
122147
)
148+
self._data.clear()
123149
return
124150

125151
LOGGER.debug(
126-
"%s: address: 0x%X, commnad 0x%X, version: 0x%X",
152+
"%s: address: 0x%X, commnad 0x%X, version: 0x%X, length: 0x%X",
127153
self.name,
128-
int(data[1:3], 16),
129-
int(data[3:5], 16) & 0x7F,
130-
int(data[5:7], 16),
154+
int(self._data[1:3], 16),
155+
int(self._data[3:5], 16) & 0x7F,
156+
int(self._data[5:7], 16),
157+
len(self._data)
131158
)
132-
self._data = data
159+
self._data_final = self._data.copy()
133160
self._data_event.set()
134161

135162
@staticmethod
@@ -154,9 +181,15 @@ async def _async_update(self) -> BMSsample:
154181
for cmd in [b":000250000E03~", b":001031000E05~"]:
155182
await self._client.write_gatt_char(BMS.uuid_tx(), data=cmd)
156183
await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT)
157-
raw_data[int(cmd[3:5], 16) & 0x7F] = self._data.copy()
184+
rsp: int = int(self._data_final[3:5], 16) & 0x7F
185+
raw_data[rsp] = self._data_final
186+
if rsp == Cmd.RT and len(self._data_final) == 0x8C: # handle metrisun version
187+
LOGGER.debug("%s: single frame protocol detected", self.name)
188+
raw_data[Cmd.CAP] = bytearray(15) + self._data_final[125:]
189+
break
190+
158191

159192
return {
160193
key: func(int(raw_data[cmd.value][idx : idx + size], 16))
161194
for key, cmd, idx, size, func in BMS._FIELDS
162-
} | self._cell_voltages(raw_data[Cmd.RT.value])
195+
} | self._cell_voltages(raw_data[Cmd.RT])

tests/test_ej_bms.py

+69-4
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@
1212
from .bluetooth import generate_ble_device
1313
from .conftest import MockBleakClient
1414

15+
BT_FRAME_SIZE = 20
16+
1517

1618
class MockEJBleakClient(MockBleakClient):
1719
"""Emulate a E&J technology BMS BleakClient."""
@@ -26,7 +28,8 @@ def _response(
2628
cmd: int = int(bytearray(data)[3:5], 16)
2729
if cmd == 0x02:
2830
return bytearray(
29-
b":0082310080000101C00000880F540F3C0F510FD70F310F2C0F340F3A0FED0FED0000000000000000000000000000000248424242F0000000000000000001AB~"
31+
b":0082310080000101C00000880F540F3C0F510FD70F310F2C0F340F3A0FED0FED0000000000000000"
32+
b"000000000000000248424242F0000000000000000001AB~"
3033
) # TODO: put numbers
3134
if cmd == 0x10:
3235
return bytearray(b":009031001E00000002000A000AD8~") # TODO: put numbers
@@ -41,9 +44,32 @@ async def write_gatt_char(
4144
"""Issue write command to GATT."""
4245
await super().write_gatt_char(char_specifier, data, response)
4346
assert self._notify_callback is not None
44-
self._notify_callback(
45-
"MockPwrcoreBleakClient", self._response(char_specifier, data)
46-
)
47+
self._notify_callback("MockEctiveBleakClient", bytearray(b'AT\r\n'))
48+
self._notify_callback("MockEctiveBleakClient", bytearray(b'AT\r\nillegal'))
49+
for notify_data in [
50+
self._response(char_specifier, data)[i : i + BT_FRAME_SIZE]
51+
for i in range(0, len(self._response(char_specifier, data)), BT_FRAME_SIZE)
52+
]:
53+
self._notify_callback("MockEctiveBleakClient", notify_data)
54+
55+
56+
class MockEJsfBleakClient(MockEJBleakClient):
57+
"""Emulate a E&J technology BMS BleakClient with single frame protocol."""
58+
59+
def _response(
60+
self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer
61+
) -> bytearray:
62+
if isinstance(char_specifier, str) and normalize_uuid_str(
63+
char_specifier
64+
) != normalize_uuid_str("6e400002-b5a3-f393-e0a9-e50e24dcca9e"):
65+
return bytearray()
66+
cmd: int = int(bytearray(data)[3:5], 16)
67+
if cmd == 0x02:
68+
return bytearray(
69+
b":008231008C000000000000000CBF0CC00CEA0CD50000000000000000000000000000000000000000"
70+
b"00000000008C000041282828F000000000000100004B044C05DC05DCB2~"
71+
) # TODO: put numbers
72+
return bytearray()
4773

4874

4975
async def test_update(monkeypatch, reconnect_fixture) -> None:
@@ -92,6 +118,45 @@ async def test_update(monkeypatch, reconnect_fixture) -> None:
92118
await bms.disconnect()
93119

94120

121+
async def test_update_single_frame(monkeypatch, reconnect_fixture) -> None:
122+
"""Test E&J technology BMS data update."""
123+
124+
monkeypatch.setattr(
125+
"custom_components.bms_ble.plugins.basebms.BleakClient",
126+
MockEJsfBleakClient,
127+
)
128+
129+
bms = BMS(
130+
generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEDevice", None, -73),
131+
reconnect_fixture,
132+
)
133+
134+
result = await bms.async_update()
135+
136+
assert result == {
137+
"voltage": 13.118,
138+
"current": 1.4,
139+
"battery_level": 75,
140+
"cycles": 1,
141+
"cycle_charge": 110.0,
142+
"cell#0": 3.263,
143+
"cell#1": 3.264,
144+
"cell#2": 3.306,
145+
"cell#3": 3.285,
146+
"delta_voltage": 0.043,
147+
"temperature": 25,
148+
"cycle_capacity": 1442.98,
149+
"power": 18.365,
150+
"battery_charging": True,
151+
}
152+
153+
# query again to check already connected state
154+
result = await bms.async_update()
155+
assert bms._client.is_connected is not reconnect_fixture # noqa: SLF001
156+
157+
await bms.disconnect()
158+
159+
95160
@pytest.fixture(
96161
name="wrong_response",
97162
params=[

0 commit comments

Comments
 (0)