Skip to content

Commit cc4b3e0

Browse files
authored
Add Ecoworthy BW02 adapter (#199)
* add detection for ECO-WORTHY with BW02 adaptor * remove service ID for detection * initial implementation * fixed capacity values * add problem detection * fix update test * fix BMS types list check * corrected advertisement test for ECOWORTHY * add problem reporting * fix test coverage * add test for cycle_chrg calculation * add problem tests * Update README.md
1 parent c7aae11 commit cc4b3e0

10 files changed

+491
-11
lines changed

README.md

+2-1
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ This integration allows to monitor Bluetooth Low Energy (BLE) battery management
2727
- Elektronicx batteries (show up as `LT-`…)
2828
- Lithtech batteries (show up as `LT-12V-`… or `L-12V`…)
2929
- Meritsun, Supervolt v1, Volthium batteries
30+
- ECO-WORTHY + BW02 adapter
3031
- Ective, Topband batteries
3132
- Felicity ESS batteries (show up as `F10`…)
3233
- JBD BMS, Jiabaida (show up as `SP..S`…)
@@ -183,7 +184,7 @@ Once pairing is done, the integration should automatically detect the BMS.
183184
- Add further battery types on [request](https://github.com/patman15/BMS_BLE-HA/issues/new?assignees=&labels=enhancement&projects=&template=feature_request.yml)
184185

185186
## Thanks to
186-
> [@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), [@ViPeR5000](https://github.com/patman15/BMS_BLE-HA/pull/182), [@edelstahlratte](https://github.com/patman15/BMS_BLE-HA/issues/161), [@Fandu21](https://github.com/patman15/BMS_BLE-HA/issues/194)
187+
> [@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), [@ViPeR5000](https://github.com/patman15/BMS_BLE-HA/pull/182), [@edelstahlratte](https://github.com/patman15/BMS_BLE-HA/issues/161), [@nezra](https://github.com/patman15/BMS_BLE-HA/issues/164), [@Fandu21](https://github.com/patman15/BMS_BLE-HA/issues/194)
187188

188189
for helping with making the integration better.
189190

custom_components/bms_ble/const.py

+1
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@
1313
BMS_TYPES: Final[list[str]] = [
1414
"cbtpwr_bms",
1515
"daly_bms",
16+
"ecoworthy_bms",
1617
"ective_bms",
1718
"ej_bms",
1819
"jbd_bms",

custom_components/bms_ble/manifest.json

+4
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@
6060
"manufacturer_id": 8856,
6161
"service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb"
6262
},
63+
{
64+
"local_name": "ECO-WORTHY*",
65+
"manufacturer_id": 49844
66+
},
6367
{
6468
"local_name": "DP04S*",
6569
"service_uuid": "0000ff00-0000-1000-8000-00805f9b34fb"

custom_components/bms_ble/plugins/basebms.py

+7
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@
2525
ATTR_TEMPERATURE,
2626
ATTR_VOLTAGE,
2727
KEY_CELL_VOLTAGE,
28+
KEY_DESIGN_CAP,
2829
KEY_PROBLEM,
2930
KEY_TEMP_VALUE,
3031
)
@@ -165,6 +166,12 @@ def can_calc(value: str, using: frozenset[str]) -> bool:
165166
]
166167
data[ATTR_DELTA_VOLTAGE] = round(max(cell_voltages) - min(cell_voltages), 3)
167168

169+
# calculate cycle charge from design capacity and SoC
170+
if can_calc(ATTR_CYCLE_CHRG, frozenset({KEY_DESIGN_CAP, ATTR_BATTERY_LEVEL})):
171+
data[ATTR_CYCLE_CHRG] = (
172+
data[KEY_DESIGN_CAP] * data[ATTR_BATTERY_LEVEL]
173+
) / 100
174+
168175
# calculate cycle capacity from voltage and cycle charge
169176
if can_calc(ATTR_CYCLE_CAP, frozenset({ATTR_VOLTAGE, ATTR_CYCLE_CHRG})):
170177
data[ATTR_CYCLE_CAP] = round(data[ATTR_VOLTAGE] * data[ATTR_CYCLE_CHRG], 3)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,183 @@
1+
"""Module to support ECO-WORTHY BMS."""
2+
3+
import asyncio
4+
from collections.abc import Callable
5+
from typing import Final
6+
7+
from bleak.backends.characteristic import BleakGATTCharacteristic
8+
from bleak.backends.device import BLEDevice
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_DESIGN_CAP,
26+
KEY_PROBLEM,
27+
KEY_TEMP_SENS,
28+
KEY_TEMP_VALUE,
29+
)
30+
31+
from .basebms import BaseBMS, BMSsample, crc_modbus
32+
33+
34+
class BMS(BaseBMS):
35+
"""ECO-WORTHY battery class implementation."""
36+
37+
_HEAD: Final[list[int]] = [0xA1, 0xA2]
38+
_CELL_POS: Final[int] = 14
39+
_TEMP_POS: Final[int] = 80
40+
_FIELDS: Final[
41+
list[tuple[str, int, int, int, bool, Callable[[int], int | float]]]
42+
] = [
43+
(ATTR_BATTERY_LEVEL, 0xA1, 16, 2, False, lambda x: x),
44+
(ATTR_VOLTAGE, 0xA1, 20, 2, False, lambda x: float(x / 100)),
45+
(ATTR_CURRENT, 0xA1, 22, 2, True, lambda x: float(x / 100)),
46+
(KEY_PROBLEM, 0xA1, 51, 2, False, lambda x: x),
47+
(KEY_DESIGN_CAP, 0xA1, 26, 2, False, lambda x: float(x / 100)),
48+
(KEY_CELL_COUNT, 0xA2, _CELL_POS, 2, False, lambda x: x),
49+
(KEY_TEMP_SENS, 0xA2, _TEMP_POS, 2, False, lambda x: x),
50+
# (ATTR_CYCLES, 0xA1, 8, 2, False, lambda x: x),
51+
]
52+
_CMDS: Final[set[int]] = set({field[1] for field in _FIELDS})
53+
54+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
55+
"""Initialize BMS."""
56+
super().__init__(__name__, ble_device, reconnect)
57+
self._data_final: dict[int, bytearray] = {}
58+
59+
@staticmethod
60+
def matcher_dict_list() -> list[dict]:
61+
"""Provide BluetoothMatcher definition."""
62+
return [
63+
{
64+
"local_name": "ECO-WORTHY*",
65+
"manufacturer_id": 0xC2B4,
66+
"connectable": True,
67+
}
68+
]
69+
70+
@staticmethod
71+
def device_info() -> dict[str, str]:
72+
"""Return device information for the battery management system."""
73+
return {"manufacturer": "ECO-WORTHY", "model": "BW02"}
74+
75+
@staticmethod
76+
def uuid_services() -> list[str]:
77+
"""Return list of 128-bit UUIDs of services required by BMS."""
78+
return [normalize_uuid_str("fff0")]
79+
80+
@staticmethod
81+
def uuid_rx() -> str:
82+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
83+
return "fff1"
84+
85+
@staticmethod
86+
def uuid_tx() -> str:
87+
"""Return 16-bit UUID of characteristic that provides write property."""
88+
raise NotImplementedError
89+
90+
@staticmethod
91+
def _calc_values() -> set[str]:
92+
return {
93+
ATTR_BATTERY_CHARGING,
94+
ATTR_CYCLE_CHRG,
95+
ATTR_CYCLE_CAP,
96+
ATTR_DELTA_VOLTAGE,
97+
ATTR_POWER,
98+
ATTR_RUNTIME,
99+
ATTR_TEMPERATURE,
100+
} # calculate further values from BMS provided set ones
101+
102+
def _notification_handler(
103+
self, _sender: BleakGATTCharacteristic, data: bytearray
104+
) -> None:
105+
"""Handle the RX characteristics notify event (new data arrives)."""
106+
self._log.debug("RX BLE data: %s", data)
107+
108+
if data[0] not in BMS._HEAD:
109+
self._log.debug("Invalid frame type: 0x%X", data[0])
110+
return
111+
112+
crc: Final[int] = crc_modbus(data[:-2])
113+
if int.from_bytes(data[-2:], "little") != crc:
114+
self._log.debug(
115+
"invalid checksum 0x%X != 0x%X",
116+
int.from_bytes(data[-2:], "little"),
117+
crc,
118+
)
119+
self._data = bytearray()
120+
return
121+
122+
self._data_final[data[0]] = data.copy()
123+
if BMS._CMDS.issubset(self._data_final.keys()):
124+
self._data_event.set()
125+
126+
@staticmethod
127+
def _decode_data(data: dict[int, bytearray]) -> dict[str, int | float]:
128+
return {
129+
key: func(
130+
int.from_bytes(
131+
data[cmd][idx : idx + size], byteorder="big", signed=sign
132+
)
133+
)
134+
for key, cmd, idx, size, sign, func in BMS._FIELDS
135+
}
136+
137+
@staticmethod
138+
def _cell_voltages(data: bytearray, cells: int, offs: int) -> dict[str, float]:
139+
return {KEY_CELL_COUNT: cells} | {
140+
f"{KEY_CELL_VOLTAGE}{idx}": float(
141+
int.from_bytes(
142+
data[offs + idx * 2 : offs + idx * 2 + 2],
143+
byteorder="big",
144+
signed=False,
145+
)
146+
)
147+
/ 1000
148+
for idx in range(cells)
149+
}
150+
151+
@staticmethod
152+
def _temp_sensors(data: bytearray, sensors: int, offs: int) -> dict[str, float]:
153+
return {
154+
f"{KEY_TEMP_VALUE}{idx}": (
155+
int.from_bytes(
156+
data[offs + idx * 2 : offs + (idx + 1) * 2],
157+
byteorder="big",
158+
signed=True,
159+
)
160+
)
161+
/ 10
162+
for idx in range(sensors)
163+
}
164+
165+
async def _async_update(self) -> BMSsample:
166+
"""Update battery status information."""
167+
168+
self._data_final.clear()
169+
self._data_event.clear() # clear event to ensure new data is acquired
170+
await asyncio.wait_for(self._wait_event(), timeout=self.BAT_TIMEOUT)
171+
172+
result: BMSsample = BMS._decode_data(self._data_final)
173+
174+
result |= BMS._cell_voltages(
175+
self._data_final[0xA2],
176+
int(result.get(KEY_CELL_COUNT, 0)),
177+
BMS._CELL_POS + 2,
178+
)
179+
result |= BMS._temp_sensors(
180+
self._data_final[0xA2], int(result.get(KEY_TEMP_SENS, 0)), BMS._TEMP_POS + 2
181+
)
182+
183+
return result

custom_components/bms_ble/plugins/ective_bms.py

+1
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,7 @@ def _calc_values() -> set[str]:
8787
return {
8888
ATTR_BATTERY_CHARGING,
8989
ATTR_CYCLE_CAP,
90+
ATTR_CYCLE_CHRG,
9091
ATTR_DELTA_VOLTAGE,
9192
ATTR_POWER,
9293
ATTR_RUNTIME,

tests/advertisement_data.py

+13
Original file line numberDiff line numberDiff line change
@@ -353,6 +353,19 @@
353353
),
354354
"felicity_bms",
355355
),
356+
( # source LOG, proxy (https://github.com/patman15/BMS_BLE-HA/issues/164#issue-2825586172)
357+
generate_advertisement_data(
358+
local_name="ECO-WORTHY 02_B8EF",
359+
manufacturer_data={49844: b"\xe0\xfa\xb8\xf0"}, # MAC address, correct
360+
service_uuids=[
361+
"00001800-0000-1000-8000-00805f9b34fb",
362+
"00001801-0000-1000-8000-00805f9b34fb",
363+
"0000fff0-0000-1000-8000-00805f9b34fb",
364+
],
365+
rssi=-50,
366+
),
367+
"ecoworthy_bms",
368+
),
356369
( # source BTctl (https://github.com/patman15/BMS_BLE-HA/issues/194)
357370
generate_advertisement_data( # Topband
358371
local_name="ZM20210512010036�",

tests/test_basebms.py

+11-8
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
ATTR_TEMPERATURE,
1616
ATTR_VOLTAGE,
1717
KEY_CELL_VOLTAGE,
18+
KEY_DESIGN_CAP,
1819
KEY_PROBLEM,
1920
)
2021
from custom_components.bms_ble.plugins.basebms import BaseBMS, BMSsample
@@ -36,7 +37,7 @@ def test_calc_missing_values(bms_data_fixture: BMSsample) -> None:
3637
"invalid",
3738
},
3839
)
39-
ref = ref | {
40+
ref: BMSsample = ref | {
4041
ATTR_CYCLE_CAP: 238,
4142
ATTR_DELTA_VOLTAGE: 0.111,
4243
ATTR_POWER: (
@@ -57,13 +58,15 @@ def test_calc_missing_values(bms_data_fixture: BMSsample) -> None:
5758
def test_calc_voltage() -> None:
5859
"""Check if missing data is correctly calculated."""
5960
bms_data = ref = {f"{KEY_CELL_VOLTAGE}0": 3.456, f"{KEY_CELL_VOLTAGE}1": 3.567}
60-
BaseBMS._add_missing_values(
61-
bms_data,
62-
{ATTR_VOLTAGE},
63-
)
64-
ref = ref | {ATTR_VOLTAGE: 7.023}
61+
BaseBMS._add_missing_values(bms_data, {ATTR_VOLTAGE})
62+
assert bms_data == ref | {ATTR_VOLTAGE: 7.023}
6563

66-
assert bms_data == ref
64+
65+
def test_calc_cycle_chrg() -> None:
66+
"""Check if missing data is correctly calculated."""
67+
bms_data = ref = {ATTR_BATTERY_LEVEL: 73, KEY_DESIGN_CAP: 125.0}
68+
BaseBMS._add_missing_values(bms_data, {ATTR_CYCLE_CHRG})
69+
assert bms_data == ref | {ATTR_CYCLE_CHRG: 91.25}
6770

6871

6972
@pytest.fixture(
@@ -76,7 +79,7 @@ def test_calc_voltage() -> None:
7679
({ATTR_CYCLE_CHRG: 0}, "doubtful cycle charge"),
7780
({ATTR_BATTERY_LEVEL: 101}, "doubtful SoC"),
7881
({KEY_PROBLEM: 0x1}, "BMS problem code"),
79-
({ATTR_PROBLEM: True}, "BMS problem report")
82+
({ATTR_PROBLEM: True}, "BMS problem report"),
8083
],
8184
ids=lambda param: param[1],
8285
)

tests/test_const.py

+10-2
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,18 @@
11
"""Test the BLE Battery Management System integration constants definition."""
22

3+
from pathlib import Path
4+
35
from custom_components.bms_ble.const import BMS_TYPES, UPDATE_INTERVAL
46

57

68
async def test_critical_constants() -> None:
79
"""Test general constants are not altered for debugging."""
810

9-
assert UPDATE_INTERVAL == 30 # ensure that update interval is 30 seconds
10-
assert len(BMS_TYPES) == 13 # check number of BMS types
11+
assert ( # ensure that update interval is 30 seconds
12+
UPDATE_INTERVAL == 30
13+
), "Update interval incorrect!"
14+
assert (
15+
len(BMS_TYPES)
16+
== sum(1 for _ in Path("custom_components/bms_ble/plugins/").glob("*_bms.py"))
17+
- 1 # remove dummy_bms
18+
), "missing BMS type in type list!"

0 commit comments

Comments
 (0)