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.
Files changed (37) hide show
  1. aiobmsble/__init__.py +53 -8
  2. aiobmsble/__main__.py +51 -27
  3. aiobmsble/basebms.py +266 -50
  4. aiobmsble/bms/__init__.py +1 -0
  5. aiobmsble/bms/abc_bms.py +164 -0
  6. aiobmsble/bms/ant_bms.py +196 -0
  7. aiobmsble/bms/braunpwr_bms.py +167 -0
  8. aiobmsble/bms/cbtpwr_bms.py +168 -0
  9. aiobmsble/bms/cbtpwr_vb_bms.py +184 -0
  10. aiobmsble/bms/daly_bms.py +164 -0
  11. aiobmsble/bms/dpwrcore_bms.py +207 -0
  12. aiobmsble/bms/dummy_bms.py +89 -0
  13. aiobmsble/bms/ecoworthy_bms.py +151 -0
  14. aiobmsble/bms/ective_bms.py +177 -0
  15. aiobmsble/bms/ej_bms.py +233 -0
  16. aiobmsble/bms/felicity_bms.py +139 -0
  17. aiobmsble/bms/jbd_bms.py +203 -0
  18. aiobmsble/bms/jikong_bms.py +301 -0
  19. aiobmsble/bms/neey_bms.py +214 -0
  20. aiobmsble/bms/ogt_bms.py +214 -0
  21. aiobmsble/bms/pro_bms.py +144 -0
  22. aiobmsble/bms/redodo_bms.py +127 -0
  23. aiobmsble/bms/renogy_bms.py +149 -0
  24. aiobmsble/bms/renogy_pro_bms.py +105 -0
  25. aiobmsble/bms/roypow_bms.py +186 -0
  26. aiobmsble/bms/seplos_bms.py +245 -0
  27. aiobmsble/bms/seplos_v2_bms.py +205 -0
  28. aiobmsble/bms/tdt_bms.py +199 -0
  29. aiobmsble/bms/tianpwr_bms.py +138 -0
  30. aiobmsble/utils.py +96 -6
  31. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/METADATA +23 -14
  32. aiobmsble-0.2.1.dist-info/RECORD +36 -0
  33. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/WHEEL +1 -1
  34. aiobmsble-0.1.0.dist-info/RECORD +0 -10
  35. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/entry_points.txt +0 -0
  36. {aiobmsble-0.1.0.dist-info → aiobmsble-0.2.1.dist-info}/licenses/LICENSE +0 -0
  37. {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 AdvertisementPattern, BMSsample, BMSvalue
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
- TIMEOUT = 5.0
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._log: Final[logging.Logger] = logging.getLogger(
53
- f"{logger_name.replace('.plugins', '')}::{self.name}:"
54
- f"{self._ble_device.address[-5:].replace(':','')})"
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[AdvertisementPattern]:
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: round(max(cell_voltages) - min(cell_voltages), 3),
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(cycle_charge / abs(current) * BaseBMS._HRS_TO_SECS)
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: round(fmean(data.get("temp_values", [])), 3),
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 can_calc(attr, frozenset(required)):
167
- data[attr] = calc_func()
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 <= 0,
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(self) -> None:
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 err:
260
+ except Exception as exc:
215
261
  self._log.info(
216
- "failed to initialize BMS connection (%s)", type(err).__name__
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: BleakGATTCharacteristic | int | str | None = None,
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
- self._log.debug("TX BLE data: %s", data.hex(" "))
230
- self._data_event.clear() # clear event before requesting new data
231
- await self._client.write_gatt_char(char or self.uuid_tx(), data, response=False)
232
- if wait_for_notify:
233
- await asyncio.wait_for(self._wait_event(), timeout=self.TIMEOUT)
234
-
235
- async def disconnect(self) -> None:
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
- if self._client.is_connected:
239
- self._log.debug("disconnecting BMS")
240
- try:
241
- self._data_event.clear()
242
- await self._client.disconnect()
243
- except BleakError:
244
- self._log.warning("disconnect failed!")
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, raw: bool = False) -> BMSsample:
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
- if not raw:
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 CRC."""
313
- return sum(frame) & 0xFF
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."""
@@ -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
+ }