Skip to content

Commit 620959c

Browse files
authored
support Redodo battery bms (#79)
* initial version, missing SoC, remaining capacity, cycles, temperature * add missing sensor values * add tests * Update manifest.json * removed unnecessary log message * fixed cycle charge, fixes runtime as well * fixed import sorting * Update test_const.py
1 parent cf16053 commit 620959c

File tree

6 files changed

+319
-5
lines changed

6 files changed

+319
-5
lines changed

README.md

+5-3
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,16 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management
1717
- Readout of individual cell voltages to be able to judge battery health
1818

1919
### Supported Devices
20-
- Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…)
2120
- CBT Power BMS (Creabest batteries)
2221
- D-powercore BMS (show up as `DXB-`…), Fliteboard batteries (show up as `TBA-`…)
2322
- Daly BMS (show up as `DL-`…)
2423
- E&J Technology BMS, Supervolt v1 batteries
25-
- JK BMS, Jikong, (HW version >=11 required)
2624
- JBD BMS, Jiabaida, Supervolt v3 batteries
25+
- JK BMS, Jikong, (HW version >=11 required)
26+
- Offgridtec LiFePo4 Smart Pro: type A & B (show up as `SmartBat-A`… or `SmartBat-B`…)
27+
- Redodo batteries
2728
- Seplos v3 (show up as `SP0`… or `SP1`…)
29+
- Supervolt batteries (JBD BMS)
2830

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

@@ -143,7 +145,7 @@ Once pairing is done, the integration should automatically detect the BMS.
143145

144146

145147
## Thanks to
146-
> [@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), [@BikeAtor, @Karatzie](https://github.com/patman15/BMS_BLE-HA/issues/57)
148+
> [@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)
147149
148150
for helping with making the integration better.
149151

custom_components/bms_ble/const.py

+1
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818
"jbd_bms",
1919
"jikong_bms",
2020
"ogt_bms",
21+
"redodo_bms",
2122
"seplos_bms",
2223
] # available BMS types
2324
DOMAIN: Final = "bms_ble"

custom_components/bms_ble/manifest.json

