aiobmsble 0.1.0__py3-none-any.whl → 0.2.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aiobmsble/__init__.py +53 -8
- aiobmsble/__main__.py +51 -27
- aiobmsble/basebms.py +266 -50
- aiobmsble/bms/__init__.py +1 -0
- aiobmsble/bms/abc_bms.py +164 -0
- aiobmsble/bms/ant_bms.py +196 -0
- aiobmsble/bms/braunpwr_bms.py +167 -0
- aiobmsble/bms/cbtpwr_bms.py +168 -0
- aiobmsble/bms/cbtpwr_vb_bms.py +184 -0
- aiobmsble/bms/daly_bms.py +164 -0
- aiobmsble/bms/dpwrcore_bms.py +207 -0
- aiobmsble/bms/dummy_bms.py +89 -0
- aiobmsble/bms/ecoworthy_bms.py +151 -0
- aiobmsble/bms/ective_bms.py +177 -0
- aiobmsble/bms/ej_bms.py +233 -0
- aiobmsble/bms/felicity_bms.py +139 -0
- aiobmsble/bms/jbd_bms.py +203 -0
- aiobmsble/bms/jikong_bms.py +301 -0
- aiobmsble/bms/neey_bms.py +214 -0
- aiobmsble/bms/ogt_bms.py +214 -0
- aiobmsble/bms/pro_bms.py +144 -0
- aiobmsble/bms/redodo_bms.py +127 -0
- aiobmsble/bms/renogy_bms.py +149 -0
- aiobmsble/bms/renogy_pro_bms.py +105 -0
- aiobmsble/bms/roypow_bms.py +186 -0
- aiobmsble/bms/seplos_bms.py +245 -0
- aiobmsble/bms/seplos_v2_bms.py +205 -0
- aiobmsble/bms/tdt_bms.py +199 -0
- aiobmsble/bms/tianpwr_bms.py +138 -0
- aiobmsble/utils.py +96 -6
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/METADATA +23 -14
- aiobmsble-0.2.1.dist-info/RECORD +36 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/WHEEL +1 -1
- aiobmsble-0.1.0.dist-info/RECORD +0 -10
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/entry_points.txt +0 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/licenses/LICENSE +0 -0
- {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/top_level.txt +0 -0
aiobmsble/basebms.py
CHANGED
@@ -2,34 +2,46 @@
|
|
2
2
|
|
3
3
|
from abc import ABC, abstractmethod
|
4
4
|
import asyncio
|
5
|
-
from collections.abc import Callable
|
5
|
+
from collections.abc import Callable, MutableMapping
|
6
6
|
import logging
|
7
7
|
from statistics import fmean
|
8
|
-
from typing import Any, Final
|
8
|
+
from typing import Any, Final, Literal
|
9
9
|
|
10
10
|
from bleak import BleakClient
|
11
11
|
from bleak.backends.characteristic import BleakGATTCharacteristic
|
12
12
|
from bleak.backends.device import BLEDevice
|
13
13
|
from bleak.exc import BleakError
|
14
|
-
from bleak_retry_connector import establish_connection
|
14
|
+
from bleak_retry_connector import BLEAK_TIMEOUT, establish_connection
|
15
15
|
|
16
|
-
from aiobmsble import
|
17
|
-
|
18
|
-
KEY_CELL_VOLTAGE: Final[str] = "cell#" # [V]
|
16
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
19
17
|
|
20
18
|
|
21
19
|
class BaseBMS(ABC):
|
22
20
|
"""Abstract base class for battery management system."""
|
23
21
|
|
24
|
-
|
22
|
+
MAX_RETRY: Final[int] = 3 # max number of retries for data requests
|
23
|
+
TIMEOUT: Final[float] = BLEAK_TIMEOUT / 4 # default timeout for BMS operations
|
24
|
+
# calculate time between retries to complete all retries (2 modes) in TIMEOUT seconds
|
25
|
+
_RETRY_TIMEOUT: Final[float] = TIMEOUT / (2**MAX_RETRY - 1)
|
26
|
+
_MAX_TIMEOUT_FACTOR: Final[int] = 8 # limit timout increase to 8x
|
25
27
|
_MAX_CELL_VOLT: Final[float] = 5.906 # max cell potential
|
26
28
|
_HRS_TO_SECS: Final[int] = 60 * 60 # seconds in an hour
|
27
29
|
|
30
|
+
class PrefixAdapter(logging.LoggerAdapter):
|
31
|
+
"""Logging adpater to add instance ID to each log message."""
|
32
|
+
|
33
|
+
def process(
|
34
|
+
self, msg: str, kwargs: MutableMapping[str, Any]
|
35
|
+
) -> tuple[str, MutableMapping[str, Any]]:
|
36
|
+
"""Process the logging message."""
|
37
|
+
prefix: str = str(self.extra.get("prefix") if self.extra else "")
|
38
|
+
return (f"{prefix} {msg}", kwargs)
|
39
|
+
|
28
40
|
def __init__(
|
29
41
|
self,
|
30
|
-
logger_name: str,
|
31
42
|
ble_device: BLEDevice,
|
32
43
|
reconnect: bool = False,
|
44
|
+
logger_name: str = "",
|
33
45
|
) -> None:
|
34
46
|
"""Intialize the BMS.
|
35
47
|
|
@@ -49,9 +61,11 @@ class BaseBMS(ABC):
|
|
49
61
|
self._ble_device: Final[BLEDevice] = ble_device
|
50
62
|
self._reconnect: Final[bool] = reconnect
|
51
63
|
self.name: Final[str] = self._ble_device.name or "undefined"
|
52
|
-
self.
|
53
|
-
|
54
|
-
|
64
|
+
self._inv_wr_mode: bool | None = None # invert write mode (WNR <-> W)
|
65
|
+
logger_name = logger_name or self.__class__.__module__
|
66
|
+
self._log: Final[BaseBMS.PrefixAdapter] = BaseBMS.PrefixAdapter(
|
67
|
+
logging.getLogger(f"{logger_name}"),
|
68
|
+
{"prefix": f"{self.name}|{self._ble_device.address[-5:].replace(':','')}:"},
|
55
69
|
)
|
56
70
|
|
57
71
|
self._log.debug(
|
@@ -67,7 +81,7 @@ class BaseBMS(ABC):
|
|
67
81
|
|
68
82
|
@staticmethod
|
69
83
|
@abstractmethod
|
70
|
-
def matcher_dict_list() -> list[
|
84
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
71
85
|
"""Return a list of Bluetooth advertisement matchers."""
|
72
86
|
|
73
87
|
@staticmethod
|
@@ -126,55 +140,78 @@ class BaseBMS(ABC):
|
|
126
140
|
return (value in values) and (value not in data) and using.issubset(data)
|
127
141
|
|
128
142
|
cell_voltages: Final[list[float]] = data.get("cell_voltages", [])
|
129
|
-
design_capacity: Final[int | float] = data.get("design_capacity", 0)
|
130
143
|
battery_level: Final[int | float] = data.get("battery_level", 0)
|
131
|
-
voltage: Final[float] = data.get("voltage", 0)
|
132
|
-
cycle_charge: Final[int | float] = data.get("cycle_charge", 0)
|
133
144
|
current: Final[float] = data.get("current", 0)
|
134
145
|
|
135
146
|
calculations: dict[BMSvalue, tuple[set[BMSvalue], Callable[[], Any]]] = {
|
136
147
|
"voltage": ({"cell_voltages"}, lambda: round(sum(cell_voltages), 3)),
|
137
148
|
"delta_voltage": (
|
138
149
|
{"cell_voltages"},
|
139
|
-
lambda:
|
150
|
+
lambda: (
|
151
|
+
round(max(cell_voltages) - min(cell_voltages), 3)
|
152
|
+
if len(cell_voltages)
|
153
|
+
else None
|
154
|
+
),
|
140
155
|
),
|
141
156
|
"cycle_charge": (
|
142
157
|
{"design_capacity", "battery_level"},
|
143
|
-
lambda: (design_capacity * battery_level) / 100,
|
158
|
+
lambda: (data.get("design_capacity", 0) * battery_level) / 100,
|
159
|
+
),
|
160
|
+
"battery_level": (
|
161
|
+
{"design_capacity", "cycle_charge"},
|
162
|
+
lambda: round(
|
163
|
+
data.get("cycle_charge", 0) / data.get("design_capacity", 0) * 100,
|
164
|
+
1,
|
165
|
+
),
|
144
166
|
),
|
145
167
|
"cycle_capacity": (
|
146
168
|
{"voltage", "cycle_charge"},
|
147
|
-
lambda: voltage * cycle_charge,
|
169
|
+
lambda: round(data.get("voltage", 0) * data.get("cycle_charge", 0), 3),
|
170
|
+
),
|
171
|
+
"power": (
|
172
|
+
{"voltage", "current"},
|
173
|
+
lambda: round(data.get("voltage", 0) * current, 3),
|
148
174
|
),
|
149
|
-
"power": ({"voltage", "current"}, lambda: round(voltage * current, 3)),
|
150
175
|
"battery_charging": ({"current"}, lambda: current > 0),
|
151
176
|
"runtime": (
|
152
177
|
{"current", "cycle_charge"},
|
153
178
|
lambda: (
|
154
|
-
int(
|
179
|
+
int(
|
180
|
+
data.get("cycle_charge", 0)
|
181
|
+
/ abs(current)
|
182
|
+
* BaseBMS._HRS_TO_SECS
|
183
|
+
)
|
155
184
|
if current < 0
|
156
185
|
else None
|
157
186
|
),
|
158
187
|
),
|
159
188
|
"temperature": (
|
160
189
|
{"temp_values"},
|
161
|
-
lambda:
|
190
|
+
lambda: (
|
191
|
+
round(fmean(data.get("temp_values", [])), 3)
|
192
|
+
if data.get("temp_values")
|
193
|
+
else None
|
194
|
+
),
|
162
195
|
),
|
163
196
|
}
|
164
197
|
|
165
198
|
for attr, (required, calc_func) in calculations.items():
|
166
|
-
if
|
167
|
-
|
199
|
+
if (
|
200
|
+
can_calc(attr, frozenset(required))
|
201
|
+
and (value := calc_func()) is not None
|
202
|
+
):
|
203
|
+
data[attr] = value
|
168
204
|
|
169
205
|
# do sanity check on values to set problem state
|
170
206
|
data["problem"] = any(
|
171
207
|
[
|
172
208
|
data.get("problem", False),
|
173
209
|
data.get("problem_code", False),
|
174
|
-
voltage <= 0,
|
210
|
+
data.get("voltage") is not None and data.get("voltage", 0) <= 0,
|
175
211
|
any(v <= 0 or v > BaseBMS._MAX_CELL_VOLT for v in cell_voltages),
|
176
212
|
data.get("delta_voltage", 0) > BaseBMS._MAX_CELL_VOLT,
|
177
|
-
cycle_charge
|
213
|
+
data.get("cycle_charge") is not None
|
214
|
+
and data.get("cycle_charge", 0.0) <= 0.0,
|
178
215
|
battery_level > 100,
|
179
216
|
]
|
180
217
|
)
|
@@ -184,13 +221,15 @@ class BaseBMS(ABC):
|
|
184
221
|
|
185
222
|
self._log.debug("disconnected from BMS")
|
186
223
|
|
187
|
-
async def _init_connection(
|
224
|
+
async def _init_connection(
|
225
|
+
self, char_notify: BleakGATTCharacteristic | int | str | None = None
|
226
|
+
) -> None:
|
188
227
|
# reset any stale data from BMS
|
189
228
|
self._data.clear()
|
190
229
|
self._data_event.clear()
|
191
230
|
|
192
231
|
await self._client.start_notify(
|
193
|
-
self.uuid_rx(), getattr(self, "_notification_handler")
|
232
|
+
char_notify or self.uuid_rx(), getattr(self, "_notification_handler")
|
194
233
|
)
|
195
234
|
|
196
235
|
async def _connect(self) -> None:
|
@@ -200,6 +239,13 @@ class BaseBMS(ABC):
|
|
200
239
|
self._log.debug("BMS already connected")
|
201
240
|
return
|
202
241
|
|
242
|
+
try:
|
243
|
+
await self._client.disconnect() # ensure no stale connection exists
|
244
|
+
except (BleakError, TimeoutError) as exc:
|
245
|
+
self._log.debug(
|
246
|
+
"failed to disconnect stale connection (%s)", type(exc).__name__
|
247
|
+
)
|
248
|
+
|
203
249
|
self._log.debug("connecting BMS")
|
204
250
|
self._client = await establish_connection(
|
205
251
|
client_class=BleakClient,
|
@@ -211,37 +257,98 @@ class BaseBMS(ABC):
|
|
211
257
|
|
212
258
|
try:
|
213
259
|
await self._init_connection()
|
214
|
-
except Exception as
|
260
|
+
except Exception as exc:
|
215
261
|
self._log.info(
|
216
|
-
"failed to initialize BMS connection (%s)", type(
|
262
|
+
"failed to initialize BMS connection (%s)", type(exc).__name__
|
217
263
|
)
|
218
264
|
await self.disconnect()
|
219
265
|
raise
|
220
266
|
|
267
|
+
def _wr_response(self, char: int | str) -> bool:
|
268
|
+
char_tx: Final[BleakGATTCharacteristic | None] = (
|
269
|
+
self._client.services.get_characteristic(char)
|
270
|
+
)
|
271
|
+
return bool(char_tx and "write" in getattr(char_tx, "properties", []))
|
272
|
+
|
273
|
+
async def _send_msg(
|
274
|
+
self,
|
275
|
+
data: bytes,
|
276
|
+
max_size: int,
|
277
|
+
char: int | str,
|
278
|
+
attempt: int,
|
279
|
+
inv_wr_mode: bool = False,
|
280
|
+
) -> None:
|
281
|
+
"""Send message to the bms in chunks if needed."""
|
282
|
+
chunk_size: Final[int] = max_size or len(data)
|
283
|
+
|
284
|
+
for i in range(0, len(data), chunk_size):
|
285
|
+
chunk: bytes = data[i : i + chunk_size]
|
286
|
+
self._log.debug(
|
287
|
+
"TX BLE req #%i (%s%s%s): %s",
|
288
|
+
attempt + 1,
|
289
|
+
"!" if inv_wr_mode else "",
|
290
|
+
"W" if self._wr_response(char) else "WNR",
|
291
|
+
"." if self._inv_wr_mode is not None else "",
|
292
|
+
chunk.hex(" "),
|
293
|
+
)
|
294
|
+
await self._client.write_gatt_char(
|
295
|
+
char,
|
296
|
+
chunk,
|
297
|
+
response=(self._wr_response(char) != inv_wr_mode),
|
298
|
+
)
|
299
|
+
|
221
300
|
async def _await_reply(
|
222
301
|
self,
|
223
302
|
data: bytes,
|
224
|
-
char:
|
303
|
+
char: int | str | None = None,
|
225
304
|
wait_for_notify: bool = True,
|
305
|
+
max_size: int = 0,
|
226
306
|
) -> None:
|
227
307
|
"""Send data to the BMS and wait for valid reply notification."""
|
228
308
|
|
229
|
-
|
230
|
-
|
231
|
-
|
232
|
-
|
233
|
-
|
234
|
-
|
235
|
-
|
309
|
+
for inv_wr_mode in (
|
310
|
+
[False, True] if self._inv_wr_mode is None else [self._inv_wr_mode]
|
311
|
+
):
|
312
|
+
try:
|
313
|
+
self._data_event.clear() # clear event before requesting new data
|
314
|
+
for attempt in range(BaseBMS.MAX_RETRY):
|
315
|
+
await self._send_msg(
|
316
|
+
data, max_size, char or self.uuid_tx(), attempt, inv_wr_mode
|
317
|
+
)
|
318
|
+
if not wait_for_notify:
|
319
|
+
return # write without wait for response selected
|
320
|
+
try:
|
321
|
+
await asyncio.wait_for(
|
322
|
+
self._wait_event(),
|
323
|
+
BaseBMS._RETRY_TIMEOUT
|
324
|
+
* min(2**attempt, BaseBMS._MAX_TIMEOUT_FACTOR),
|
325
|
+
)
|
326
|
+
except TimeoutError:
|
327
|
+
self._log.debug("TX BLE request timed out.")
|
328
|
+
continue # retry sending data
|
329
|
+
|
330
|
+
self._inv_wr_mode = inv_wr_mode
|
331
|
+
return # leave loop if no exception
|
332
|
+
except BleakError as exc:
|
333
|
+
# reconnect on communication errors
|
334
|
+
self._log.warning(
|
335
|
+
"TX BLE request error, retrying connection (%s)", type(exc).__name__
|
336
|
+
)
|
337
|
+
await self.disconnect()
|
338
|
+
await self._connect()
|
339
|
+
raise TimeoutError
|
340
|
+
|
341
|
+
async def disconnect(self, reset: bool = False) -> None:
|
236
342
|
"""Disconnect the BMS, includes stoping notifications."""
|
237
343
|
|
238
|
-
|
239
|
-
|
240
|
-
|
241
|
-
|
242
|
-
|
243
|
-
|
244
|
-
|
344
|
+
self._log.debug("disconnecting BMS (%s)", str(self._client.is_connected))
|
345
|
+
try:
|
346
|
+
self._data_event.clear()
|
347
|
+
if reset:
|
348
|
+
self._inv_wr_mode = None # reset write mode
|
349
|
+
await self._client.disconnect()
|
350
|
+
except BleakError:
|
351
|
+
self._log.warning("disconnect failed!")
|
245
352
|
|
246
353
|
async def _wait_event(self) -> None:
|
247
354
|
"""Wait for data event and clear it."""
|
@@ -252,7 +359,7 @@ class BaseBMS(ABC):
|
|
252
359
|
async def _async_update(self) -> BMSsample:
|
253
360
|
"""Return a dictionary of BMS values (keys need to come from the SENSOR_TYPES list)."""
|
254
361
|
|
255
|
-
async def async_update(self
|
362
|
+
async def async_update(self) -> BMSsample:
|
256
363
|
"""Retrieve updated values from the BMS using method of the subclass.
|
257
364
|
|
258
365
|
Args:
|
@@ -266,8 +373,7 @@ class BaseBMS(ABC):
|
|
266
373
|
await self._connect()
|
267
374
|
|
268
375
|
data: BMSsample = await self._async_update()
|
269
|
-
|
270
|
-
self._add_missing_values(data, self._calc_values())
|
376
|
+
self._add_missing_values(data, self._calc_values())
|
271
377
|
|
272
378
|
if self._reconnect:
|
273
379
|
# disconnect after data update to force reconnect next time (slow!)
|
@@ -275,6 +381,107 @@ class BaseBMS(ABC):
|
|
275
381
|
|
276
382
|
return data
|
277
383
|
|
384
|
+
@staticmethod
|
385
|
+
def _decode_data(
|
386
|
+
fields: tuple[BMSdp, ...],
|
387
|
+
data: bytearray | dict[int, bytearray],
|
388
|
+
*,
|
389
|
+
byteorder: Literal["little", "big"] = "big",
|
390
|
+
offset: int = 0,
|
391
|
+
) -> BMSsample:
|
392
|
+
result: BMSsample = {}
|
393
|
+
for field in fields:
|
394
|
+
if isinstance(data, dict) and field.idx not in data:
|
395
|
+
continue
|
396
|
+
msg: bytearray = data[field.idx] if isinstance(data, dict) else data
|
397
|
+
result[field.key] = field.fct(
|
398
|
+
int.from_bytes(
|
399
|
+
msg[offset + field.pos : offset + field.pos + field.size],
|
400
|
+
byteorder=byteorder,
|
401
|
+
signed=field.signed,
|
402
|
+
)
|
403
|
+
)
|
404
|
+
return result
|
405
|
+
|
406
|
+
@staticmethod
|
407
|
+
def _cell_voltages(
|
408
|
+
data: bytearray,
|
409
|
+
*,
|
410
|
+
cells: int,
|
411
|
+
start: int,
|
412
|
+
size: int = 2,
|
413
|
+
byteorder: Literal["little", "big"] = "big",
|
414
|
+
divider: int = 1000,
|
415
|
+
) -> list[float]:
|
416
|
+
"""Return cell voltages from BMS message.
|
417
|
+
|
418
|
+
Args:
|
419
|
+
data: Raw data from BMS
|
420
|
+
cells: Number of cells to read
|
421
|
+
start: Start position in data array
|
422
|
+
size: Number of bytes per cell value (defaults 2)
|
423
|
+
byteorder: Byte order ("big"/"little" endian)
|
424
|
+
divider: Value to divide raw value by, defaults to 1000 (mv to V)
|
425
|
+
|
426
|
+
Returns:
|
427
|
+
list[float]: List of cell voltages in volts
|
428
|
+
|
429
|
+
"""
|
430
|
+
return [
|
431
|
+
value / divider
|
432
|
+
for idx in range(cells)
|
433
|
+
if (len(data) >= start + (idx + 1) * size)
|
434
|
+
and (
|
435
|
+
value := int.from_bytes(
|
436
|
+
data[start + idx * size : start + (idx + 1) * size],
|
437
|
+
byteorder=byteorder,
|
438
|
+
signed=False,
|
439
|
+
)
|
440
|
+
)
|
441
|
+
]
|
442
|
+
|
443
|
+
@staticmethod
|
444
|
+
def _temp_values(
|
445
|
+
data: bytearray,
|
446
|
+
*,
|
447
|
+
values: int,
|
448
|
+
start: int,
|
449
|
+
size: int = 2,
|
450
|
+
byteorder: Literal["little", "big"] = "big",
|
451
|
+
signed: bool = True,
|
452
|
+
offset: float = 0,
|
453
|
+
divider: int = 1,
|
454
|
+
) -> list[int | float]:
|
455
|
+
"""Return temperature values from BMS message.
|
456
|
+
|
457
|
+
Args:
|
458
|
+
data: Raw data from BMS
|
459
|
+
values: Number of values to read
|
460
|
+
start: Start position in data array
|
461
|
+
size: Number of bytes per cell value (defaults 2)
|
462
|
+
byteorder: Byte order ("big"/"little" endian)
|
463
|
+
signed: Indicates whether two's complement is used to represent the integer.
|
464
|
+
offset: The offset read values are shifted by (for Kelvin use 273.15)
|
465
|
+
divider: Value to divide raw value by, defaults to 1000 (mv to V)
|
466
|
+
|
467
|
+
Returns:
|
468
|
+
list[int | float]: List of temperature values
|
469
|
+
|
470
|
+
"""
|
471
|
+
return [
|
472
|
+
value / divider if divider != 1 else value
|
473
|
+
for idx in range(values)
|
474
|
+
if (len(data) >= start + (idx + 1) * size)
|
475
|
+
and (
|
476
|
+
value := int.from_bytes(
|
477
|
+
data[start + idx * size : start + (idx + 1) * size],
|
478
|
+
byteorder=byteorder,
|
479
|
+
signed=signed,
|
480
|
+
)
|
481
|
+
- offset
|
482
|
+
)
|
483
|
+
]
|
484
|
+
|
278
485
|
|
279
486
|
def crc_modbus(data: bytearray) -> int:
|
280
487
|
"""Calculate CRC-16-CCITT MODBUS."""
|
@@ -286,6 +493,11 @@ def crc_modbus(data: bytearray) -> int:
|
|
286
493
|
return crc & 0xFFFF
|
287
494
|
|
288
495
|
|
496
|
+
def lrc_modbus(data: bytearray) -> int:
|
497
|
+
"""Calculate MODBUS LRC."""
|
498
|
+
return ((sum(data) ^ 0xFFFF) + 1) & 0xFFFF
|
499
|
+
|
500
|
+
|
289
501
|
def crc_xmodem(data: bytearray) -> int:
|
290
502
|
"""Calculate CRC-16-CCITT XMODEM."""
|
291
503
|
crc: int = 0x0000
|
@@ -308,6 +520,10 @@ def crc8(data: bytearray) -> int:
|
|
308
520
|
return crc & 0xFF
|
309
521
|
|
310
522
|
|
311
|
-
def crc_sum(frame: bytearray) -> int:
|
312
|
-
"""Calculate frame
|
313
|
-
|
523
|
+
def crc_sum(frame: bytearray, size: int = 1) -> int:
|
524
|
+
"""Calculate the checksum of a frame using a specified size.
|
525
|
+
|
526
|
+
size : int, optional
|
527
|
+
The size of the checksum in bytes (default is 1).
|
528
|
+
"""
|
529
|
+
return sum(frame) & ((1 << (8 * size)) - 1)
|
@@ -0,0 +1 @@
|
|
1
|
+
"""Package for battery management systems (BMS) plugins."""
|
aiobmsble/bms/abc_bms.py
ADDED
@@ -0,0 +1,164 @@
|
|
1
|
+
"""Module to support ABC BMS."""
|
2
|
+
|
3
|
+
import contextlib
|
4
|
+
from typing import Final
|
5
|
+
|
6
|
+
from bleak.backends.characteristic import BleakGATTCharacteristic
|
7
|
+
from bleak.backends.device import BLEDevice
|
8
|
+
from bleak.uuids import normalize_uuid_str
|
9
|
+
|
10
|
+
from aiobmsble import BMSdp, BMSsample, BMSvalue, MatcherPattern
|
11
|
+
from aiobmsble.basebms import BaseBMS, crc8
|
12
|
+
|
13
|
+
|
14
|
+
class BMS(BaseBMS):
|
15
|
+
"""ABC BMS implementation."""
|
16
|
+
|
17
|
+
_HEAD_CMD: Final[int] = 0xEE
|
18
|
+
_HEAD_RESP: Final[bytes] = b"\xcc"
|
19
|
+
_INFO_LEN: Final[int] = 0x14
|
20
|
+
_EXP_REPLY: Final[dict[int, set[int]]] = { # wait for these replies
|
21
|
+
0xC0: {0xF1},
|
22
|
+
0xC1: {0xF0, 0xF2},
|
23
|
+
0xC2: {0xF0, 0xF3, 0xF4}, # 4 cells per F4 message
|
24
|
+
0xC3: {0xF5, 0xF6, 0xF7, 0xF8, 0xFA},
|
25
|
+
0xC4: {0xF9},
|
26
|
+
}
|
27
|
+
_FIELDS: Final[tuple[BMSdp, ...]] = (
|
28
|
+
BMSdp("temp_sensors", 4, 1, False, lambda x: x, 0xF2),
|
29
|
+
BMSdp("voltage", 2, 3, False, lambda x: x / 1000, 0xF0),
|
30
|
+
BMSdp("current", 5, 3, True, lambda x: x / 1000, 0xF0),
|
31
|
+
# ("design_capacity", 8, 3, False, lambda x: x / 1000, 0xF0),
|
32
|
+
BMSdp("battery_level", 16, 1, False, lambda x: x, 0xF0),
|
33
|
+
BMSdp("cycle_charge", 11, 3, False, lambda x: x / 1000, 0xF0),
|
34
|
+
BMSdp("cycles", 14, 2, False, lambda x: x, 0xF0),
|
35
|
+
BMSdp( # only first bit per byte is used
|
36
|
+
"problem_code",
|
37
|
+
2,
|
38
|
+
16,
|
39
|
+
False,
|
40
|
+
lambda x: sum(((x >> (i * 8)) & 1) << i for i in range(16)),
|
41
|
+
0xF9,
|
42
|
+
),
|
43
|
+
)
|
44
|
+
_RESPS: Final[set[int]] = {field.idx for field in _FIELDS} | {0xF4} # cell voltages
|
45
|
+
|
46
|
+
def __init__(self, ble_device: BLEDevice, reconnect: bool = False) -> None:
|
47
|
+
"""Initialize BMS."""
|
48
|
+
super().__init__(ble_device, reconnect)
|
49
|
+
self._data_final: dict[int, bytearray] = {}
|
50
|
+
self._exp_reply: set[int] = set()
|
51
|
+
|
52
|
+
@staticmethod
|
53
|
+
def matcher_dict_list() -> list[MatcherPattern]:
|
54
|
+
"""Provide BluetoothMatcher definition."""
|
55
|
+
return [
|
56
|
+
{
|
57
|
+
"local_name": pattern,
|
58
|
+
"service_uuid": normalize_uuid_str("fff0"),
|
59
|
+
"connectable": True,
|
60
|
+
}
|
61
|
+
for pattern in ("ABC-*", "SOK-*") # "NB-*", "Hoover",
|
62
|
+
]
|
63
|
+
|
64
|
+
@staticmethod
|
65
|
+
def device_info() -> dict[str, str]:
|
66
|
+
"""Return device information for the battery management system."""
|
67
|
+
return {"manufacturer": "Chunguang Song", "model": "ABC BMS"}
|
68
|
+
|
69
|
+
@staticmethod
|
70
|
+
def uuid_services() -> list[str]:
|
71
|
+
"""Return list of 128-bit UUIDs of services required by BMS."""
|
72
|
+
return [normalize_uuid_str("ffe0")]
|
73
|
+
|
74
|
+
@staticmethod
|
75
|
+
def uuid_rx() -> str:
|
76
|
+
"""Return 16-bit UUID of characteristic that provides notification/read property."""
|
77
|
+
return "ffe1"
|
78
|
+
|
79
|
+
@staticmethod
|
80
|
+
def uuid_tx() -> str:
|
81
|
+
"""Return 16-bit UUID of characteristic that provides write property."""
|
82
|
+
return "ffe2"
|
83
|
+
|
84
|
+
@staticmethod
|
85
|
+
def _calc_values() -> frozenset[BMSvalue]:
|
86
|
+
return frozenset(
|
87
|
+
{
|
88
|
+
"battery_charging",
|
89
|
+
"cycle_capacity",
|
90
|
+
"delta_voltage",
|
91
|
+
"power",
|
92
|
+
"runtime",
|
93
|
+
"temperature",
|
94
|
+
}
|
95
|
+
) # calculate further values from BMS provided set ones
|
96
|
+
|
97
|
+
def _notification_handler(
|
98
|
+
self, _sender: BleakGATTCharacteristic, data: bytearray
|
99
|
+
) -> None:
|
100
|
+
"""Handle the RX characteristics notify event (new data arrives)."""
|
101
|
+
self._log.debug("RX BLE data: %s", data)
|
102
|
+
|
103
|
+
if not data.startswith(BMS._HEAD_RESP):
|
104
|
+
self._log.debug("Incorrect frame start")
|
105
|
+
return
|
106
|
+
|
107
|
+
if len(data) != BMS._INFO_LEN:
|
108
|
+
self._log.debug("Incorrect frame length")
|
109
|
+
return
|
110
|
+
|
111
|
+
if (crc := crc8(data[:-1])) != data[-1]:
|
112
|
+
self._log.debug("invalid checksum 0x%X != 0x%X", data[-1], crc)
|
113
|
+
return
|
114
|
+
|
115
|
+
if data[1] == 0xF4 and 0xF4 in self._data_final:
|
116
|
+
# expand cell voltage frame with all parts
|
117
|
+
self._data_final[0xF4] = bytearray(self._data_final[0xF4][:-2] + data[2:])
|
118
|
+
else:
|
119
|
+
self._data_final[data[1]] = data.copy()
|
120
|
+
|
121
|
+
self._exp_reply.discard(data[1])
|
122
|
+
|
123
|
+
if not self._exp_reply: # check if all expected replies are received
|
124
|
+
self._data_event.set()
|
125
|
+
|
126
|
+
@staticmethod
|
127
|
+
def _cmd(cmd: bytes) -> bytes:
|
128
|
+
"""Assemble a ABC BMS command."""
|
129
|
+
frame = bytearray([BMS._HEAD_CMD, cmd[0], 0x00, 0x00, 0x00])
|
130
|
+
frame.append(crc8(frame))
|
131
|
+
return bytes(frame)
|
132
|
+
|
133
|
+
async def _async_update(self) -> BMSsample:
|
134
|
+
"""Update battery status information."""
|
135
|
+
self._data_final.clear()
|
136
|
+
for cmd in (0xC1, 0xC2, 0xC4):
|
137
|
+
self._exp_reply.update(BMS._EXP_REPLY[cmd])
|
138
|
+
with contextlib.suppress(TimeoutError):
|
139
|
+
await self._await_reply(BMS._cmd(bytes([cmd])))
|
140
|
+
|
141
|
+
# check all repsonses are here, 0xF9 is not mandatory (not all BMS report it)
|
142
|
+
self._data_final.setdefault(0xF9, bytearray())
|
143
|
+
if not BMS._RESPS.issubset(set(self._data_final.keys())):
|
144
|
+
self._log.debug("Incomplete data set %s", self._data_final.keys())
|
145
|
+
raise TimeoutError("BMS data incomplete.")
|
146
|
+
|
147
|
+
result: BMSsample = BMS._decode_data(
|
148
|
+
BMS._FIELDS, self._data_final, byteorder="little"
|
149
|
+
)
|
150
|
+
return result | {
|
151
|
+
"cell_voltages": BMS._cell_voltages( # every second value is the cell idx
|
152
|
+
self._data_final[0xF4],
|
153
|
+
cells=(len(self._data_final[0xF4]) - 4) // 2,
|
154
|
+
start=3,
|
155
|
+
byteorder="little",
|
156
|
+
size=2,
|
157
|
+
)[::2],
|
158
|
+
"temp_values": BMS._temp_values(
|
159
|
+
self._data_final[0xF2],
|
160
|
+
start=5,
|
161
|
+
values=result.get("temp_sensors", 0),
|
162
|
+
byteorder="little",
|
163
|
+
),
|
164
|
+
}
|