Skip to content

Commit 02359a9

Browse files
authored
Add TDT BMS support (#117)
* initial code * add setup code * cleaned initialization and added tests * account for variable cell/sensor count * Update README.md * fixed coding style * removed unnecessary commands * updated 4s sample data
1 parent 6d9ad87 commit 02359a9

File tree

8 files changed

+587
-6
lines changed

8 files changed

+587
-6
lines changed

README.md

+9-2
Original file line numberDiff line numberDiff line change
@@ -29,9 +29,10 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management
2929
- Supervolt v3 batteries (show up as `SX1*`…)
3030
- JK BMS, Jikong, (HW version >=6 required)
3131
- Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…)
32-
- LiTime, Redodo batteries
32+
- LiTime, Power Queen, and Redodo batteries
3333
- Seplos v2 (show up as `BP0`?)
3434
- Seplos v3 (show up as `SP0`… or `SP1`…)
35+
- TDT BMS (show up as e.g., `XDZN`…)
3536

3637
New device types can be easily added via the plugin architecture of this integration. See the [contribution guidelines](CONTRIBUTING.md) for details.
3738

@@ -86,6 +87,12 @@ Installation can be done using [HACS](https://hacs.xyz/docs/use/) by [adding a c
8687
1. Restart Home Assistant
8788
1. In the HA UI go to "Configuration" -> "Integrations" click "+" and [search](https://my.home-assistant.io/redirect/config_flow_start/?domain=bms_ble) for "BLE Battery Management"
8889

90+
## Known Issues
91+
92+
<details><summary>Elektronicx batteries</summary>
93+
Bluetooth is turned off, when there is no current. Thus device will get unavailble / cannot be added.
94+
</details>
95+
8996
## FAQ
9097
### My sensors show unknown/unavailable at startup!
9198
The polling interval is 30 seconds. So at startup it takes a few minutes to detect the battery and query the sensors. Then data will be available.
@@ -164,7 +171,7 @@ Once pairing is done, the integration should automatically detect the BMS.
164171
- Add further battery types on [request](https://github.com/patman15/BMS_BLE-HA/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml)
165172

166173
## Thanks to
167-
> [@gkathan](https://github.com/patman15/BMS_BLE-HA/issues/2), [@downset](https://github.com/patman15/BMS_BLE-HA/issues/19), [@gerritb](https://github.com/patman15/BMS_BLE-HA/issues/22), [@Goaheadz](https://github.com/patman15/BMS_BLE-HA/issues/24), [@alros100, @majonessyltetoy](https://github.com/patman15/BMS_BLE-HA/issues/52), [@snipah, @Gruni22](https://github.com/patman15/BMS_BLE-HA/issues/59), [@azisto](https://github.com/patman15/BMS_BLE-HA/issues/78), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57), [@SkeLLLa,@romanshypovskyi](https://github.com/patman15/BMS_BLE-HA/issues/90), [@riogrande75, @ebagnoli, @andreas-bulling](https://github.com/patman15/BMS_BLE-HA/issues/101), [@hacsler](https://github.com/patman15/BMS_BLE-HA/issues/103)
174+
> [@gkathan](https://github.com/patman15/BMS_BLE-HA/issues/2), [@downset](https://github.com/patman15/BMS_BLE-HA/issues/19), [@gerritb](https://github.com/patman15/BMS_BLE-HA/issues/22), [@Goaheadz](https://github.com/patman15/BMS_BLE-HA/issues/24), [@alros100, @majonessyltetoy](https://github.com/patman15/BMS_BLE-HA/issues/52), [@snipah, @Gruni22](https://github.com/patman15/BMS_BLE-HA/issues/59), [@azisto](https://github.com/patman15/BMS_BLE-HA/issues/78), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57), [@SkeLLLa,@romanshypovskyi](https://github.com/patman15/BMS_BLE-HA/issues/90), [@riogrande75, @ebagnoli, @andreas-bulling](https://github.com/patman15/BMS_BLE-HA/issues/101), [@goblinmaks, @andreitoma-github](https://github.com/patman15/BMS_BLE-HA/issues/102), [@hacsler](https://github.com/patman15/BMS_BLE-HA/issues/103)
168175

169176
for helping with making the integration better.
170177

custom_components/bms_ble/const.py

+1
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
"redodo_bms",
2222
"seplos_bms",
2323
"seplos_v2_bms",
24+
"tdt_bms",
2425
"dpwrcore_bms", # only name filter
2526
] # available BMS types
2627
DOMAIN: Final[str] = "bms_ble"

custom_components/bms_ble/manifest.json

+3
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,9 @@
6868
"service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb",
6969
"manufacturer_id": 65535
7070
},
71+
{
72+
"manufacturer_id": 54976
73+
},
7174
{
7275
"local_name": "BP0?",
7376
"service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb"

custom_components/bms_ble/plugins/basebms.py

-2
Original file line numberDiff line numberDiff line change
@@ -238,7 +238,6 @@ def crc_modbus(data: bytearray) -> int:
238238
crc = (crc >> 1) ^ 0xA001 if crc % 2 else (crc >> 1)
239239
return crc & 0xFFFF
240240

241-
242241
def crc_xmodem(data: bytearray) -> int:
243242
"""Calculate CRC-16-CCITT XMODEM."""
244243
crc: int = 0x0000
@@ -248,7 +247,6 @@ def crc_xmodem(data: bytearray) -> int:
248247
crc = (crc << 1) ^ 0x1021 if (crc & 0x8000) else (crc << 1)
249248
return crc & 0xFFFF
250249

251-
252250
def crc_sum(frame: bytes) -> int:
253251
"""Calculate frame CRC."""
254252
return sum(frame) & 0xFF
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
"""Module to support TDT BMS."""
2+
3+
import asyncio
4+
import logging
5+
from typing import Any, Callable, Final
6+
7+
from bleak.backends.device import BLEDevice
8+
from bleak.exc import BleakError
9+
from bleak.uuids import normalize_uuid_str
10+
11+
from custom_components.bms_ble.const import (
12+
ATTR_BATTERY_CHARGING,
13+
ATTR_BATTERY_LEVEL,
14+
ATTR_CURRENT,
15+
ATTR_CYCLE_CAP,
16+
ATTR_CYCLE_CHRG,
17+
ATTR_CYCLES,
18+
ATTR_DELTA_VOLTAGE,
19+
ATTR_POWER,
20+
ATTR_RUNTIME,
21+
ATTR_TEMPERATURE,
22+
ATTR_VOLTAGE,
23+
KEY_CELL_COUNT,
24+
KEY_CELL_VOLTAGE,
25+
KEY_TEMP_SENS,
26+
KEY_TEMP_VALUE,
27+
)
28+
29+
from .basebms import BaseBMS, BMSsample, crc_modbus
30+
31+
LOGGER = logging.getLogger(__name__)
32+
BAT_TIMEOUT = 10
33+
34+
35+
class BMS(BaseBMS):
36+
"""Dummy battery class implementation."""
37+
38+
_UUID_CFG: Final[str] = "fffa"
39+
_HEAD: Final[int] = 0x7E
40+
_TAIL: Final[int] = 0x0D
41+
_CMD_VER: Final[int] = 0x00
42+
_RSP_VER: Final[int] = 0x00
43+
_CELL_POS: Final[int] = 0x8
44+
_INFO_LEN: Final[int] = 10 # minimal frame length
45+
_FIELDS: Final[
46+
list[tuple[str, int, int, int, bool, Callable[[int], int | float]]]
47+
] = [
48+
(ATTR_VOLTAGE, 0x8C, 2, 2, False, lambda x: float(x / 100)),
49+
(
50+
ATTR_CURRENT,
51+
0x8C,
52+
0,
53+
2,
54+
False,
55+
lambda x: float((x & 0x3FFF) / 10 * (-1 if x >> 15 else 1)),
56+
),
57+
(ATTR_CYCLE_CHRG, 0x8C, 4, 2, False, lambda x: float(x / 10)),
58+
(ATTR_BATTERY_LEVEL, 0x8C, 13, 1, False, lambda x: x),
59+
(ATTR_CYCLES, 0x8C, 8, 2, False, lambda x: x),
60+
]
61+
_CMDS: Final[list[int]] = [*list({field[1] for field in _FIELDS})]
62+
63+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
64+
"""Initialize BMS."""
65+
LOGGER.debug("%s init(), BT address: %s", self.device_id(), ble_device.address)
66+
super().__init__(LOGGER, self._notification_handler, ble_device, reconnect)
67+
self._data: bytearray = bytearray()
68+
self._exp_len: int = 0
69+
self._data_final: dict[int, bytearray] = {}
70+
71+
@staticmethod
72+
def matcher_dict_list() -> list[dict[str, Any]]:
73+
"""Provide BluetoothMatcher definition."""
74+
return [{"manufacturer_id": 54976, "connectable": True}]
75+
76+
@staticmethod
77+
def device_info() -> dict[str, str]:
78+
"""Return device information for the battery management system."""
79+
return {"manufacturer": "TDT", "model": "Smart BMS"}
80+
81+
@staticmethod
82+
def uuid_services() -> list[str]:
83+
"""Return list of 128-bit UUIDs of services required by BMS."""
84+
return [normalize_uuid_str("fff0")]
85+
86+
@staticmethod
87+
def uuid_rx() -> str:
88+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
89+
return "fff1"
90+
91+
@staticmethod
92+
def uuid_tx() -> str:
93+
"""Return 16-bit UUID of characteristic that provides write property."""
94+
return "fff2"
95+
96+
@staticmethod
97+
def _calc_values() -> set[str]:
98+
return {
99+
ATTR_BATTERY_CHARGING,
100+
ATTR_CYCLE_CAP,
101+
ATTR_DELTA_VOLTAGE,
102+
ATTR_POWER,
103+
ATTR_RUNTIME,
104+
ATTR_TEMPERATURE,
105+
} # calculate further values from BMS provided set ones
106+
107+
async def _init_characteristics(self) -> None:
108+
try:
109+
await self._client.write_gatt_char(BMS._UUID_CFG, data=b"HiLink")
110+
if (
111+
ret := int.from_bytes(await self._client.read_gatt_char(BMS._UUID_CFG))
112+
) != 0x1:
113+
LOGGER.debug("%s: error initializing BMS: %X", self.name, ret)
114+
except (BleakError, EOFError) as err:
115+
LOGGER.debug("%s: failed to intialize BMS: %s", self.name, err)
116+
117+
await super()._init_characteristics()
118+
119+
def _notification_handler(self, _sender, data: bytearray) -> None:
120+
"""Handle the RX characteristics notify event (new data arrives)."""
121+
LOGGER.debug("%s: Received BLE data: %s", self.name, data)
122+
123+
if (
124+
data[0] == BMS._HEAD
125+
and len(data) > BMS._INFO_LEN
126+
and len(self._data) >= self._exp_len
127+
):
128+
self._exp_len = BMS._INFO_LEN + int.from_bytes(data[6:8])
129+
self._data = bytearray()
130+
131+
self._data += data
132+
LOGGER.debug(
133+
"%s: RX BLE data (%s): %s",
134+
self._ble_device.name,
135+
"start" if data == self._data else "cnt.",
136+
data,
137+
)
138+
139+
# verify that data long enough
140+
if len(self._data) < self._exp_len:
141+
return
142+
143+
if self._data[-1] != BMS._TAIL:
144+
LOGGER.debug("%s: frame end incorrect: %s", self.name, self._data)
145+
return
146+
147+
if self._data[1] != BMS._RSP_VER:
148+
LOGGER.debug(
149+
"%s: unknown frame version: V%.1f", self.name, self._data[1] / 10
150+
)
151+
return
152+
153+
if self._data[4]:
154+
LOGGER.debug("%s: BMS reported error code: 0x%X", self.name, self._data[4])
155+
return
156+
157+
crc = crc_modbus(self._data[:-3])
158+
if int.from_bytes(self._data[-3:-1], "big") != crc:
159+
LOGGER.debug(
160+
"%s: RX data CRC is invalid: 0x%X != 0x%X",
161+
self._ble_device.name,
162+
int.from_bytes(self._data[-3:-1], "big"),
163+
crc,
164+
)
165+
return
166+
self._data_final[self._data[5]] = self._data
167+
self._data_event.set()
168+
169+
@staticmethod
170+
def _cmd(cmd: int, data: bytearray = bytearray()) -> bytearray:
171+
"""Assemble a TDT BMS command."""
172+
assert cmd in (0x8C, 0x8D, 0x92) # allow only read commands
173+
frame = bytearray(
174+
[BMS._HEAD, BMS._CMD_VER, 0x1, 0x3, 0x0, cmd]
175+
) # fixed version
176+
frame += len(data).to_bytes(2, "big", signed=False) + data
177+
frame += bytearray(int.to_bytes(crc_modbus(frame), 2, byteorder="big"))
178+
frame += bytearray([BMS._TAIL])
179+
LOGGER.debug("TX cmd: %s", frame.hex(" ")) # TODO: remove
180+
return frame
181+
182+
@staticmethod
183+
def _decode_data(data: dict[int, bytearray], offs: int) -> dict[str, int | float]:
184+
return {
185+
key: func(
186+
int.from_bytes(
187+
data[cmd][idx + offs : idx + offs + size],
188+
byteorder="big",
189+
signed=sign,
190+
)
191+
)
192+
for key, cmd, idx, size, sign, func in BMS._FIELDS
193+
}
194+
195+
@staticmethod
196+
def _cell_voltages(data: bytearray) -> dict[str, float]:
197+
return {
198+
f"{KEY_CELL_VOLTAGE}{idx}": float(
199+
int.from_bytes(
200+
data[BMS._CELL_POS + 1 + idx * 2 : BMS._CELL_POS + 1 + idx * 2 + 2],
201+
byteorder="big",
202+
signed=False,
203+
)
204+
)
205+
/ 1000
206+
for idx in range(data[BMS._CELL_POS])
207+
}
208+
209+
@staticmethod
210+
def _temp_sensors(data: bytearray, sensors: int, offs: int) -> dict[str, float]:
211+
return {
212+
f"{KEY_TEMP_VALUE}{idx}": (
213+
int.from_bytes(
214+
data[offs + idx * 2 : offs + (idx + 1) * 2],
215+
byteorder="big",
216+
signed=False,
217+
)
218+
- 2731.5
219+
)
220+
/ 10
221+
for idx in range(sensors)
222+
if int.from_bytes(
223+
data[offs + idx * 2 : offs + (idx + 1) * 2],
224+
byteorder="big",
225+
signed=False,
226+
)
227+
}
228+
229+
async def _async_update(self) -> BMSsample:
230+
"""Update battery status information."""
231+
232+
for cmd in BMS._CMDS:
233+
await self._client.write_gatt_char(BMS.uuid_tx(), data=BMS._cmd(cmd))
234+
await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT)
235+
236+
result: BMSsample = {KEY_CELL_COUNT: int(self._data_final[0x8C][BMS._CELL_POS])}
237+
result[KEY_TEMP_SENS] = int(
238+
self._data_final[0x8C][BMS._CELL_POS + int(result[KEY_CELL_COUNT]) * 2 + 1]
239+
)
240+
241+
result |= BMS._cell_voltages(self._data_final[0x8C])
242+
result |= BMS._temp_sensors(
243+
self._data_final[0x8C],
244+
int(result[KEY_TEMP_SENS]),
245+
BMS._CELL_POS + int(result[KEY_CELL_COUNT]) * 2 + 2,
246+
)
247+
result |= BMS._decode_data(
248+
self._data_final,
249+
BMS._CELL_POS + int(result[KEY_CELL_COUNT] + result[KEY_TEMP_SENS]) * 2 + 2,
250+
)
251+
252+
self._data_final.clear()
253+
254+
return result

tests/conftest.py

+11-1
Original file line numberDiff line numberDiff line change
@@ -309,10 +309,20 @@ async def write_gatt_char(
309309
) -> None:
310310
"""Mock write GATT characteristics."""
311311
LOGGER.debug(
312-
"MockBleakClient write_gatt_char for %s, data: %s", char_specifier, data
312+
"MockBleakClient write_gatt_char %s, data: %s", char_specifier, data
313313
)
314314
assert self._connected, "write_gatt_char called, but client not connected."
315315

316+
async def read_gatt_char(
317+
self,
318+
char_specifier: BleakGATTCharacteristic | int | str | UUID,
319+
**kwargs,
320+
) -> bytearray:
321+
"""Mock write GATT characteristics."""
322+
LOGGER.debug("MockBleakClient read_gatt_char %s", char_specifier)
323+
assert self._connected, "read_gatt_char called, but client not connected."
324+
return bytearray()
325+
316326
async def disconnect(self) -> bool:
317327
"""Mock disconnect."""
318328
assert self._connected, "Disconnect called, but client not connected."

tests/test_const.py

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,4 +7,4 @@ async def test_critical_constants() -> None:
77
"""Test general constants are not altered for debugging."""
88

99
assert UPDATE_INTERVAL == 30 # ensure that update interval is 30 seconds
10-
assert len(BMS_TYPES) == 11 # check number of BMS types
10+
assert len(BMS_TYPES) == 12 # check number of BMS types

0 commit comments

Comments
 (0)