+5-1
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,10 @@
3838
{
3939
"service_uuid": "0000ffb0-0000-1000-8000-00805f9b34fb"
4040
},
41+
{
42+
"service_uuid": "0000ffe0-0000-1000-8000-00805f9b34fb",
43+
"manufacturer_id": 22618
44+
},
4145
{
4246
"local_name": "libatt*",
4347
"manufacturer_id": 21320
@@ -51,5 +55,5 @@
5155
"iot_class": "local_polling",
5256
"issue_tracker": "https://github.com/patman15/BMS_BLE-HA/issues",
5357
"requirements": [],
54-
"version": "1.8.0"
58+
"version": "1.9.0"
5559
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,152 @@
1+
"""Module to support Dummy 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.uuids import normalize_uuid_str
9+
10+
from custom_components.bms_ble.const import (
11+
ATTR_BATTERY_CHARGING,
12+
ATTR_BATTERY_LEVEL,
13+
ATTR_CURRENT,
14+
ATTR_CYCLE_CAP,
15+
ATTR_CYCLE_CHRG,
16+
ATTR_CYCLES,
17+
ATTR_DELTA_VOLTAGE,
18+
ATTR_POWER,
19+
ATTR_RUNTIME,
20+
ATTR_TEMPERATURE,
21+
ATTR_VOLTAGE,
22+
KEY_CELL_VOLTAGE,
23+
)
24+
25+
from .basebms import BaseBMS, BMSsample
26+
27+
LOGGER = logging.getLogger(__name__)
28+
BAT_TIMEOUT = 10
29+
30+
31+
class BMS(BaseBMS):
32+
"""Dummy battery class implementation."""
33+
34+
CRC_POS: Final = -1 # last byte
35+
HEAD_LEN: Final = 3
36+
MAX_CELLS: Final = 16
37+
38+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
39+
"""Initialize BMS."""
40+
LOGGER.debug("%s init(), BT address: %s", self.device_id(), ble_device.address)
41+
super().__init__(LOGGER, self._notification_handler, ble_device, reconnect)
42+
43+
self._data: bytearray = bytearray()
44+
self._FIELDS: Final[
45+
list[tuple[str, int, int, bool, Callable[[int], int | float]]]
46+
] = [
47+
(ATTR_VOLTAGE, 12, 2, False, lambda x: float(x / 1000)),
48+
(ATTR_CURRENT, 48, 4, True, lambda x: float(x / 1000)),
49+
(ATTR_TEMPERATURE, 56, 2, False, lambda x: x),
50+
(ATTR_BATTERY_LEVEL, 90, 2, False, lambda x: x),
51+
(ATTR_CYCLE_CHRG, 62, 2, False, lambda x: float(x / 100)),
52+
(ATTR_CYCLES, 96, 4, False, lambda x: x),
53+
]
54+
55+
@staticmethod
56+
def matcher_dict_list() -> list[dict[str, Any]]:
57+
"""Provide BluetoothMatcher definition."""
58+
return [
59+
{
60+
"service_uuid": BMS.uuid_services()[0],
61+
"manufacturer_id": 0x585A,
62+
"connectable": True,
63+
}
64+
]
65+
66+
@staticmethod
67+
def device_info() -> dict[str, str]:
68+
"""Return device information for the battery management system."""
69+
return {"manufacturer": "Redodo", "model": "Bluetooth battery"}
70+
71+
@staticmethod
72+
def uuid_services() -> list[str]:
73+
"""Return list of 128-bit UUIDs of services required by BMS."""
74+
return [normalize_uuid_str("ffe0")] # change service UUID here!
75+
76+
@staticmethod
77+
def uuid_rx() -> str:
78+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
79+
return "ffe1"
80+
81+
@staticmethod
82+
def uuid_tx() -> str:
83+
"""Return 16-bit UUID of characteristic that provides write property."""
84+
return "ffe2"
85+
86+
@staticmethod
87+
def _calc_values() -> set[str]:
88+
return {
89+
ATTR_BATTERY_CHARGING,
90+
ATTR_DELTA_VOLTAGE,
91+
ATTR_CYCLE_CAP,
92+
ATTR_POWER,
93+
ATTR_RUNTIME,
94+
} # calculate further values from BMS provided set ones
95+
96+
def _notification_handler(self, _sender, data: bytearray) -> None:
97+
"""Handle the RX characteristics notify event (new data arrives)."""
98+
LOGGER.debug("%s: Received BLE data: %s", self.name, data.hex(" "))
99+
100+
if len(data) < 3 or not data.startswith(b"\x00\x00"):
101+
LOGGER.debug("%s: incorrect SOF.")
102+
return
103+
104+
if len(data) != data[2] + self.HEAD_LEN + 1: # add header length and CRC
105+
LOGGER.debug("(%s) incorrect frame length (%i)", self.name, len(data))
106+
return
107+
108+
crc = self._crc(data[: self.CRC_POS])
109+
if crc != data[self.CRC_POS]:
110+
LOGGER.debug(
111+
"(%s) Rx data CRC is invalid: 0x%x != 0x%x",
112+
self.name,
113+
data[len(data) + self.CRC_POS],
114+
crc,
115+
)
116+
return
117+
118+
self._data = data
119+
self._data_event.set()
120+
121+
def _crc(self, frame: bytes) -> int:
122+
"""Calculate frame CRC."""
123+
return sum(frame) & 0xFF
124+
125+
def _cell_voltages(self, data: bytearray, cells: int) -> dict[str, float]:
126+
"""Return cell voltages from status message."""
127+
return {
128+
f"{KEY_CELL_VOLTAGE}{idx}": int.from_bytes(
129+
data[16 + 2 * idx : 16 + 2 * idx + 2],
130+
byteorder="little",
131+
signed=False,
132+
)
133+
/ 1000
134+
for idx in range(cells)
135+
if int.from_bytes(data[16 + 2 * idx : 16 + 2 * idx + 2], byteorder="little")
136+
}
137+
138+
async def _async_update(self) -> BMSsample:
139+
"""Update battery status information."""
140+
await self._client.write_gatt_char(
141+
BMS.uuid_tx(), data=b"\x00\x00\x04\x01\x13\x55\xaa\x17"
142+
)
143+
await asyncio.wait_for(self._wait_event(), timeout=BAT_TIMEOUT)
144+
145+
return {
146+
key: func(
147+
int.from_bytes(
148+
self._data[idx : idx + size], byteorder="little", signed=sign
149+
)
150+
)
151+
for key, idx, size, sign, func in self._FIELDS
152+
} | self._cell_voltages(self._data, self.MAX_CELLS)

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) == 8 # check number of BMS types
10+
assert len(BMS_TYPES) == 9 # check number of BMS types

tests/test_redodo_bms.py

+155
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
"""Test the E&J technology BMS implementation."""
2+
3+
import pytest
4+
from collections.abc import Buffer
5+
from uuid import UUID
6+
7+
from bleak.backends.characteristic import BleakGATTCharacteristic
8+
from bleak.uuids import normalize_uuid_str
9+
from custom_components.bms_ble.plugins.redodo_bms import BMS
10+
11+
from .bluetooth import generate_ble_device
12+
from .conftest import MockBleakClient
13+
14+
15+
class MockRedodoBleakClient(MockBleakClient):
16+
"""Emulate a Redodo BMS BleakClient."""
17+
18+
def _response(
19+
self, char_specifier: BleakGATTCharacteristic | int | str | UUID, data: Buffer
20+
) -> bytearray:
21+
if isinstance(char_specifier, str) and normalize_uuid_str(
22+
char_specifier
23+
) != normalize_uuid_str("ffe2"):
24+
return bytearray()
25+
cmd: int = bytearray(data)[4]
26+
if cmd == 0x13:
27+
return bytearray(
28+
b"\x00\x00\x65\x01\x93\x55\xaa\x00\x46\x66\x00\x00\xbc\x67\x00\x00\xf5\x0c\xf7\x0c"
29+
b"\xfc\x0c\xfb\x0c\xf8\x0c\xf2\x0c\xfa\x0c\xf5\x0c\x00\x00\x00\x00\x00\x00\x00\x00"
30+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x65\xfa\xff\xff\x17\x00\x16\x00\x17\x00\x00\x00"
31+
b"\x00\x00\xe9\x1a\x04\x29\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
32+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x41\x00\x64\x00\x00\x00\x03\x00\x00\x00"
33+
b"\x5f\x01\x00\x00\xbc"
34+
) # TODO: put numbers
35+
return bytearray()
36+
37+
async def write_gatt_char(
38+
self,
39+
char_specifier: BleakGATTCharacteristic | int | str | UUID,
40+
data: Buffer,
41+
response: bool = None, # type: ignore[implicit-optional] # noqa: RUF013 # same as upstream
42+
) -> None:
43+
"""Issue write command to GATT."""
44+
await super().write_gatt_char(char_specifier, data, response)
45+
assert self._notify_callback is not None
46+
self._notify_callback(
47+
"MockRedodoBleakClient", self._response(char_specifier, data)
48+
)
49+
50+
51+
async def test_update(monkeypatch, reconnect_fixture) -> None:
52+
"""Test Redodo technology BMS data update."""
53+
54+
monkeypatch.setattr(
55+
"custom_components.bms_ble.plugins.basebms.BleakClient",
56+
MockRedodoBleakClient,
57+
)
58+
59+
bms = BMS(
60+
generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEDevice", None, -73),
61+
reconnect_fixture,
62+
)
63+
64+
result = await bms.async_update()
65+
66+
assert result == {
67+
"voltage": 26.556,
68+
"current": -1.435,
69+
"cell#0": 3.317,
70+
"cell#1": 3.319,
71+
"cell#2": 3.324,
72+
"cell#3": 3.323,
73+
"cell#4": 3.320,
74+
"cell#5": 3.314,
75+
"cell#6": 3.322,
76+
"cell#7": 3.317,
77+
"delta_voltage": 0.01,
78+
"power": -38.108,
79+
"battery_charging": False,
80+
"battery_level": 65,
81+
"cycle_charge": 68.89,
82+
"cycle_capacity": 1829.443,
83+
"runtime": 172825,
84+
"temperature": 23,
85+
"cycles": 3,
86+
}
87+
88+
# query again to check already connected state
89+
result = await bms.async_update()
90+
assert bms._client.is_connected is not reconnect_fixture # noqa: SLF001
91+
92+
await bms.disconnect()
93+
94+
95+
@pytest.fixture(
96+
name="wrong_response",
97+
params=[
98+
bytearray(
99+
b"\x00\x00\x65\x01\x93\x55\xaa\x00\x46\x66\x00\x00\xbc\x67\x00\x00\xf5\x0c\xf7\x0c"
100+
b"\xfc\x0c\xfb\x0c\xf8\x0c\xf2\x0c\xfa\x0c\xf5\x0c\x00\x00\x00\x00\x00\x00\x00\x00"
101+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x65\xfa\xff\xff\x17\x00\x16\x00\x17\x00\x00\x00"
102+
b"\x00\x00\xe9\x1a\x04\x29\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
103+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x41\x00\x64\x00\x00\x00\x03\x00\x00\x00"
104+
b"\x5f\x01\x00\x00\xff" # wrong CRC
105+
),
106+
bytearray(
107+
b"\x00\x01\x65\x01\x93\x55\xaa\x00\x46\x66\x00\x00\xbc\x67\x00\x00\xf5\x0c\xf7\x0c"
108+
b"\xfc\x0c\xfb\x0c\xf8\x0c\xf2\x0c\xfa\x0c\xf5\x0c\x00\x00\x00\x00\x00\x00\x00\x00"
109+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x65\xfa\xff\xff\x17\x00\x16\x00\x17\x00\x00\x00"
110+
b"\x00\x00\xe9\x1a\x04\x29\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
111+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x41\x00\x64\x00\x00\x00\x03\x00\x00\x00"
112+
b"\x5f\x01\x00\x00\xbc" # wrong SOF
113+
),
114+
bytearray(
115+
b"\x00\x00\x65\x01\x93\x55\xaa\x00\x46\x66\x00\x00\xbc\x67\x00\x00\xf5\x0c\xf7\x0c"
116+
b"\xfc\x0c\xfb\x0c\xf8\x0c\xf2\x0c\xfa\x0c\xf5\x0c\x00\x00\x00\x00\x00\x00\x00\x00"
117+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x65\xfa\xff\xff\x17\x00\x16\x00\x17\x00\x00\x00"
118+
b"\x00\x00\xe9\x1a\x04\x29\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
119+
b"\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x41\x00\x64\x00\x00\x00\x03\x00\x00\x00"
120+
b"\x5f\x01\x00\xbc" # wrong length
121+
),
122+
bytearray(b"\x00"), # much too short
123+
],
124+
)
125+
def response(request):
126+
"""Return all possible BMS variants."""
127+
return request.param
128+
129+
130+
async def test_invalid_response(monkeypatch, wrong_response) -> None:
131+
"""Test data up date with BMS returning invalid data."""
132+
133+
monkeypatch.setattr(
134+
"custom_components.bms_ble.plugins.redodo_bms.BAT_TIMEOUT",
135+
0.1,
136+
)
137+
138+
monkeypatch.setattr(
139+
"tests.test_redodo_bms.MockRedodoBleakClient._response",
140+
lambda _s, _c_, d: wrong_response,
141+
)
142+
143+
monkeypatch.setattr(
144+
"custom_components.bms_ble.plugins.basebms.BleakClient",
145+
MockRedodoBleakClient,
146+
)
147+
148+
bms = BMS(generate_ble_device("cc:cc:cc:cc:cc:cc", "MockBLEDevice", None, -73))
149+
150+
result = {}
151+
with pytest.raises(TimeoutError):
152+
result = await bms.async_update()
153+
154+
assert not result
155+
await bms.disconnect()

0 commit comments

Comments
 (0)